From 743851b0b55dbcd9653a009d33885066e219dcc5 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 05:35:19 -0500
Subject: [PATCH 01/62] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5120)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../src/commonMain/composeResources/values-bg/strings.xml | 1 +
.../src/commonMain/composeResources/values-cs/strings.xml | 1 +
.../src/commonMain/composeResources/values-de/strings.xml | 1 +
.../src/commonMain/composeResources/values-et/strings.xml | 1 +
.../src/commonMain/composeResources/values-fi/strings.xml | 4 ++++
.../src/commonMain/composeResources/values-fr/strings.xml | 1 +
.../src/commonMain/composeResources/values-it/strings.xml | 1 +
.../src/commonMain/composeResources/values-pl/strings.xml | 1 +
.../src/commonMain/composeResources/values-ro/strings.xml | 1 +
.../src/commonMain/composeResources/values-ru/strings.xml | 1 +
.../src/commonMain/composeResources/values-sv/strings.xml | 1 +
.../src/commonMain/composeResources/values-uk/strings.xml | 1 +
.../src/commonMain/composeResources/values-zh-rCN/strings.xml | 1 +
.../src/commonMain/composeResources/values-zh-rTW/strings.xml | 1 +
14 files changed, 17 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index ff2ceced6..56f32b1ba 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -250,6 +250,7 @@
Съобщението е доставено
Устройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките.
Грешка
+ Неизвестна грешка
Игнорирай
Премахване от игнорирани
Добави '%1$s' към списъка с игнорирани?
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index 51e156e5d..868a84993 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -263,6 +263,7 @@
Doručeno
Vaše zařízení se může odpojit a restartovat při aplikaci nastavení.
Chyba
+ Neznámá chyba
Ignorovat
Odstranit z ignorovaných
Přidat '%1$s' do seznamu ignorovaných?
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index a358cb984..01c1aaa2a 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -302,6 +302,7 @@
Zustellung bestätigt
Ihr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden.
Fehler
+ Unbekannter Fehler
Ignorieren
Aus Ignorierliste entfernen
'%1$s' zur Ignorieren-Liste hinzufügen?
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 969d46acb..4b8e5a879 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -302,6 +302,7 @@
Kohale toimetatud
Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda.
Viga
+ Tundmatu viga
Eira
Eemalda ignoreeritute hulgast
Lisa '%1$s' eiramis loendisse?
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 98a2fc84c..504b821b2 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -302,6 +302,7 @@
Toimitus vahvistettu
Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön.
Virhe
+ Tuntematon virhe
Jätä huomiotta
Poista huomioimattomista
Lisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen.
@@ -581,6 +582,9 @@
Ulostulon kesto (millisekuntia)
Hälytysaikakatkaisu (sekuntia)
Soittoääni
+ Tuotu soittoääni
+ Tiedosto on tyhjä
+ Virhe tuotaessa: %1$s
Aloita
Käytä I2S protokollaa äänimerkille
LoRa
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index fe1a9aaef..16da56ad7 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -276,6 +276,7 @@
Reconfiguration de NodeDB
Réception confirmée par le destinataire
Erreur
+ Une erreur inconnue s'est produite
Ignorer
Supprimer des ignorés
Ajouter '%1$s' à la liste des ignorés ? Votre radio va redémarrer après avoir effectué ce changement.
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 8e9066c22..406626027 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -284,6 +284,7 @@
Consegna confermata
Il dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni.
Errore
+ Errore sconosciuto
Ignora
Rimuovi da ignorati
Aggiungere '%1$s' alla lista degli ignorati?
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 448e7eaac..64f32551d 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -268,6 +268,7 @@
Zresetuj NodeDB
Dostarczono
Błąd
+ Nieznany błąd
Zignoruj
Usuń z listy ignorowanych
Dodać '%1$s' do listy ignorowanych?
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 440302ec3..ff5de3636 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -302,6 +302,7 @@
Livrare confirmată
Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate.
Eroare
+ Eroare necunoscuta
Ignoră
Eliminați din lista ignorate
Adaugă '%1$s' in lista de ignor? Radioul tău va reporni după ce această modificare.
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index b414c046c..a201c1dc8 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -308,6 +308,7 @@
Доставка подтверждена
Ваше устройство может отключиться и перезагрузиться во время применения настроек.
Ошибка
+ Неизвестная ошибка
Игнорировать
Удалить из игнорируемых
Добавить '%1$s' в список игнорируемых?
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index da0bb8d4f..fce685c0a 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -272,6 +272,7 @@
Nollställ NodeDB
Sändning bekräftad
Fel
+ Okänt fel
Ignorera
Ta bort från ignorerade
Lägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring.
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 2c885d5e5..e92552e55 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -222,6 +222,7 @@
Очищення бази вузлів
Доставку підтверджено
Помилка
+ Невідома помилка
Ігнорувати
Вилучити з ігнорованих
Додати '%1$s' до чорного списку? Після цієї зміни ваш пристрій перезавантажиться.
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index f7c3d5e92..bfb4e6fc0 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -289,6 +289,7 @@
已送达
在应用设置时,您的设备可能会断开连接并重启。
错误
+ 未知错误
忽略
从忽略中删除
添加 '%1$s' 到忽略列表?
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index fb6856a0e..b4d05cfdb 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -273,6 +273,7 @@
已確認送達
在設定套用的過程中,您的裝置可能會斷開連線並重新啟動。
錯誤
+ 未知錯誤
忽略
從忽略清單中移除
將 '%1$s' 加入忽略清單嗎?
From 3c7e1266f819f90df2bfca6717cd1df0414d6c3a Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 06:01:03 -0500
Subject: [PATCH 02/62] fix: truncate traceroute chart x-values to whole
seconds to prevent Vico crash (#5122)
---
.../feature/node/metrics/TracerouteChart.kt | 2 +-
.../feature/node/metrics/TracerouteChartTest.kt | 13 +++++++++++++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
index ce6300205..c1e5e69fe 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
@@ -112,7 +112,7 @@ internal fun resolveTraceroutePoints(requests: List, results: List()
+
+ val point = resolveTraceroutePoints(requests, results).first()
+
+ // Must truncate to whole seconds to avoid Vico "x-values are too precise" crash
+ assertEquals(1000.0, point.timeSeconds)
+ }
+
@Test
fun returnHops_computedWhenRouteBackAvailable() {
val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC))
From 99378c92919a4936afdbe1a19fea3997bb1f4af5 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 14 Apr 2026 06:50:59 -0500
Subject: [PATCH 03/62] chore(deps): update core/proto/src/main/proto digest to
98e95ee (#5123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index a4c649bd3..98e95eeaa 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit a4c649bd3e877dab9011d9e32dc778640ec22852
+Subproject commit 98e95eeaa26770e6ede0291753623e4744b6ede1
From 9acdf5309f8f0ab96b30d6505bdac5e93a3bb72c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 06:41:01 -0500
Subject: [PATCH 04/62] =?UTF-8?q?refactor:=20modern=20APIs=20=E2=80=94=20K?=
=?UTF-8?q?oin=204.2,=20CMP=201.11,=20Ktor=20resilience,=20Room=20@Upsert,?=
=?UTF-8?q?=20injected=20dispatchers=20(#5119)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/build.gradle.kts | 1 -
.../org/meshtastic/app/map/MapViewModel.kt | 72 +++--
.../app/map/prefs/di/GoogleMapsKoinModule.kt | 13 +-
.../kotlin/org/meshtastic/app/MainActivity.kt | 2 +-
.../org/meshtastic/app/di/NetworkModule.kt | 12 +
.../core/database/dao/DeviceHardwareDao.kt | 9 +-
.../core/database/dao/FirmwareReleaseDao.kt | 6 +-
.../core/database/dao/NodeInfoDao.kt | 8 +-
.../database/dao/TracerouteNodePositionDao.kt | 6 +-
.../di/CoreDatastoreAndroidModule.kt | 10 +-
.../core/datastore/di/CoreDatastoreModule.kt | 9 +-
.../core/network/HttpClientDefaults.kt | 31 ++
.../core/service/MarkAsReadReceiver.kt | 6 +-
.../meshtastic/core/service/MeshService.kt | 6 +-
.../core/service/ReactionReceiver.kt | 6 +-
.../meshtastic/core/service/ReplyReceiver.kt | 6 +-
core/ui/build.gradle.kts | 1 -
.../ui/component/TimeTickWithLifecycle.kt | 5 +-
.../ui/component/TimeTickWithLifecycle.kt | 5 +-
.../desktop/DesktopNotificationManager.kt | 24 +-
.../kotlin/org/meshtastic/desktop/Main.kt | 297 +++++++++++-------
.../data/DesktopPreferencesDataSource.kt | 28 +-
.../meshtastic/desktop/di/DesktopDiModule.kt | 4 +
.../desktop/di/DesktopKoinModule.kt | 16 +-
.../desktop/di/DesktopPlatformModule.kt | 70 +++--
.../desktop/navigation/DesktopNavigation.kt | 29 +-
.../DesktopMeshServiceNotifications.kt | 33 +-
.../desktop/radio/DesktopMessageQueue.kt | 5 +-
.../meshtastic/desktop/stub/CompassStubs.kt | 3 +
.../desktop/ui/DesktopMainScreen.kt | 5 +-
feature/messaging/build.gradle.kts | 1 -
gradle/libs.versions.toml | 2 -
32 files changed, 453 insertions(+), 278 deletions(-)
create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0942756c0..39e6bbcc7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -266,7 +266,6 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
- implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 70ff4858d..e4eabbb76 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
-import kotlinx.coroutines.Dispatchers
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
+ private val dispatchers: CoroutineDispatchers,
+ private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
- val loadedItems = persistedLayerFiles.mapNotNull { file ->
- if (file.isFile) {
- val layerType =
- when (file.extension.lowercase()) {
- "kml",
- "kmz",
- -> LayerType.KML
- "geojson",
- "json",
- -> LayerType.GEOJSON
- else -> null
- }
+ val loadedItems =
+ persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ val layerType =
+ when (file.extension.lowercase()) {
+ "kml",
+ "kmz",
+ -> LayerType.KML
+ "geojson",
+ "json",
+ -> LayerType.GEOJSON
+ else -> null
+ }
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
+ layerType?.let {
+ val uri = Uri.fromFile(file)
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = it,
+ )
+ }
+ } else {
+ null
}
- } else {
- null
}
- }
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@@ -550,7 +558,7 @@ class MapViewModel(
}
}
- private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
+ private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@@ -621,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(Dispatchers.IO) {
+ withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -636,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
- return withContext(Dispatchers.IO) {
+ return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
- val url = java.net.URL(uriToLoad.toString())
- java.io.BufferedInputStream(url.openStream())
+ val response = httpClient.get(uriToLoad.toString())
+ if (!response.status.isSuccess()) {
+ Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
+ return@withContext null
+ }
+ response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
index e33fb1f8c..668dedbaa 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
@@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
- fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
+ fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = CoroutineScope(dispatchers.io + SupervisorJob()),
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 03549c0b3..d86df9d60 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -48,8 +48,8 @@ import coil3.compose.setSingletonImageLoaderFactory
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
-import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 4aa27bf0e..dd7e9d8be 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -31,6 +31,8 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
@@ -40,6 +42,7 @@ import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
@@ -84,6 +87,15 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
+ install(plugin = HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(plugin = HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index fcdc079f2..c1e399c97 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(deviceHardware: DeviceHardwareEntity)
+ @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(deviceHardware: List)
+ @Upsert suspend fun insertAll(deviceHardware: List)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
index 0a5520a07..040941a49 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
@@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
+ @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index e11d10f50..eb3c27b7e 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
import androidx.room3.MapColumn
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@@ -168,8 +166,7 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
+ @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@@ -295,8 +292,7 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun putAll(nodes: List)
+ @Upsert suspend fun putAll(nodes: List)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
index 2e7f6c549..fde388ce5 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
@@ -17,9 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -32,6 +31,5 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(entities: List)
+ @Upsert suspend fun insertAll(entities: List)
}
diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
index 94ef1c605..9de792a84 100644
--- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
+++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
@@ -50,7 +50,7 @@ class PreferencesDataStoreModule {
@Named("CorePreferencesDataStore")
fun providePreferencesDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
index aa81f1ac6..3cb3cabe8 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -24,10 +24,17 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
+/**
+ * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances.
+ *
+ * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules.
+ */
+const val DATASTORE_SCOPE = "DataStoreScope"
+
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
- @Named("DataStoreScope")
+ @Named(DATASTORE_SCOPE)
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
new file mode 100644
index 000000000..db558bedb
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -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 .
+ */
+package org.meshtastic.core.network
+
+/**
+ * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups.
+ *
+ * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on
+ * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry].
+ */
+object HttpClientDefaults {
+ /** Timeout in milliseconds for connect, request, and socket operations. */
+ const val TIMEOUT_MS = 30_000L
+
+ /** Maximum number of automatic retries on server errors (5xx). */
+ const val MAX_RETRIES = 3
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
index 966569f4f..36c26c879 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
@@ -20,12 +20,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
@@ -38,7 +38,9 @@ class MarkAsReadReceiver :
private val serviceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 028030f76..5869ce94f 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -25,11 +25,11 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koin.android.ext.android.inject
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.toRemoteExceptions
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
@@ -84,8 +84,10 @@ class MeshService : Service() {
private val router: MeshRouter by inject()
+ private val dispatchers: CoroutineDispatchers by inject()
+
private val serviceJob = Job()
- private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) }
private var isServiceInitialized = false
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
index 5965b9ddd..f4db74403 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.ServiceRepository
@@ -41,7 +41,9 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 4e82a735d..d7a943783 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -44,7 +44,9 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index d07a5afc3..44b483c91 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -55,7 +55,6 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
- implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
index f8b0586f4..aa47539bb 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
@@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import org.meshtastic.core.common.util.nowMillis
@Composable
actual fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
- var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
+ var value by remember { mutableLongStateOf(nowMillis) }
DisposableEffect(context) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- value = System.currentTimeMillis()
+ value = nowMillis
}
}
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
index 22f84b217..165262170 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
+import org.meshtastic.core.common.util.nowMillis
-/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */
-@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis()
+/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */
+@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 26fa16f6e..e3c7f8b19 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -25,15 +26,22 @@ import org.meshtastic.core.repository.NotificationPrefs
import androidx.compose.ui.window.Notification as ComposeNotification
/**
- * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid
- * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
+ * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications.
+ *
+ * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user
+ * preferences for message, node-event, and low-battery categories.
+ *
+ * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
+ * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
init {
- co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
+ Logger.i { "DesktopNotificationManager initialized" }
}
private val _notifications = MutableSharedFlow(extraBufferCapacity = 10)
+
+ /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */
val notifications: SharedFlow = _notifications.asSharedFlow()
override fun dispatch(notification: Notification) {
@@ -46,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Category.Service -> true
}
- co.touchlab.kermit.Logger.d {
- "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
- }
+ Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" }
if (!enabled) return
@@ -61,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
}
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
- co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
+ Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
}
override fun cancel(id: Int) {
- // Desktop Tray notifications cannot be cancelled once sent via TrayState
+ // Desktop tray notifications cannot be cancelled once sent via TrayState.
}
override fun cancelAll() {
- // Desktop Tray notifications cannot be cleared once sent via TrayState
+ // Desktop tray notifications cannot be cleared once sent via TrayState.
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 0a450c007..25a5b8ce3 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -18,7 +18,6 @@ package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
-import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
@@ -55,13 +54,19 @@ import coil3.memory.MemoryCache
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
+import coil3.util.DebugLogger
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
-import org.jetbrains.skia.Image
+import org.jetbrains.compose.resources.decodeToSvgPainter
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
@@ -75,33 +80,50 @@ import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop
import java.util.Locale
+import coil3.util.Logger as CoilLogger
/** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */
-private val LocalAppLocale = staticCompositionLocalOf { "" }
-
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB
+/**
+ * Loads an SVG from JVM classpath resources and returns a [Painter].
+ *
+ * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair.
+ * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and
+ * raster images — not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe.
+ */
@Composable
-private fun classpathPainterResource(path: String): Painter {
- val bitmap: ImageBitmap =
- remember(path) {
- val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes()
- Image.makeFromEncoded(bytes).toComposeImageBitmap()
+private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) {
+ val classLoader =
+ requireNotNull(Thread.currentThread().contextClassLoader) {
+ "Missing context class loader while loading resource: $path"
}
- return remember(bitmap) { BitmapPainter(bitmap) }
+ val bytes =
+ requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" }
+ .use { it.readAllBytes() }
+ bytes.decodeToSvgPainter(density)
}
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
- val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
- val systemLocale = remember { Locale.getDefault() }
- val uiViewModel = remember { koinApp.koin.get() }
- val httpClient = remember { koinApp.koin.get() }
+ remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
+ DisposableEffect(Unit) { onDispose { stopKoin() } }
+ val uiViewModel = koinViewModel()
+
+ DeepLinkHandler(args, uiViewModel)
+ MeshServiceLifecycle()
+ ThemeAndLocaleProvider(uiViewModel)
+}
+
+// ----- Deep link handling -----
+
+/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */
+@Composable
+private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) {
LaunchedEffect(args) {
args.forEach { arg ->
if (
@@ -124,14 +146,28 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
}
}
}
+}
- val meshServiceController = remember { koinApp.koin.get() }
+// ----- Mesh service lifecycle -----
+
+/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */
+@Composable
+private fun MeshServiceLifecycle() {
+ val meshServiceController = koinInject()
DisposableEffect(Unit) {
meshServiceController.start()
onDispose { meshServiceController.stop() }
}
+}
- val uiPrefs = remember { koinApp.koin.get() }
+// ----- Theme, locale, and application shell -----
+
+/** Resolves the user's theme/locale preferences and renders the full application UI. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
+ val systemLocale = remember { Locale.getDefault() }
+ val uiPrefs = koinInject()
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
@@ -144,25 +180,59 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
else -> isSystemInDarkTheme()
}
+ MeshtasticDesktopApp(uiViewModel, isDarkTheme)
+}
+
+// ----- Application chrome (tray, window, navigation) -----
+
+/** Composes the system tray, window, and Coil image loader. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) {
var isAppVisible by remember { mutableStateOf(true) }
var isWindowReady by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
- val appIcon = classpathPainterResource("icon.png")
+ val density = LocalDensity.current
+ val appIcon = svgPainterResource("tray_icon_black.svg", density)
- @Suppress("DEPRECATION")
val trayIcon =
- androidx.compose.ui.res.painterResource(
- if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
- )
+ svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density)
- val notificationManager = remember { koinApp.koin.get() }
- val desktopPrefs = remember { koinApp.koin.get() }
+ val notificationManager = koinInject()
+ val desktopPrefs = koinInject()
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
}
+ WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }
+
+ Tray(
+ state = trayState,
+ icon = trayIcon,
+ tooltip = "Meshtastic Desktop",
+ onAction = { isAppVisible = true },
+ menu = {
+ Item("Show Meshtastic", onClick = { isAppVisible = true })
+ Item("Quit", onClick = ::exitApplication)
+ },
+ )
+
+ if (isWindowReady && isAppVisible) {
+ MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false }
+ }
+}
+
+// ----- Window bounds persistence -----
+
+/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */
+@Composable
+private fun WindowBoundsManager(
+ desktopPrefs: DesktopPreferencesDataSource,
+ windowState: WindowState,
+ onReady: () -> Unit,
+) {
LaunchedEffect(Unit) {
val initialWidth = desktopPrefs.windowWidth.first()
val initialHeight = desktopPrefs.windowHeight.first()
@@ -177,7 +247,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
WindowPosition(Alignment.Center)
}
- isWindowReady = true
+ onReady()
snapshotFlow {
val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN
@@ -188,86 +258,99 @@ fun main(args: Array) = application(exitProcessOnExit = false) {
desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3])
}
}
+}
- Tray(
- state = trayState,
- icon = trayIcon,
- tooltip = "Meshtastic Desktop",
- onAction = { isAppVisible = true },
- menu = {
- Item("Show Meshtastic", onClick = { isAppVisible = true })
- Item("Quit", onClick = ::exitApplication)
- },
- )
+// ----- Main window with keyboard shortcuts and Coil -----
- if (isWindowReady && isAppVisible) {
- val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
- val backStack = multiBackstack.activeBackStack
+/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.MeshtasticWindow(
+ uiViewModel: UIViewModel,
+ isDarkTheme: Boolean,
+ appIcon: Painter,
+ windowState: WindowState,
+ onCloseRequest: () -> Unit,
+) {
+ val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
- Window(
- onCloseRequest = { isAppVisible = false },
- title = "Meshtastic Desktop",
- icon = appIcon,
- state = windowState,
- onPreviewKeyEvent = { event ->
- if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
- when {
- event.key == Key.Q -> {
- exitApplication()
- true
- }
- event.key == Key.Comma -> {
- if (
- TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
- ) {
- multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
- }
- true
- }
- event.key == Key.One -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
- true
- }
- event.key == Key.Two -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
- true
- }
- event.key == Key.Three -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
- true
- }
- event.key == Key.Four -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
- true
- }
- event.key == Key.Slash -> {
- backStack.add(SettingsRoute.About)
- true
- }
- else -> false
- }
- },
- ) {
- setSingletonImageLoaderFactory { context ->
- val cacheDir = desktopDataDir() + "/image_cache_v3"
- ImageLoader.Builder(context)
- .components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
- // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
- // that show up as solid/black hardware images.
- add(SvgDecoder.Factory(renderToBitmap = true))
- }
- .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
- .diskCache {
- DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build()
- }
- .crossfade(true)
- .build()
- }
-
- CompositionLocalProvider(LocalAppLocale provides localePref) {
- AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
- }
- }
+ Window(
+ onCloseRequest = onCloseRequest,
+ title = "Meshtastic Desktop",
+ icon = appIcon,
+ state = windowState,
+ onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) },
+ ) {
+ CoilImageLoaderSetup()
+ AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
+ }
+}
+
+/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun CoilImageLoaderSetup() {
+ val httpClient = koinInject()
+ val buildConfigProvider = koinInject()
+
+ setSingletonImageLoaderFactory { context ->
+ val cacheDir = desktopDataDir() + "/image_cache_v3"
+ ImageLoader.Builder(context)
+ .components {
+ add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
+ // that show up as solid/black hardware images.
+ add(SvgDecoder.Factory(renderToBitmap = true))
+ }
+ .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
+ .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() }
+ .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null)
+ .crossfade(true)
+ .build()
+ }
+}
+
+// ----- Keyboard shortcuts -----
+
+/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */
+private fun handleKeyboardShortcut(
+ event: androidx.compose.ui.input.key.KeyEvent,
+ multiBackstack: MultiBackstack,
+ exitApplication: () -> Unit,
+): Boolean {
+ if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false
+ val backStack = multiBackstack.activeBackStack
+ return when (event.key) {
+ Key.Q -> {
+ exitApplication()
+ true
+ }
+ Key.Comma -> {
+ if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
+ }
+ true
+ }
+ Key.One -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
+ true
+ }
+ Key.Two -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
+ true
+ }
+ Key.Three -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
+ true
+ }
+ Key.Four -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
+ true
+ }
+ Key.Slash -> {
+ backStack.add(SettingsRoute.About)
+ true
+ }
+ else -> false
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
index 9af34f28d..6dd562bd4 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
@@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -30,16 +29,21 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
-const val KEY_WINDOW_WIDTH = "window_width"
-const val KEY_WINDOW_HEIGHT = "window_height"
-const val KEY_WINDOW_X = "window_x"
-const val KEY_WINDOW_Y = "window_y"
-
+/**
+ * Persists and restores desktop window geometry (position and size) across application restarts.
+ *
+ * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via
+ * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption.
+ */
@Single
-class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) {
+class DesktopPreferencesDataSource(
+ @Named("CorePreferencesDataStore") private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f)
val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f)
@@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va
): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default)
companion object {
- val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH)
- val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT)
- val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X)
- val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y)
+ val WINDOW_WIDTH = floatPreferencesKey("window_width")
+ val WINDOW_HEIGHT = floatPreferencesKey("window_height")
+ val WINDOW_X = floatPreferencesKey("window_x")
+ val WINDOW_Y = floatPreferencesKey("window_y")
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
index 0bb5311aa..d27f6d5d9 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
@@ -19,6 +19,10 @@ package org.meshtastic.desktop.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
+/**
+ * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`,
+ * `@KoinViewModel`).
+ */
@Module
@ComponentScan("org.meshtastic.desktop")
class DesktopDiModule
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 336f87b54..5b3b03f9d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -14,11 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports
+
package org.meshtastic.desktop.di
// Generated Koin module extensions from core KMP modules
import io.ktor.client.HttpClient
import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
@@ -32,6 +36,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.network.service.ApiService
@@ -163,7 +168,7 @@ private fun desktopPlatformStubsModule() = module {
single { NoopServiceBroadcasts() }
single { NoopAppWidgetUpdater() }
single { NoopMeshWorkerManager() }
- single { DesktopMessageQueue(packetRepository = get(), radioController = get()) }
+ single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
single { NoopMeshLocationManager() }
single { NoopLocationRepository() }
single { NoopMQTTRepository() }
@@ -178,6 +183,15 @@ private fun desktopPlatformStubsModule() = module {
single {
HttpClient(Java) {
install(ContentNegotiation) { json(get()) }
+ install(HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
index 6b0aa1b2a..743c2065d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
@@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toPath
@@ -35,10 +34,12 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.datastore.di.DATASTORE_SCOPE
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
@@ -49,10 +50,10 @@ import org.meshtastic.proto.LocalStats
private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
- return PreferenceDataStoreFactory.create(
+ return PreferenceDataStoreFactory.createWithPath(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
scope = scope,
- produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() },
+ produceFile = { "$dir/$name.preferences_pb".toPath() },
)
}
@@ -80,16 +81,15 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
* - [Lifecycle] (`ProcessLifecycle`)
* - [BuildConfigProvider]
*/
-@Suppress("InjectDispatcher")
fun desktopPlatformModule() = module {
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
// DataStore has no close() API — the in-memory cache is released only when this Job is cancelled
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
- val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ single(named(DATASTORE_SCOPE)) { CoroutineScope(get().io + SupervisorJob()) }
- includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope))
+ includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
// -- Build config (values generated at build time by generateDesktopBuildConfig) --
single {
@@ -108,30 +108,50 @@ fun desktopPlatformModule() = module {
}
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
-private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module {
- single>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
+private fun desktopPreferencesDataStoreModule() = module {
+ single>(named("AnalyticsDataStore")) {
+ createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE)))
+ }
single>(named("HomoglyphEncodingDataStore")) {
- createPreferencesDataStore("homoglyph_encoding", scope)
+ createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("AppDataStore")) {
+ createPreferencesDataStore("app", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("CustomEmojiDataStore")) {
+ createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("MapDataStore")) {
+ createPreferencesDataStore("map", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("MapConsentDataStore")) {
+ createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE)))
}
- single>(named("AppDataStore")) { createPreferencesDataStore("app", scope) }
- single>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) }
- single>(named("MapDataStore")) { createPreferencesDataStore("map", scope) }
- single>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) }
single>(named("MapTileProviderDataStore")) {
- createPreferencesDataStore("map_tile_provider", scope)
+ createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("MeshDataStore")) {
+ createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("RadioDataStore")) {
+ createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("UiDataStore")) {
+ createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("MeshLogDataStore")) {
+ createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE)))
+ }
+ single>(named("FilterDataStore")) {
+ createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE)))
}
- single>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) }
- single>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) }
- single>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) }
- single>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) }
- single>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) }
single>(named("CorePreferencesDataStore")) {
- createPreferencesDataStore("core_preferences", scope)
+ createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE)))
}
}
/** Proto [DataStore] instances (OkioStorage-backed). */
-private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
+private fun desktopProtoDataStoreModule() = module {
val protoDir = desktopDataDir() + "/datastore"
single>(named("CoreLocalConfigDataStore")) {
@@ -143,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -156,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/module_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -169,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/channel_set.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -182,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_stats.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index f30ecb66b..594a62bc4 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
@@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
+import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
/**
- * Registers entry providers for all top-level desktop destinations.
+ * Registers [NavKey] entry providers for every desktop destination.
*
- * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
- * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
- * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
- * shared composables are wired.
+ * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping
+ * the desktop shell free of screen-level composable knowledge.
*/
-fun EntryProviderScope.desktopNavGraph(
- backStack: NavBackStack,
- uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel,
-) {
- // Nodes — real composables from feature:node
+fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) {
nodesGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
)
-
- // Conversations — real composables from feature:messaging
contactsGraph(backStack, uiViewModel.scrollToTopEventFlow)
-
- // Map — placeholder for now, will be replaced with feature:map real implementation
mapGraph(backStack)
-
- // Firmware — in-flow destination (for example from Settings), not a top-level rail tab
firmwareGraph(backStack)
-
- // Settings — real composables from feature:settings
settingsGraph(backStack)
-
- // Channels
channelsGraph(backStack)
-
- // Connections — shared screen
connectionsGraph(backStack)
-
- // WiFi Provisioning — nymea-networkmanager BLE protocol
wifiProvisionGraph(backStack)
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
index a5ec5b795..309fff7da 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop.notification
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.Notification
@@ -29,8 +30,15 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/**
- * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to
- * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
+ * Desktop implementation of [MeshServiceNotifications].
+ *
+ * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through
+ * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications.
+ *
+ * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
+ *
+ * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
+ * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
@Suppress("TooManyFunctions")
class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications {
@@ -39,14 +47,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
}
override fun initChannels() {
- // no-op for desktop
+ // No-op: desktop has no Android notification channels.
}
- override fun updateServiceStateNotification(
- state: org.meshtastic.core.model.ConnectionState,
- telemetry: Telemetry?,
- ) {
- // We don't have a foreground service on desktop
+ override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {
+ // No-op: desktop has no foreground service notification.
}
override suspend fun updateMessageNotification(
@@ -106,16 +111,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
)
}
- @Suppress("ktlint:standard:max-line-length")
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
- notificationManager.dispatch(
- Notification(
- title = name,
- message = alert,
- category = Notification.Category.Alert,
- contactKey = contactKey,
- ),
- )
+ val notification =
+ Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey)
+ notificationManager.dispatch(notification)
}
override fun showNewNodeSeenNotification(node: Node) {
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
index c272e7bd9..3888b0af3 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
@@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
@@ -36,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository
class DesktopMessageQueue(
private val packetRepository: PacketRepository,
private val radioController: RadioController,
+ dispatchers: CoroutineDispatchers,
) : MessageQueue {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun enqueue(packetId: Int) {
scope.launch {
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
index 5e223ed67..b0761522d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
@@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.feature.node.compass.PhoneLocationState
+/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */
class NoopCompassHeadingProvider : CompassHeadingProvider {
override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false))
}
+/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */
class NoopPhoneLocationProvider : PhoneLocationProvider {
override fun locationUpdates(): Flow =
flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
}
+/** No-op [MagneticFieldProvider] — always returns zero declination. */
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
index 00b2e82c7..a55bf902f 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
@@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
-/** Desktop main screen — uses shared navigation components. */
+/**
+ * Desktop main screen — assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and
+ * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider.
+ */
@Composable
fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
val backStack = multiBackstack.activeBackStack
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 80eed61c5..f2887d98a 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -42,7 +42,6 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.ui)
- implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 230e6533f..2c9978463 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,7 +12,6 @@ glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.11.0-alpha03"
navigation3 = "1.1.0-rc01"
-navigationevent = "1.1.0-alpha01"
paging = "3.4.2"
room = "3.0.0-alpha03"
koin = "4.2.1"
@@ -112,7 +111,6 @@ jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecyc
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
-jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }
From 3aadd29e67e56656d7d4bd37d1b6ed442980b3a6 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 14 Apr 2026 07:17:49 -0500
Subject: [PATCH 05/62] chore(deps): update core/proto/src/main/proto digest to
a045501 (#5124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index 98e95eeaa..a045501ea 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit 98e95eeaa26770e6ede0291753623e4744b6ede1
+Subproject commit a045501ea848f49d546cc10e4c162a32317d4c7e
From 27055290e2a7a79ecf0b2e017684a43280b9e2b5 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 07:37:12 -0500
Subject: [PATCH 06/62] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5125)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../src/commonMain/composeResources/values-bg/strings.xml | 3 +++
.../src/commonMain/composeResources/values-ru/strings.xml | 3 +++
2 files changed, 6 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 56f32b1ba..14fc7aae5 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -469,6 +469,9 @@
Известия при получаване на сигнал/позвъняване
Използване на PWM зумер
Тон на звънене
+ Импортирана мелодия
+ Файлът е празен
+ Грешка при импортиране: %1$s
LoRa
Опции
Разширени
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index a201c1dc8..ef0e89a45 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -590,6 +590,9 @@
Продолжительность вывода (миллисекунды)
Таймаут Nag (в секундах)
Рингтон
+ Импортировать рингтон
+ Файл пуст
+ Ошибка импорта: %1$s
Воспроизвести
Использовать I2S как буззер
LoRa
From c6f58cc7994506ca527a2d719a0217c0f7174415 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:48:25 -0500
Subject: [PATCH 07/62] chore(deps): update core/proto/src/main/proto digest to
940ac38 (#5126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index a045501ea..940ac382a 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit a045501ea848f49d546cc10e4c162a32317d4c7e
+Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b
From 099aea2d81655d355b5ffdc8a7a2fac447861a09 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 10:16:10 -0500
Subject: [PATCH 08/62] feat(desktop): add entitlements and wire
MeshConnectionManager into orchestrator (#5127)
---
.../core/service/MeshServiceOrchestrator.kt | 3 +++
.../core/service/MeshServiceOrchestratorTest.kt | 3 +++
desktop/build.gradle.kts | 5 +++++
desktop/entitlements.plist | 14 ++++++++++++++
.../main/kotlin/org/meshtastic/desktop/Main.kt | 15 +++++++--------
5 files changed, 32 insertions(+), 8 deletions(-)
create mode 100644 desktop/entitlements.plist
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
index e651d95ce..50e88cc3f 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
@@ -26,6 +26,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
+import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -57,6 +58,7 @@ class MeshServiceOrchestrator(
private val takMeshIntegration: TAKMeshIntegration,
private val takPrefs: TakPrefs,
private val databaseManager: DatabaseManager,
+ private val connectionManager: MeshConnectionManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) {
private var serviceJob: Job? = null
@@ -87,6 +89,7 @@ class MeshServiceOrchestrator(
serviceJob = job
serviceNotifications.initChannels()
+ connectionManager.updateStatusNotification()
// Observe TAK server pref to start/stop
takJob =
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index 48be7dbf6..ddb7b148f 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -35,6 +35,7 @@ import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -67,6 +68,7 @@ class MeshServiceOrchestratorTest {
private val takPrefs: TakPrefs = mock(MockMode.autofill)
private val cotHandler: CoTHandler = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
+ private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = CoroutineScope(testDispatcher)
@@ -111,6 +113,7 @@ class MeshServiceOrchestratorTest {
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
databaseManager = databaseManager,
+ connectionManager = connectionManager,
scope = testScope,
)
}
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index df5122a4d..fdf7cee5c 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -158,9 +158,14 @@ compose.desktop {
iconFile.set(project.file("src/main/resources/icon.icns"))
minimumSystemVersion = "12.0"
bundleID = "org.meshtastic.desktop"
+ entitlementsFile.set(project.file("entitlements.plist"))
infoPlist {
extraKeysRawXml =
"""
+ NSBluetoothAlwaysUsageDescription
+ Meshtastic uses Bluetooth to communicate with your Meshtastic radio device.
+ NSLocalNetworkUsageDescription
+ Meshtastic uses your local network to discover Meshtastic devices connected via WiFi.
NSUserNotificationAlertStyle
alert
CFBundleURLTypes
diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist
new file mode 100644
index 000000000..f799a66e9
--- /dev/null
+++ b/desktop/entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.bluetooth
+
+
+
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 25a5b8ce3..8b33a3612 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -60,9 +60,7 @@ import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
import org.jetbrains.compose.resources.decodeToSvgPainter
import org.koin.compose.koinInject
-import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.context.startKoin
-import org.koin.core.context.stopKoin
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.desktopDataDir
@@ -107,12 +105,13 @@ private fun svgPainterResource(path: String, density: Density): Painter = rememb
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array) = application(exitProcessOnExit = false) {
- Logger.i { "Meshtastic Desktop — Starting" }
-
- remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
- DisposableEffect(Unit) { onDispose { stopKoin() } }
-
- val uiViewModel = koinViewModel()
+ val koinApp = remember {
+ Logger.i { "Meshtastic Desktop — Starting" }
+ startKoin { modules(desktopPlatformModule(), desktopModule()) }
+ }
+ val systemLocale = remember { Locale.getDefault() }
+ val uiViewModel = remember { koinApp.koin.get() }
+ val httpClient = remember { koinApp.koin.get() }
DeepLinkHandler(args, uiViewModel)
MeshServiceLifecycle()
From f48fc61729b3f6f465c2a3bfd47d21114a9bd2bb Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 19:03:24 -0500
Subject: [PATCH 09/62] feat(environment): add 1-Wire multi-thermometer
(DS18B20) display support (#5130)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 1 +
.../meshtastic/core/ui/theme/CustomColors.kt | 4 ++
.../node/component/EnvironmentMetrics.kt | 13 ++++
.../feature/node/metrics/CommonCharts.kt | 7 +-
.../feature/node/metrics/EnvironmentCharts.kt | 26 ++++++-
.../node/metrics/EnvironmentMetrics.kt | 35 ++++++++++
.../node/metrics/EnvironmentMetricsState.kt | 69 ++++++++++++++++++-
.../feature/node/metrics/MetricsViewModel.kt | 14 +++-
8 files changed, 162 insertions(+), 7 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4a5e40ade..9678c9919 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -748,6 +748,7 @@
Rain (24h)
Weight
Radiation
+ 1-Wire Temp
Indoor Air Quality (IAQ)
URL
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index 240c01503..d2047b603 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
@@ -60,6 +60,10 @@ object GraphColors {
val Lime = Color(0xFFCDDC39)
val Indigo = Color(0xFF3F51B5)
val DeepOrange = Color(0xFFFF5722)
+ val Magenta = Color(0xFFE040FB)
+ val SkyBlue = Color(0xFF03A9F4)
+ val Chartreuse = Color(0xFF76FF03)
+ val Coral = Color(0xFFFF6E40)
}
object StatusColors {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index aa44a6b7e..067d9cf40 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_radioactive
import org.meshtastic.core.resources.ic_soil_moisture
import org.meshtastic.core.resources.ic_soil_temperature
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.pressure
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
@@ -222,6 +223,18 @@ internal fun EnvironmentMetrics(
),
)
}
+ // 1-Wire temperature sensors (up to 8 channels)
+ one_wire_temperature
+ .filterNot { it.isNaN() }
+ .forEachIndexed { idx, temp ->
+ add(
+ DrawableMetricInfo(
+ label = Res.string.one_wire_temperature,
+ value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}",
+ icon = Res.drawable.ic_soil_temperature,
+ ),
+ )
+ }
}
}
FlowRow(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
index bb6efdff6..f8d48dd59 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
@@ -127,6 +127,8 @@ data class LegendData(
val color: Color,
val isLine: Boolean = false,
val metricKey: Any? = null,
+ /** When non-null, overrides the resolved [nameRes] string in the legend label. */
+ val labelOverride: String? = null,
)
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
@@ -153,11 +155,12 @@ fun Legend(
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
+ val label = data.labelOverride ?: stringResource(data.nameRes)
if (onToggle != null) {
FilterChip(
selected = isVisible,
onClick = { onToggle(index) },
- label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) },
+ label = { Text(text = label, style = MaterialTheme.typography.labelSmall) },
leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) },
modifier = Modifier.padding(horizontal = 2.dp),
)
@@ -166,7 +169,7 @@ fun Legend(
LegendIndicator(color = data.color, isLine = data.isLine)
Spacer(modifier = Modifier.width(4.dp))
Text(
- text = stringResource(data.nameRes),
+ text = label,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
index c0164dd80..0f809ef81 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
@@ -42,6 +42,7 @@ import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
@@ -112,6 +113,27 @@ private val LEGEND_DATA_3 =
),
)
+private val LEGEND_DATA_4 =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ .mapIndexed { index, entry ->
+ LegendData(
+ nameRes = Res.string.one_wire_temperature,
+ labelOverride = "1-Wire Temp ${index + 1}",
+ color = entry.color,
+ isLine = true,
+ metricKey = entry,
+ )
+ }
+
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun EnvironmentMetricsChart(
@@ -132,7 +154,7 @@ fun EnvironmentMetricsChart(
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val allLegendData =
- (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
+ (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter {
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
@@ -143,7 +165,7 @@ fun EnvironmentMetricsChart(
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
}
- val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
+ val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) }
val showPressure =
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
index 4f9e88d47..77c6781f1 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
@@ -54,6 +54,7 @@ import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.iaq_definition
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.rainfall_1h
import org.meshtastic.core.resources.rainfall_24h
@@ -443,6 +444,39 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
}
+@Composable
+private fun OneWireTemperatureDisplay(
+ envMetrics: org.meshtastic.proto.EnvironmentMetrics,
+ environmentDisplayFahrenheit: Boolean,
+) {
+ val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() }
+ if (sensors.isEmpty()) return
+ val oneWireEntries =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C"
+ sensors.forEachIndexed { idx, temp ->
+ val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
+ }
+}
+
@Composable
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
@@ -484,6 +518,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
RadiationDisplay(envMetrics)
WindDisplay(envMetrics)
RainfallDisplay(envMetrics)
+ OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
index dda094e21..686a228b2 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
@@ -18,16 +18,24 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.util.UnitConversions
+import org.meshtastic.core.ui.theme.GraphColors.Amber
import org.meshtastic.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.Chartreuse
+import org.meshtastic.core.ui.theme.GraphColors.Coral
import org.meshtastic.core.ui.theme.GraphColors.Cyan
+import org.meshtastic.core.ui.theme.GraphColors.DeepOrange
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
+import org.meshtastic.core.ui.theme.GraphColors.Indigo
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
+import org.meshtastic.core.ui.theme.GraphColors.LightGreen
import org.meshtastic.core.ui.theme.GraphColors.Lime
+import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
+import org.meshtastic.core.ui.theme.GraphColors.SkyBlue
import org.meshtastic.core.ui.theme.GraphColors.Teal
import org.meshtastic.proto.Telemetry
@@ -66,7 +74,39 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed
},
RADIATION(Lime) {
- override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation
+ },
+ ONE_WIRE_TEMP_1(Amber) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0)
+ },
+ ONE_WIRE_TEMP_2(DeepOrange) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1)
+ },
+ ONE_WIRE_TEMP_3(Indigo) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2)
+ },
+ ONE_WIRE_TEMP_4(LightGreen) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3)
+ },
+ ONE_WIRE_TEMP_5(Magenta) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4)
+ },
+ ONE_WIRE_TEMP_6(SkyBlue) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5)
+ },
+ ONE_WIRE_TEMP_7(Chartreuse) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6)
+ },
+ ONE_WIRE_TEMP_8(Coral) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7)
}, ;
abstract fun getValue(telemetry: Telemetry): Float?
@@ -205,6 +245,33 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp
shouldPlot[Environment.RADIATION.ordinal] = true
}
+ // 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware)
+ val oneWireEntries =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ oneWireEntries.forEach { entry ->
+ val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } }
+ if (values.isNotEmpty()) {
+ var minVal = values.minOf { it }
+ var maxVal = values.maxOf { it }
+ if (useFahrenheit) {
+ minVal = UnitConversions.celsiusToFahrenheit(minVal)
+ maxVal = UnitConversions.celsiusToFahrenheit(maxVal)
+ }
+ minValues.add(minVal)
+ maxValues.add(maxVal)
+ shouldPlot[entry.ordinal] = true
+ }
+ }
+
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index b7ab25368..4967e65d5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -148,6 +148,8 @@ open class MetricsViewModel(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
+ one_wire_temperature =
+ em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
@@ -381,21 +383,25 @@ open class MetricsViewModel(
}
fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) {
+ val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," +
"\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," +
- "\"soilMoisture\"\n",
+ "\"soilMoisture\",$oneWireHeaders\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val em = t.environment_metrics
+ val owt = em?.one_wire_temperature ?: emptyList()
+ val oneWireValues =
+ (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" }
"\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," +
"\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," +
"\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," +
"\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," +
- "\"${em?.soil_moisture ?: ""}\""
+ "\"${em?.soil_moisture ?: ""}\",$oneWireValues"
}
}
@@ -457,4 +463,8 @@ open class MetricsViewModel(
}
protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
+
+ companion object {
+ private const val ONE_WIRE_SENSOR_COUNT = 8
+ }
}
From fa63a4ac502fa4a6b7b336c3f65406eeefc76a6d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 20:14:20 -0500
Subject: [PATCH 10/62] feat: add high-contrast theme with accessible message
bubbles (#5135)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../kotlin/org/meshtastic/app/MainActivity.kt | 4 +-
.../settings/SetContrastLevelUseCase.kt | 27 ++++
.../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 8 ++
.../core/repository/AppPreferences.kt | 4 +
.../composeResources/values/strings.xml | 5 +
.../core/testing/FakeAppPreferences.kt | 6 +
.../meshtastic/core/ui/theme/ContrastLevel.kt | 44 +++++++
.../org/meshtastic/core/ui/theme/Theme.kt | 33 +++--
.../core/ui/viewmodel/UIViewModel.kt | 1 +
.../kotlin/org/meshtastic/desktop/Main.kt | 18 ++-
.../component/MessageActionsBottomSheet.kt | 3 +-
.../messaging/component/MessageItem.kt | 117 +++++++++++-------
.../feature/messaging/component/Reaction.kt | 9 +-
.../feature/settings/SettingsScreen.kt | 10 ++
.../settings/component/AppearanceSection.kt | 19 ++-
.../feature/settings/SettingsViewModel.kt | 6 +
.../component/ContrastPickerDialog.kt | 58 +++++++++
.../feature/settings/SettingsViewModelTest.kt | 3 +
.../feature/settings/DesktopSettingsScreen.kt | 18 +++
19 files changed, 328 insertions(+), 65 deletions(-)
create mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt
create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index d86df9d60..8316ad8e2 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -124,6 +124,8 @@ class MainActivity : ComponentActivity() {
setSingletonImageLoaderFactory { get() }
val theme by model.theme.collectAsStateWithLifecycle()
+ val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
+ val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
@@ -141,7 +143,7 @@ class MainActivity : ComponentActivity() {
}
AppCompositionLocals {
- AppTheme(dynamicColor = dynamic, darkTheme = dark) {
+ AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
new file mode 100644
index 000000000..fa708d165
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.UiPrefs
+
+@Single
+open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) {
+ operator fun invoke(value: Int) {
+ uiPrefs.setContrastLevel(value)
+ }
+}
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index 33f688389..7fe0da822 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -62,6 +62,13 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_THEME] = value } }
}
+ override val contrastLevel: StateFlow =
+ dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0)
+
+ override fun setContrastLevel(value: Int) {
+ scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } }
+ }
+
override val locale: StateFlow =
dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "")
@@ -152,6 +159,7 @@ class UiPrefsImpl(
val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed")
val KEY_THEME = intPreferencesKey("theme")
+ val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level")
val KEY_LOCALE = stringPreferencesKey("locale")
val KEY_NODE_SORT = intPreferencesKey("node-sort-option")
val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index f5203e3c1..bb32c1fbd 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -80,6 +80,10 @@ interface UiPrefs {
fun setTheme(value: Int)
+ val contrastLevel: StateFlow
+
+ fun setContrastLevel(value: Int)
+
val locale: StateFlow
fun setLocale(languageTag: String)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 9678c9919..77c923d94 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -278,10 +278,15 @@
Reset to defaults
Apply
Theme
+ Contrast
Light
Dark
System default
Choose theme
+ Contrast level
+ Standard
+ Medium
+ High
Provide phone location to mesh
Compact encoding for Cyrillic
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 2b9f9918f..9a703004c 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -84,6 +84,12 @@ class FakeUiPrefs : UiPrefs {
theme.value = value
}
+ override val contrastLevel = MutableStateFlow(0)
+
+ override fun setContrastLevel(value: Int) {
+ contrastLevel.value = value
+ }
+
override val locale = MutableStateFlow("en")
override fun setLocale(languageTag: String) {
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt
new file mode 100644
index 000000000..cd68cd12c
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.ui.theme
+
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/**
+ * Application-wide contrast level for accessibility.
+ *
+ * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and
+ * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in
+ * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders.
+ */
+enum class ContrastLevel(val value: Int) {
+ STANDARD(0),
+ MEDIUM(1),
+ HIGH(2),
+ ;
+
+ companion object {
+ fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD
+ }
+}
+
+/**
+ * Composition local providing the current [ContrastLevel].
+ *
+ * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators).
+ */
+val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
index eb40222af..07c6ab3ad 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:Suppress("UnusedPrivateProperty")
+@file:Suppress("MatchingDeclarationName")
package org.meshtastic.core.ui.theme
@@ -25,6 +25,7 @@ import androidx.compose.material3.MotionScheme.Companion.expressive
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@@ -272,19 +273,33 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
+ contrastLevel: ContrastLevel = ContrastLevel.STANDARD,
content:
@Composable()
() -> Unit,
) {
- val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null
- val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme
+ val dynamicScheme =
+ if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) {
+ dynamicColorScheme(darkTheme)
+ } else {
+ null
+ }
+ val colorScheme =
+ dynamicScheme
+ ?: when (contrastLevel) {
+ ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme
+ ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme
+ else -> if (darkTheme) darkScheme else lightScheme
+ }
- MaterialExpressiveTheme(
- colorScheme = colorScheme,
- typography = AppTypography,
- motionScheme = expressive(),
- content = content,
- )
+ CompositionLocalProvider(LocalContrastLevel provides contrastLevel) {
+ MaterialExpressiveTheme(
+ colorScheme = colorScheme,
+ typography = AppTypography,
+ motionScheme = expressive(),
+ content = content,
+ )
+ }
}
const val MODE_DYNAMIC = 6969420
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index b1c4cebf2..12f1ea0f5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -118,6 +118,7 @@ class UIViewModel(
}
val theme: StateFlow = uiPrefs.theme
+ val contrastLevel: StateFlow = uiPrefs.contrastLevel
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 8b33a3612..11111dd7a 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -169,7 +169,8 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
val uiPrefs = koinInject()
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
-
+ val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0)
+ val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale)
val isDarkTheme =
@@ -179,7 +180,7 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
else -> isSystemInDarkTheme()
}
- MeshtasticDesktopApp(uiViewModel, isDarkTheme)
+ MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel)
}
// ----- Application chrome (tray, window, navigation) -----
@@ -187,7 +188,11 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
/** Composes the system tray, window, and Coil image loader. */
@Composable
@OptIn(ExperimentalCoilApi::class)
-private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) {
+private fun ApplicationScope.MeshtasticDesktopApp(
+ uiViewModel: UIViewModel,
+ isDarkTheme: Boolean,
+ contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel,
+) {
var isAppVisible by remember { mutableStateOf(true) }
var isWindowReady by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
@@ -219,7 +224,7 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
)
if (isWindowReady && isAppVisible) {
- MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false }
+ MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false }
}
}
@@ -267,6 +272,7 @@ private fun WindowBoundsManager(
private fun ApplicationScope.MeshtasticWindow(
uiViewModel: UIViewModel,
isDarkTheme: Boolean,
+ contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel,
appIcon: Painter,
windowState: WindowState,
onCloseRequest: () -> Unit,
@@ -281,7 +287,9 @@ private fun ApplicationScope.MeshtasticWindow(
onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) },
) {
CoilImageLoaderSetup()
- AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
+ AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) {
+ DesktopMainScreen(uiViewModel, multiBackstack)
+ }
}
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
index 380b913a5..c4c99720c 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
@@ -37,7 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
@@ -134,7 +133,7 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit,
.clickable { onReact(emoji) },
contentAlignment = Alignment.Center,
) {
- Text(text = emoji, fontSize = 20.sp)
+ Text(text = emoji, style = MaterialTheme.typography.titleMedium)
}
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 586b91dd6..7d8747eb8 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -29,14 +29,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -47,8 +45,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -72,6 +73,8 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.FormatQuote
import org.meshtastic.core.ui.icon.HopCount
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.theme.ContrastLevel
+import org.meshtastic.core.ui.theme.LocalContrastLevel
import org.meshtastic.core.ui.theme.MessageItemColors
import org.meshtastic.core.ui.util.createClipEntry
@@ -175,7 +178,9 @@ fun MessageItem(
}
val containsBel = message.text.contains('\u0007')
+ val contrastLevel = LocalContrastLevel.current
+ val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second)
val alpha =
if (message.filtered) {
FILTERED_ALPHA
@@ -184,15 +189,31 @@ fun MessageItem(
} else {
NORMAL_ALPHA
}
+
val containerColor =
- if (message.fromLocal) {
- Color(ourNode.colors.second).copy(alpha = alpha)
- } else {
- Color(node.colors.second).copy(alpha = alpha)
+ when (contrastLevel) {
+ ContrastLevel.HIGH ->
+ when {
+ message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow
+ inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest
+ inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow
+ else -> MaterialTheme.colorScheme.surfaceContainerHigh
+ }
+ ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f))
+ ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha)
+ }
+ val contentColor =
+ when (contrastLevel) {
+ ContrastLevel.HIGH,
+ ContrastLevel.MEDIUM,
+ -> MaterialTheme.colorScheme.onSurface
+ ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first)
+ }
+ val metadataStyle =
+ when (contrastLevel) {
+ ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall
+ else -> MaterialTheme.typography.labelSmall
}
- val cardColors =
- CardDefaults.cardColors()
- .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
val messageShape =
getMessageBubbleShape(
cornerRadius = 8.dp,
@@ -206,7 +227,12 @@ fun MessageItem(
if (containsBel) {
Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape)
} else {
- Modifier
+ when (contrastLevel) {
+ ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape)
+ ContrastLevel.MEDIUM ->
+ Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape)
+ ContrastLevel.STANDARD -> Modifier
+ }
},
)
val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name
@@ -244,9 +270,12 @@ fun MessageItem(
onDoubleClick = onDoubleClick,
)
.then(messageModifier)
- .semantics(mergeDescendants = true) { contentDescription = messageA11yText },
+ .semantics(mergeDescendants = true) {
+ contentDescription = messageA11yText
+ role = Role.Button
+ },
color = containerColor,
- contentColor = contentColorFor(containerColor),
+ contentColor = contentColor,
shape = messageShape,
) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
@@ -254,16 +283,11 @@ fun MessageItem(
modifier = Modifier.fillMaxWidth(),
message = message,
ourNode = ourNode,
- hasSamePrev = hasSamePrev,
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
)
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
- AutoLinkText(
- text = message.text,
- style = MaterialTheme.typography.bodyMedium,
- color = cardColors.contentColor,
- )
+ AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor)
Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
if (!message.fromLocal) {
@@ -281,7 +305,10 @@ fun MessageItem(
imageVector = MeshtasticIcons.HopCount,
contentDescription = null,
modifier = Modifier.size(14.dp),
- tint = cardColors.contentColor.copy(alpha = 0.7f),
+ tint =
+ contentColor.copy(
+ alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f,
+ ),
)
Text(
text =
@@ -290,7 +317,7 @@ fun MessageItem(
} else {
"?"
},
- style = MaterialTheme.typography.labelSmall,
+ style = metadataStyle,
)
}
}
@@ -306,8 +333,13 @@ fun MessageItem(
if (message.filtered) {
Text(
text = stringResource(Res.string.filter_message_label),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = metadataStyle,
+ color =
+ if (contrastLevel == ContrastLevel.HIGH) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
)
}
@@ -318,11 +350,7 @@ fun MessageItem(
)
}
Spacer(modifier = Modifier.weight(1f))
- Text(
- modifier = Modifier.padding(start = 16.dp),
- text = message.time,
- style = MaterialTheme.typography.labelSmall,
- )
+ Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle)
}
}
}
@@ -356,30 +384,33 @@ private enum class ActiveSheet {
private fun OriginalMessageSnippet(
message: Message,
ourNode: Node,
- hasSamePrev: Boolean,
onNavigateToOriginalMessage: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val originalMessage = message.originalMessage
if (originalMessage != null && originalMessage.packetId != 0) {
val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node
- val cardColors =
- CardDefaults.cardColors()
- .copy(
- containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f),
- contentColor = Color(originalMessageNode.colors.first),
- )
+ val contrastLevel = LocalContrastLevel.current
+ val replyContainerColor =
+ when (contrastLevel) {
+ ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer
+ else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f)
+ }
+ val replyContentColor =
+ when (contrastLevel) {
+ ContrastLevel.HIGH,
+ ContrastLevel.MEDIUM,
+ -> MaterialTheme.colorScheme.onSurface
+ ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first)
+ }
+ // Rectangle shape — the outer message bubble's Surface clips to its
+ // rounded corners, so the reply header inherits the correct top radii
+ // automatically and stays square on the bottom where body text follows.
Surface(
modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
- contentColor = cardColors.contentColor,
- color = cardColors.containerColor,
- shape =
- getMessageBubbleShape(
- cornerRadius = 16.dp,
- isSender = originalMessage.fromLocal,
- hasSamePrev = hasSamePrev,
- hasSameNext = true, // always square off original message bottom
- ),
+ contentColor = replyContentColor,
+ color = replyContainerColor,
+ shape = RectangleShape,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
index 6545083bb..27797592b 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
@@ -123,7 +123,6 @@ internal fun ReactionItem(
text = emojiCount.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
- fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -248,7 +247,13 @@ internal fun ReactionDialog(
text = "$emoji${reactions.size}",
modifier =
Modifier.clip(CircleShape)
- .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
+ .background(
+ if (selectedEmoji == emoji) {
+ MaterialTheme.colorScheme.surfaceContainerHigh
+ } else {
+ Color.Transparent
+ },
+ )
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier)
.padding(8.dp)
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 82558309d..eeab3b873 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -56,6 +56,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
+import org.meshtastic.feature.settings.component.ContrastPickerDialog
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
@@ -155,6 +156,14 @@ fun SettingsScreen(
)
}
+ var showContrastPickerDialog by remember { mutableStateOf(false) }
+ if (showContrastPickerDialog) {
+ ContrastPickerDialog(
+ onClickContrast = { settingsViewModel.setContrastLevel(it) },
+ onDismiss = { showContrastPickerDialog = false },
+ )
+ }
+
Scaffold(
topBar = {
MainAppBar(
@@ -227,6 +236,7 @@ fun SettingsScreen(
AppearanceSection(
onShowLanguagePicker = { showLanguagePickerDialog = true },
onShowThemePicker = { showThemePickerDialog = true },
+ onShowContrastPicker = { showContrastPickerDialog = true },
)
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
index f70cda978..cb61c8295 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
@@ -28,6 +28,7 @@ import androidx.core.net.toUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.contrast
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.theme
import org.meshtastic.core.ui.component.ListItem
@@ -37,9 +38,13 @@ import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
-/** Section for app appearance settings like language and theme. */
+/** Section for app appearance settings like language, theme, and contrast. */
@Composable
-fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
+fun AppearanceSection(
+ onShowLanguagePicker: () -> Unit,
+ onShowThemePicker: () -> Unit,
+ onShowContrastPicker: () -> Unit,
+) {
val context = LocalContext.current
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
@@ -74,11 +79,19 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () ->
) {
onShowThemePicker()
}
+
+ ListItem(
+ text = stringResource(Res.string.contrast),
+ leadingIcon = MeshtasticIcons.FormatPaint,
+ trailingIcon = null,
+ ) {
+ onShowContrastPicker()
+ }
}
}
@Preview(showBackground = true)
@Composable
private fun AppearanceSectionPreview() {
- AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
+ AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) }
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index fc5923c1a..d4b39565b 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -33,6 +33,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
@@ -65,6 +66,7 @@ class SettingsViewModel(
private val meshLogPrefs: MeshLogPrefs,
private val notificationPrefs: NotificationPrefs,
private val setThemeUseCase: SetThemeUseCase,
+ private val setContrastLevelUseCase: SetContrastLevelUseCase,
private val setLocaleUseCase: SetLocaleUseCase,
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
private val setProvideLocationUseCase: SetProvideLocationUseCase,
@@ -162,6 +164,10 @@ class SettingsViewModel(
setThemeUseCase(theme)
}
+ fun setContrastLevel(level: Int) {
+ setContrastLevelUseCase(level)
+ }
+
/** Set the application locale. Empty string means system default. */
fun setLocale(languageTag: String) {
setLocaleUseCase(languageTag)
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt
new file mode 100644
index 000000000..c8adc418a
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+@file:Suppress("MatchingDeclarationName")
+
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.choose_contrast
+import org.meshtastic.core.resources.contrast_high
+import org.meshtastic.core.resources.contrast_medium
+import org.meshtastic.core.resources.contrast_standard
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.theme.ContrastLevel
+
+/** Contrast level options matching [ContrastLevel] ordinal values. */
+enum class ContrastOption(val label: StringResource, val level: ContrastLevel) {
+ STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD),
+ MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM),
+ HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH),
+}
+
+/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */
+@Composable
+fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) {
+ MeshtasticDialog(
+ title = stringResource(Res.string.choose_contrast),
+ onDismiss = onDismiss,
+ text = {
+ Column {
+ ContrastOption.entries.forEach { option ->
+ ListItem(text = stringResource(option.label), trailingIcon = null) {
+ onClickContrast(option.level.value)
+ onDismiss()
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
index 64eab2f80..0ba5c3a79 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
@@ -96,6 +97,7 @@ class SettingsViewModelTest {
val uiPrefs = appPreferences.ui
val setThemeUseCase = SetThemeUseCase(uiPrefs)
+ val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs)
val setLocaleUseCase = SetLocaleUseCase(uiPrefs)
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs)
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs)
@@ -116,6 +118,7 @@ class SettingsViewModelTest {
meshLogPrefs = appPreferences.meshLog,
notificationPrefs = notificationPrefs,
setThemeUseCase = setThemeUseCase,
+ setContrastLevelUseCase = setContrastLevelUseCase,
setLocaleUseCase = setLocaleUseCase,
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
setProvideLocationUseCase = setProvideLocationUseCase,
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
index 9a221f8dd..2e358a58c 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
+import org.meshtastic.core.resources.contrast
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.info
@@ -67,6 +68,7 @@ import org.meshtastic.core.ui.icon.Memory
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
+import org.meshtastic.feature.settings.component.ContrastPickerDialog
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
@@ -101,6 +103,7 @@ fun DesktopSettingsScreen(
var showThemePickerDialog by remember { mutableStateOf(false) }
var showLanguagePickerDialog by remember { mutableStateOf(false) }
+ var showContrastPickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
@@ -108,6 +111,13 @@ fun DesktopSettingsScreen(
)
}
+ if (showContrastPickerDialog) {
+ ContrastPickerDialog(
+ onClickContrast = { settingsViewModel.setContrastLevel(it) },
+ onDismiss = { showContrastPickerDialog = false },
+ )
+ }
+
if (showLanguagePickerDialog) {
LanguagePickerDialog(
onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) },
@@ -172,6 +182,14 @@ fun DesktopSettingsScreen(
showThemePickerDialog = true
}
+ ListItem(
+ text = stringResource(Res.string.contrast),
+ leadingIcon = MeshtasticIcons.FormatPaint,
+ trailingIcon = null,
+ ) {
+ showContrastPickerDialog = true
+ }
+
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = MeshtasticIcons.Language,
From bf0deef7089000162d0bb61d8f29c0ce58827bf2 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 20:14:31 -0500
Subject: [PATCH 11/62] fix(icons): audit and correct icon migration
regressions from #5030 #5040 #5056 (#5136)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../org/meshtastic/core/ui/icon/Device.kt | 3 +-
.../messaging/component/MessageStatusIcon.kt | 10 +--
.../feature/node/component/NodeItem.kt | 5 ++
.../feature/node/component/NodeStatusIcons.kt | 61 ++-----------------
.../feature/node/list/NodeListScreen.kt | 2 +
.../feature/node/list/NodeListViewModel.kt | 9 +++
.../node/list/NodeListViewModelTest.kt | 4 ++
7 files changed, 34 insertions(+), 60 deletions(-)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt
index 66060116f..6bf669ab6 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt
@@ -25,6 +25,7 @@ import org.meshtastic.core.resources.ic_fingerprint
import org.meshtastic.core.resources.ic_fork_left
import org.meshtastic.core.resources.ic_home
import org.meshtastic.core.resources.ic_icecream
+import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_military_tech
import org.meshtastic.core.resources.ic_mountain_flag
import org.meshtastic.core.resources.ic_my_location
@@ -75,4 +76,4 @@ val MeshtasticIcons.DeviceNumbers: ImageVector
val MeshtasticIcons.Android: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_android)
val MeshtasticIcons.HardwareModel: ImageVector
- @Composable get() = vectorResource(Res.drawable.ic_router)
+ @Composable get() = vectorResource(Res.drawable.ic_memory)
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt
index 501a3f7dc..7b361d497 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt
@@ -24,11 +24,13 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.ui.icon.Acknowledged
+import org.meshtastic.core.ui.icon.AddLink
+import org.meshtastic.core.ui.icon.CloudUpload
+import org.meshtastic.core.ui.icon.LinkIcon
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MessageEnroute
import org.meshtastic.core.ui.icon.MessageError
import org.meshtastic.core.ui.icon.MqttDelivered
-import org.meshtastic.core.ui.icon.MqttSyncing
import org.meshtastic.core.ui.icon.Warning
@Composable
@@ -36,10 +38,10 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) {
val icon =
when (status) {
MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged
- MessageStatus.QUEUED -> MeshtasticIcons.MqttSyncing
+ MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload
MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered
- MessageStatus.SFPP_ROUTING -> MeshtasticIcons.MqttSyncing
- MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.MqttDelivered
+ MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink
+ MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon
MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute
MessageStatus.ERROR -> MeshtasticIcons.MessageError
else -> MeshtasticIcons.Warning
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 514be15e7..ad6714db7 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -48,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
@@ -106,6 +107,7 @@ fun NodeItem(
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
connectionState: ConnectionState,
+ deviceType: DeviceType? = null,
isActive: Boolean = false,
) {
val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
@@ -166,6 +168,7 @@ fun NodeItem(
isMuted = isMuted,
isUnmessageable = unmessageable,
connectionState = connectionState,
+ deviceType = deviceType,
contentColor = contentColor,
)
@@ -400,6 +403,7 @@ private fun NodeItemHeader(
isMuted: Boolean,
isUnmessageable: Boolean,
connectionState: ConnectionState,
+ deviceType: DeviceType?,
contentColor: Color,
) {
Row(
@@ -445,6 +449,7 @@ private fun NodeItemHeader(
isMuted = isMuted,
isUnmessageable = isUnmessageable,
connectionState = connectionState,
+ deviceType = deviceType,
contentColor = contentColor,
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index 007c12c96..1bbafad6a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -46,17 +47,11 @@ import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
-import org.meshtastic.core.ui.icon.DeviceSleep
-import org.meshtastic.core.ui.icon.Disconnected
+import org.meshtastic.core.ui.component.ConnectionsNavIcon
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.MqttDelivered
-import org.meshtastic.core.ui.icon.MqttSyncing
import org.meshtastic.core.ui.icon.Unmessageable
import org.meshtastic.core.ui.icon.VolumeOff
-import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
-import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
-import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@OptIn(ExperimentalMaterial3Api::class)
@@ -68,11 +63,12 @@ fun NodeStatusIcons(
isMuted: Boolean,
connectionState: ConnectionState,
modifier: Modifier = Modifier,
+ deviceType: DeviceType? = null,
contentColor: Color = LocalContentColor.current,
) {
Row(modifier = modifier.padding(4.dp)) {
if (isThisNode) {
- ThisNodeStatusBadge(connectionState)
+ ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType)
}
if (isUnmessageable) {
@@ -104,7 +100,7 @@ fun NodeStatusIcons(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
+private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: DeviceType?) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
@@ -123,55 +119,10 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
},
state = rememberTooltipState(),
) {
- when (connectionState) {
- ConnectionState.Connected -> ConnectedStatusIcon()
- ConnectionState.Connecting -> ConnectingStatusIcon()
- ConnectionState.Disconnected -> DisconnectedStatusIcon()
- ConnectionState.DeviceSleep -> DeviceSleepStatusIcon()
- }
+ ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp))
}
}
-@Composable
-private fun ConnectedStatusIcon() {
- Icon(
- imageVector = MeshtasticIcons.MqttDelivered,
- contentDescription = stringResource(Res.string.connected),
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.StatusGreen,
- )
-}
-
-@Composable
-private fun ConnectingStatusIcon() {
- Icon(
- imageVector = MeshtasticIcons.MqttSyncing,
- contentDescription = stringResource(Res.string.connecting),
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.StatusOrange,
- )
-}
-
-@Composable
-private fun DisconnectedStatusIcon() {
- Icon(
- imageVector = MeshtasticIcons.Disconnected,
- contentDescription = stringResource(Res.string.disconnected),
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.StatusRed,
- )
-}
-
-@Composable
-private fun DeviceSleepStatusIcon() {
- Icon(
- imageVector = MeshtasticIcons.DeviceSleep,
- contentDescription = stringResource(Res.string.device_sleeping),
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colorScheme.StatusYellow,
- )
-}
-
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatusBadge(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 9c2c208f4..5a156b836 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -97,6 +97,7 @@ fun NodeListScreen(
}
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
+ val deviceType by viewModel.deviceType.collectAsStateWithLifecycle()
val isScrollInProgress by remember {
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
@@ -187,6 +188,7 @@ fun NodeListScreen(
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
connectionState = connectionState,
+ deviceType = deviceType,
isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index df65a3477..172a296eb 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -23,13 +23,16 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
@@ -45,6 +48,7 @@ class NodeListViewModel(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
+ private val radioInterfaceService: RadioInterfaceService,
val nodeManagementActions: NodeManagementActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,
val nodeFilterPreferences: NodeFilterPreferences,
@@ -58,6 +62,11 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
+ val deviceType: StateFlow =
+ radioInterfaceService.currentDeviceAddressFlow
+ .map { address -> address?.let { DeviceType.fromAddress(it) } }
+ .stateInWhileSubscribed(initialValue = null)
+
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
index 602134aa0..9511a2da1 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt
@@ -32,6 +32,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeRadioInterfaceService
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
@@ -45,6 +46,7 @@ class NodeListViewModelTest {
private lateinit var viewModel: NodeListViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
+ private lateinit var radioInterfaceService: FakeRadioInterfaceService
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill)
@@ -55,6 +57,7 @@ class NodeListViewModelTest {
fun setUp() {
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
+ radioInterfaceService = FakeRadioInterfaceService()
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile())
@@ -79,6 +82,7 @@ class NodeListViewModelTest {
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
radioController = radioController,
+ radioInterfaceService = radioInterfaceService,
nodeManagementActions = nodeManagementActions,
getFilteredNodesUseCase = getFilteredNodesUseCase,
nodeFilterPreferences = nodeFilterPreferences,
From 79ed0a865a1d2b7fe9218a8ac3aad25711015316 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 20:14:38 -0500
Subject: [PATCH 12/62] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5128)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
app/src/main/assets/firmware_releases.json | 14 +++++++-------
.../composeResources/values-et/strings.xml | 3 +++
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 4859e45cf..ffdb465d6 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,6 +24,13 @@
}
],
"alpha": [
+ {
+ "id": "v2.7.22.96dd647",
+ "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha",
+ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647",
+ "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json",
+ "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647"
+ },
{
"id": "v2.7.21.1370b23",
"title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
@@ -177,13 +184,6 @@
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7",
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip",
"release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7"
- },
- {
- "id": "v2.6.7.2d6181f",
- "title": "Meshtastic Firmware 2.6.7.2d6181f Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip",
- "release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f"
}
]
},
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 4b8e5a879..6f5a7fa4d 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -582,6 +582,9 @@
Väljundi kestvus (millisekundit)
Häire ajalõpp (sekundit)
Helin
+ Imporditud helin
+ Fail on tühi
+ Viga importimisel: %1$s
Mängi ette
Kasuta I2S summerina
LoRa
From 50ade01e554f433fcc15c760e88cd7ae46762f16 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 20:49:34 -0500
Subject: [PATCH 13/62] docs(agents): add PR and commit hygiene guidance
(#5137)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
AGENTS.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 9fcc166b5..ab2549475 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,3 +66,9 @@ Do NOT duplicate content into agent-specific files. When you modify architecture
- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
+
+
+- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
+- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
+- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
+
From 72b981f73b78c8d5e2e62349dc10a50f32eca13c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 21:17:50 -0500
Subject: [PATCH 14/62] =?UTF-8?q?chore:=20KMP=20audit=20=E2=80=94=20common?=
=?UTF-8?q?ize=20code,=20centralize=20utilities,=20eliminate=20dead=20abst?=
=?UTF-8?q?ractions=20(#5133)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../instructions/kmp-common.instructions.md | 3 +
.github/workflows/reusable-check.yml | 2 +-
.skills/code-review/SKILL.md | 9 +-
.skills/compose-ui/SKILL.md | 32 +-
.skills/implement-feature/SKILL.md | 2 +-
.skills/kmp-architecture/SKILL.md | 8 +-
.skills/navigation-and-di/SKILL.md | 7 +
.skills/project-overview/SKILL.md | 5 +
.skills/testing-ci/SKILL.md | 11 +-
AGENTS.md | 14 +-
.../kotlin/org/meshtastic/app/MainActivity.kt | 4 +-
.../org/meshtastic/app/MeshUtilApplication.kt | 3 +-
.../org/meshtastic/app/di/NetworkModule.kt | 14 +-
.../main/kotlin/KmpFeatureConventionPlugin.kt | 6 +-
.../org/meshtastic/core/ble/BleRetry.kt | 4 +-
core/common/README.md | 4 +-
core/common/build.gradle.kts | 1 +
.../core/common/util/CommonUri.android.kt | 45 -
.../core/common/util/MeshtasticUriExt.kt | 25 -
.../org/meshtastic/core/common/ByteUtils.kt | 25 -
.../{MeshtasticUri.kt => AddressUtils.kt} | 17 +-
.../meshtastic/core/common/util/CommonUri.kt | 28 +-
.../meshtastic/core/common/util/Exceptions.kt | 28 +-
.../meshtastic/core/common/util/Formatter.kt | 113 +-
.../HomoglyphCharacterStringTransformer.kt | 6 +-
.../core/common/util/MetricFormatter.kt | 53 +
.../core/common/util/AddressUtilsTest.kt | 72 ++
...{MeshtasticUriTest.kt => CommonUriTest.kt} | 18 +-
.../core/common/util/FormatStringTest.kt | 44 +
.../core/common/util/MetricFormatterTest.kt | 123 ++
.../meshtastic/core/common/util/Formatter.kt | 130 --
.../meshtastic/core/common/util/NoopStubs.kt | 14 -
.../meshtastic/core/common/util/Formatter.kt | 20 -
.../core/common/util/CommonUri.jvm.kt | 49 -
.../core/common/util/JvmPlatformUtils.kt | 20 +-
.../core/common/util/CommonUriTest.kt | 44 -
.../core/data/manager/HistoryManagerImpl.kt | 3 +-
.../data/manager/MeshActionHandlerImpl.kt | 3 +-
.../data/manager/MeshConnectionManagerImpl.kt | 2 +-
.../data/manager/MeshMessageProcessorImpl.kt | 2 +-
.../DeviceHardwareRepositoryImpl.kt | 5 +-
.../FirmwareReleaseRepositoryImpl.kt | 5 +-
.../data/repository/PacketRepositoryImpl.kt | 2 +-
.../38.json | 1052 +++++++++++++++++
.../core/database/DatabaseConstants.kt | 12 +-
.../core/database/DatabaseManager.kt | 2 +
.../core/database/MeshtasticDatabase.kt | 3 +-
.../core/database/dao/MeshLogDao.kt | 6 +-
.../core/database/dao/NodeInfoDao.kt | 77 +-
.../meshtastic/core/database/dao/PacketDao.kt | 51 +-
.../core/database/entity/NodeEntity.kt | 1 +
.../meshtastic/core/database/entity/Packet.kt | 12 +-
.../core/model/util/AndroidDateTimeUtils.kt | 51 -
.../meshtastic/core/model/util/UriBridge.kt | 3 +-
.../kotlin/org/meshtastic/core/model/Node.kt | 32 +-
.../meshtastic/core/model/util/Extensions.kt | 2 +-
.../meshtastic/core/model/util/SfppHasher.kt | 24 +-
.../core/model/util/SharedContact.kt | 2 +-
.../core/model/util/CommonUtilsTest.kt} | 4 +-
.../core/model/util/SfppHasherTest.kt | 87 ++
.../meshtastic/core/model/util/NoopStubs.kt | 4 -
.../meshtastic/core/model/util/SfppHasher.kt | 35 -
.../core/network/HttpClientDefaults.kt | 3 +
.../core/network/radio/MockRadioTransport.kt | 4 +-
.../core/network/service/ApiService.kt | 8 +-
.../network/repository/JvmServiceDiscovery.kt | 6 +-
.../repository/JvmServiceDiscoveryTest.kt | 9 +-
.../core/prefs/mesh/MeshPrefsImpl.kt | 10 +-
.../meshtastic/core/repository/FileService.kt | 6 +-
.../core/repository/PacketRepository.kt | 2 +-
.../composeResources/values/strings.xml | 6 +-
.../core/service/AndroidFileServiceTest.kt | 9 +-
.../core/service/AndroidFileService.kt | 17 +-
.../meshtastic/core/service/JvmFileService.kt | 20 +-
.../org/meshtastic/core/takserver/CoTXml.kt | 32 +-
.../core/takserver/fountain/CodecExpect.kt | 8 +-
.../fountain/{CodecActual.kt => ZlibCodec.kt} | 19 -
.../fountain/{CodecActual.kt => ZlibCodec.kt} | 8 -
.../meshtastic/core/ui/util/PlatformUtils.kt | 14 +-
.../core/ui/component/DropDownPreference.kt | 13 +-
.../ui/component/EditPasswordPreference.kt | 4 +-
.../meshtastic/core/ui/component/ImportFab.kt | 9 +-
.../core/ui/component/LoraSignalIndicator.kt | 6 +-
.../core/ui/component/MaterialBatteryInfo.kt | 7 +-
.../core/ui/component/SignalInfo.kt | 7 +-
.../core/ui/emoji/EmojiPickerDialog.kt | 7 +-
.../core/ui/qr/ScannedQrCodeDialog.kt | 3 +-
.../meshtastic/core/ui/util/PlatformUtils.kt | 3 +-
.../core/ui/viewmodel/UIViewModel.kt | 9 +-
.../core/ui/viewmodel/ViewModelExtensions.kt | 2 +-
.../org/meshtastic/core/ui/util/NoopStubs.kt | 3 +-
.../meshtastic/core/ui/util/PlatformUtils.kt | 7 +-
.../kotlin/org/meshtastic/desktop/Main.kt | 14 +-
.../desktop/di/DesktopKoinModule.kt | 9 +-
docs/kmp-status.md | 8 +-
docs/roadmap.md | 4 +-
.../ui/components/CurrentlyConnectedInfo.kt | 7 +-
.../firmware/AndroidFirmwareFileHandler.kt | 23 +-
.../feature/firmware/FirmwareUpdateScreen.kt | 4 +-
.../firmware/FirmwareUpdateViewModel.kt | 15 +-
.../feature/firmware/ota/BleOtaTransport.kt | 9 +-
.../feature/firmware/ota/BleScanSupport.kt | 2 +-
.../feature/firmware/ota/WifiOtaTransport.kt | 7 +-
.../firmware/ota/dfu/SecureDfuTransport.kt | 20 +-
.../feature/messaging/QuickChatPreviews.kt | 0
.../component/MessageItemPreviews.kt | 0
.../messaging/component/ReactionPreviews.kt | 0
.../ui/contact/AdaptiveContactsScreen.kt | 4 +-
.../feature/messaging/ui/contact/Contacts.kt | 11 +-
.../node/component/NodeDetailsSection.kt | 6 +-
.../feature/node/component/NodeItem.kt | 32 +-
.../feature/node/list/NodeListScreen.kt | 4 +-
.../feature/node/metrics/DeviceMetrics.kt | 21 +-
.../feature/node/metrics/MetricsViewModel.kt | 17 +-
.../feature/node/metrics/PowerMetrics.kt | 17 +-
.../feature/node/metrics/SignalMetrics.kt | 20 +-
.../feature/node/metrics/TracerouteLog.kt | 7 +-
.../node/navigation/AdaptiveNodeListScreen.kt | 2 +-
.../node/navigation/NodesNavigation.kt | 4 +-
.../node/metrics/MetricsViewModelTest.kt | 4 +-
.../feature/settings/SettingsScreen.kt | 15 +-
.../component/SecurityConfigScreen.android.kt | 4 +-
.../feature/settings/SettingsViewModel.kt | 4 +-
.../feature/settings/debugging/DebugSearch.kt | 4 +-
.../settings/debugging/DebugViewModel.kt | 23 +-
.../settings/navigation/SettingsNavigation.kt | 3 +-
.../settings/radio/RadioConfigViewModel.kt | 8 +-
.../radio/channel/ChannelConfigScreen.kt | 8 +-
.../settings/radio/channel/ChannelScreen.kt | 4 +-
.../radio/component/LoRaConfigItemList.kt | 2 +-
.../wifiprovision/domain/NymeaWifiService.kt | 7 +-
gradle/libs.versions.toml | 3 +-
132 files changed, 2186 insertions(+), 916 deletions(-)
delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
delete mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
rename core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/{MeshtasticUri.kt => AddressUtils.kt} (62%)
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
rename core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/{MeshtasticUriTest.kt => CommonUriTest.kt} (65%)
create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
delete mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
delete mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
delete mode 100644 core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
delete mode 100644 core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
delete mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
rename core/{common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt => model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt} (95%)
create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
delete mode 100644 core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
rename core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/{CodecActual.kt => ZlibCodec.kt} (83%)
rename core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/{CodecActual.kt => ZlibCodec.kt} (90%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt (100%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt (100%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt (100%)
diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md
index 235d5826d..7dac915bc 100644
--- a/.github/instructions/kmp-common.instructions.md
+++ b/.github/instructions/kmp-common.instructions.md
@@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt"
- Never use plain `androidx.compose` dependencies in `commonMain`.
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
+- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
- Check `gradle/libs.versions.toml` before adding dependencies.
+- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
+- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 26dbe7685..632bf1ea4 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -213,7 +213,7 @@ jobs:
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
- if: ${{ !cancelled() }}
+ if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md
index 6a774297c..acab253d5 100644
--- a/.skills/code-review/SKILL.md
+++ b/.skills/code-review/SKILL.md
@@ -13,14 +13,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
- - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` or `expect`/`actual`
+ - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
+- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
-- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`.
+- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
@@ -36,8 +37,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi
### 5. Networking, DB & I/O
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
+- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
+- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
### 6. Dependency Catalog Aliases
@@ -47,7 +50,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
### 7. Testing
-- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
+- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md
index d2e79c542..22fe1b489 100644
--- a/.skills/compose-ui/SKILL.md
+++ b/.skills/compose-ui/SKILL.md
@@ -14,8 +14,31 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`).
+ - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
+ ```kotlin
+ val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
+ stringResource(Res.string.battery_percent, formatted) // uses %1$s
+ ```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
+
+### String Formatting Decision Tree
+Choose the right tool for the job:
+
+| Scenario | Tool | Example |
+|----------|------|---------|
+| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` |
+| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
+| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
+| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
+| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
+| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
+
+**Rules:**
+1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
+2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
+3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
+4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
+
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.` symbol.
@@ -25,6 +48,13 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
+## 4. Compose Previews
+- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
+- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
+
+## 5. Dialog & State Patterns
+- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
+
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md
index 0e76b30e6..0277bee10 100644
--- a/.skills/implement-feature/SKILL.md
+++ b/.skills/implement-feature/SKILL.md
@@ -33,7 +33,7 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
### 6. Verify Locally
- Run the baseline checks (see `testing-ci` skill):
```bash
- ./gradlew spotlessCheck detekt assembleDebug test allTests
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
```bash
diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md
index 805d9f2f9..46602c430 100644
--- a/.skills/kmp-architecture/SKILL.md
+++ b/.skills/kmp-architecture/SKILL.md
@@ -16,12 +16,14 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
## 3. Core Libraries & Constraints
-- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly.
+- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
+- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
- **Standard Library Replacements:**
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
+- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
- **BLE:** Route through `core:ble` using **Kable**.
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
@@ -38,6 +40,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
## 6. I/O & Serialization
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
+- **Room Patterns:**
+ - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
+ - Use `LIMIT 1` on `@Query` methods that expect a single row.
+ - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
## 7. Build-Logic Conventions
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md
index 557db4717..e92e2cfa3 100644
--- a/.skills/navigation-and-di/SKILL.md
+++ b/.skills/navigation-and-di/SKILL.md
@@ -15,6 +15,13 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
- **A1 Module Compile Safety:** Do **not** enable A1 `compileSafety`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) because of our decoupled Clean Architecture design (interfaces in one module, implemented in another).
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
+### Koin Startup Pattern (K2 Compiler Plugin)
+The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The correct canonical startup for this path is:
+```kotlin
+startKoin { modules(AppKoinModule().module()) }
+```
+Do **not** use `@KoinApplication` — that annotation is part of the **KSP annotations path** (`koin-ksp-compiler`) and generates a `startKoin()` extension via KSP. It is incompatible with the K2 plugin approach. The two paths are mutually exclusive; the project has deliberately chosen K2 for compile-time wiring without KSP overhead.
+
## Navigation 3
### Guidelines
diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md
index d7d6af473..291cff488 100644
--- a/.skills/project-overview/SKILL.md
+++ b/.skills/project-overview/SKILL.md
@@ -73,6 +73,11 @@ Agents **MUST** perform these steps automatically at the start of every session
git submodule update --init
```
+3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
+ ```bash
+ [ -f local.properties ] || cp secrets.defaults.properties local.properties
+ ```
+
## Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
index 0dca01eb6..2c20258c1 100644
--- a/.skills/testing-ci/SKILL.md
+++ b/.skills/testing-ci/SKILL.md
@@ -5,17 +5,14 @@ Guidelines and commands for verifying code changes locally and understanding the
## 1) Baseline local verification order
-Run in this order for routine changes to ensure code formatting, analysis, and basic compilation:
+Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
```bash
-./gradlew clean
-./gradlew spotlessCheck
-./gradlew spotlessApply
-./gradlew detekt
-./gradlew assembleDebug
-./gradlew test allTests
+./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
+> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
+
> **Why `test allTests` and not just `test`:**
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
diff --git a/AGENTS.md b/AGENTS.md
index ab2549475..07d9b0050 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,11 +25,16 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
+ 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
- `./gradlew clean spotlessCheck spotlessApply detekt assembleDebug test allTests`
+ ```
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+ ```
+ > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
+ > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
@@ -57,9 +62,10 @@ Do NOT duplicate content into agent-specific files. When you modify architecture
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
-- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`.
-- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety.
-- **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`).
+- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
+- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
+- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
+- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 8316ad8e2..0864e55cd 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -45,6 +45,7 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
+import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
@@ -57,7 +58,6 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
-import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
@@ -278,7 +278,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
+ model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
private fun createShareIntent(message: String): PendingIntent {
diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index d32cc3df6..34d4797cd 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -28,6 +28,7 @@ import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
@@ -57,7 +58,7 @@ open class MeshUtilApplication :
Application(),
Configuration.Provider {
- private val applicationScope = CoroutineScope(Dispatchers.Default)
+ private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index dd7e9d8be..91ab81ec0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -24,6 +24,8 @@ import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
+import coil3.memoryCacheMaxSizePercentWhileInBackground
+import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
@@ -31,11 +33,13 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.request.url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import okio.Path.Companion.toOkioPath
@@ -47,6 +51,7 @@ import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
private const val MEMORY_CACHE_PERCENT = 0.25
+private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
@Module
class NetworkModule {
@@ -67,7 +72,12 @@ class NetworkModule {
buildConfigProvider: BuildConfigProvider,
): ImageLoader = ImageLoader.Builder(context = application)
.components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ add(
+ KtorNetworkFetcherFactory(
+ httpClient = httpClient,
+ concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
+ ),
+ )
add(SvgDecoder.Factory(scaleToDensity = true))
}
.memoryCache {
@@ -80,6 +90,7 @@ class NetworkModule {
.build()
}
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
+ .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
.crossfade(enable = true)
.build()
@@ -87,6 +98,7 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
+ install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
install(plugin = HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
index 6af52cd50..be280f29c 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -54,16 +54,18 @@ class KmpFeatureConventionPlugin : Plugin {
// Logging
implementation(libs.library("kermit"))
+
+ // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview)
+ // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11
+ implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("androidMain").dependencies {
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
- implementation(libs.library("compose-multiplatform-material3"))
implementation(libs.library("compose-multiplatform-ui"))
- implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
index c636d4718..5e85a52f8 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
@@ -48,9 +48,7 @@ suspend fun retryBleOperation(
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
- Logger.w(e) {
- "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
- }
+ Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
delay(delayMs)
}
}
diff --git a/core/common/README.md b/core/common/README.md
index da7700ac5..979586213 100644
--- a/core/common/README.md
+++ b/core/common/README.md
@@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
-### 2. `ByteUtils.kt`
-Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
+### 2. `MetricFormatter.kt`
+Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces.
### 3. `BuildConfigProvider.kt`
An interface for accessing build-time configuration in a multiplatform-friendly way.
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 08ec08865..e4d94943e 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -37,6 +37,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
+ api(libs.uri.kmp)
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
deleted file mode 100644
index a99bccd84..000000000
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * 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.common.util
-
-import android.net.Uri
-
-actual class CommonUri(private val uri: Uri) {
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.pathSegments
-
- actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
- uri.getBooleanQueryParameter(key, defaultValue)
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
- }
-
- fun toUri(): Uri = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
deleted file mode 100644
index 7669a66b0..000000000
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * 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 .
- */
-package org.meshtastic.core.common.util
-
-import android.net.Uri
-
-/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */
-fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString)
-
-/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */
-fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString())
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
deleted file mode 100644
index c27040e73..000000000
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 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.common
-
-/** Utility function to make it easy to declare byte arrays */
-fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
-
-fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
-
-private const val BYTE_MASK = 0xff
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
similarity index 62%
rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
index 0babff5b1..1072801c6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2026 Meshtastic LLC
+ * 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
@@ -17,13 +17,14 @@
package org.meshtastic.core.common.util
/**
- * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
- * modules without coupling them to the android.net.Uri class.
+ * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
+ * blank, or sentinel values (`"N"`, `"NULL"`).
*/
-data class MeshtasticUri(val uriString: String) {
- override fun toString(): String = uriString
-
- companion object {
- fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
+fun normalizeAddress(addr: String?): String {
+ val u = addr?.trim()?.uppercase()
+ return when {
+ u.isNullOrBlank() -> "DEFAULT"
+ u == "N" || u == "NULL" -> "DEFAULT"
+ else -> u.replace(":", "")
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
index 7079cbf5e..00b15861f 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
@@ -16,22 +16,14 @@
*/
package org.meshtastic.core.common.util
-/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
-expect class CommonUri {
- val host: String?
- val fragment: String?
- val pathSegments: List
+import com.eygraber.uri.Uri
- fun getQueryParameter(key: String): String?
-
- fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
-
- override fun toString(): String
-
- companion object {
- fun parse(uriString: String): CommonUri
- }
-}
-
-/** Extension to convert platform Uri to CommonUri in Android source sets. */
-expect fun CommonUri.toPlatformUri(): Any
+/**
+ * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
+ *
+ * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
+ * identically on Android, JVM, and iOS without platform stubs.
+ *
+ * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
+ */
+typealias CommonUri = Uri
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
index ccd565286..c5d3c2091 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
@@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
-/** Suspend-compatible variant of [ignoreException]. */
+/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
try {
inner()
+ } catch (e: CancellationException) {
+ throw e
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
@@ -69,3 +72,26 @@ fun exceptionReporter(inner: () -> Unit) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
+
+/**
+ * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead
+ * of [runCatching] in coroutine contexts.
+ */
+@Suppress("TooGenericExceptionCaught")
+inline fun safeCatching(block: () -> T): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Result.failure(e)
+}
+
+/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */
+@Suppress("TooGenericExceptionCaught")
+inline fun T.safeCatching(block: T.() -> R): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Result.failure(e)
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
index d54455df8..7a24819a7 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -16,5 +16,114 @@
*/
package org.meshtastic.core.common.util
-/** Multiplatform string formatting helper. */
-expect fun formatString(pattern: String, vararg args: Any?): String
+/**
+ * Pure-Kotlin multiplatform string formatting.
+ *
+ * Implements the subset of Java's `String.format()` patterns used in this codebase:
+ * - `%s`, `%d` — positional or sequential string/integer
+ * - `%N$s`, `%N$d` — explicit positional string/integer
+ * - `%N$.Nf`, `%.Nf` — float with decimal precision
+ * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
+ * - `%%` — literal percent
+ */
+@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements")
+fun formatString(pattern: String, vararg args: Any?): String = buildString {
+ var i = 0
+ var autoIndex = 0
+ while (i < pattern.length) {
+ if (pattern[i] != '%') {
+ append(pattern[i])
+ i++
+ continue
+ }
+ i++ // skip '%'
+ if (i >= pattern.length) break
+
+ // Literal %%
+ if (pattern[i] == '%') {
+ append('%')
+ i++
+ continue
+ }
+
+ // Parse optional positional index (N$)
+ var explicitIndex: Int? = null
+ val startPos = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i < pattern.length && pattern[i] == '$' && i > startPos) {
+ explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
+ i++ // skip '$'
+ } else {
+ i = startPos // rewind — digits are part of width/precision, not positional index
+ }
+
+ // Parse optional flags (zero-pad)
+ var zeroPad = false
+ if (i < pattern.length && pattern[i] == '0') {
+ zeroPad = true
+ i++
+ }
+
+ // Parse optional width
+ var width: Int? = null
+ val widthStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > widthStart) {
+ width = pattern.substring(widthStart, i).toInt()
+ }
+
+ // Parse optional precision (.N)
+ var precision: Int? = null
+ if (i < pattern.length && pattern[i] == '.') {
+ i++ // skip '.'
+ val precStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > precStart) {
+ precision = pattern.substring(precStart, i).toInt()
+ }
+ }
+
+ // Parse conversion character
+ if (i >= pattern.length) break
+ val conversion = pattern[i]
+ i++
+
+ val argIndex = explicitIndex ?: autoIndex++
+ val arg = args.getOrNull(argIndex)
+
+ when (conversion) {
+ 's' -> append(arg?.toString() ?: "null")
+ 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
+ 'f' -> {
+ val value = (arg as? Number)?.toDouble() ?: 0.0
+ val places = precision ?: DEFAULT_FLOAT_PRECISION
+ append(NumberFormatter.format(value, places))
+ }
+ 'x',
+ 'X',
+ -> {
+ val value = (arg as? Number)?.toLong() ?: 0L
+ // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
+ val masked = if (arg is Int) value and INT_MASK else value
+ var hex = masked.toString(HEX_RADIX)
+ if (conversion == 'X') hex = hex.uppercase()
+ val padChar = if (zeroPad) '0' else ' '
+ val padWidth = width ?: 0
+ append(hex.padStart(padWidth, padChar))
+ }
+ else -> {
+ // Unknown conversion — reproduce original token
+ append('%')
+ if (explicitIndex != null) append("${explicitIndex + 1}$")
+ if (zeroPad) append('0')
+ if (width != null) append(width)
+ if (precision != null) append(".$precision")
+ append(conversion)
+ }
+ }
+ }
+}
+
+private const val DEFAULT_FLOAT_PRECISION = 6
+private const val HEX_RADIX = 16
+private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index e3612dfda..1abb8807c 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
- fun optimizeUtf8StringWithHomoglyphs(value: String): String {
- val stringBuilder = StringBuilder()
- for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
- return stringBuilder.toString()
+ fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
+ for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
new file mode 100644
index 000000000..8e57b4dbb
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
@@ -0,0 +1,53 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.common.util
+
+/**
+ * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
+ * NodeItem, and metric screens.
+ *
+ * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
+ * for a mesh networking app where consistency matters.
+ */
+object MetricFormatter {
+
+ fun temperature(celsius: Float, isFahrenheit: Boolean): String {
+ val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
+ val unit = if (isFahrenheit) "°F" else "°C"
+ return "${NumberFormatter.format(value, 1)}$unit"
+ }
+
+ fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
+
+ fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
+
+ fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
+
+ fun percent(value: Int): String = "$value%"
+
+ fun humidity(value: Float): String = percent(value, 0)
+
+ fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
+
+ fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
+
+ fun rssi(value: Int): String = "$value dBm"
+}
+
+private const val FAHRENHEIT_SCALE = 1.8f
+private const val FAHRENHEIT_OFFSET = 32
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
new file mode 100644
index 000000000..040861b8d
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AddressUtilsTest {
+
+ @Test
+ fun nullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(null))
+ }
+
+ @Test
+ fun blankReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(""))
+ assertEquals("DEFAULT", normalizeAddress(" "))
+ }
+
+ @Test
+ fun sentinelNReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("N"))
+ assertEquals("DEFAULT", normalizeAddress("n"))
+ }
+
+ @Test
+ fun sentinelNullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("NULL"))
+ assertEquals("DEFAULT", normalizeAddress("null"))
+ assertEquals("DEFAULT", normalizeAddress("Null"))
+ }
+
+ @Test
+ fun stripsColons() {
+ assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
+ }
+
+ @Test
+ fun uppercases() {
+ assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
+ }
+
+ @Test
+ fun trimsWhitespace() {
+ assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
+ }
+
+ @Test
+ fun alreadyNormalizedPassesThrough() {
+ assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
+ }
+
+ @Test
+ fun mixedCaseWithColons() {
+ assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
+ }
+}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
similarity index 65%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
index 7ca9f9fe8..899938ba4 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
@@ -19,11 +19,25 @@ package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
-class MeshtasticUriTest {
+class CommonUriTest {
@Test
fun testParseAndToString() {
val uriString = "content://com.example.provider/file.txt"
- val uri = MeshtasticUri.parse(uriString)
+ val uri = CommonUri.parse(uriString)
assertEquals(uriString, uri.toString())
}
+
+ @Test
+ fun testQueryParameters() {
+ val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
+ assertEquals("meshtastic.org", uri.host)
+ assertEquals("key=value&complete=true", uri.fragment)
+ }
+
+ @Test
+ fun testFileUri() {
+ val uri = CommonUri.parse("file:///tmp/export.csv")
+ assertEquals("file", uri.scheme)
+ assertEquals("/tmp/export.csv", uri.path)
+ }
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
index 94b81f0fb..de2d20e9e 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
@@ -93,4 +93,48 @@ class FormatStringTest {
fun sequentialFloatSubstitution() {
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
}
+
+ // Hex format tests
+
+ @Test
+ fun lowercaseHex() {
+ assertEquals("ff", formatString("%x", 255))
+ }
+
+ @Test
+ fun uppercaseHex() {
+ assertEquals("FF", formatString("%X", 255))
+ }
+
+ @Test
+ fun zeroPaddedHex() {
+ assertEquals("000000ff", formatString("%08x", 255))
+ }
+
+ @Test
+ fun zeroPaddedHexNodeId() {
+ assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt()))
+ }
+
+ @Test
+ fun hexZeroValue() {
+ assertEquals("00000000", formatString("%08x", 0))
+ }
+
+ @Test
+ fun positionalHex() {
+ assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42))
+ }
+
+ // Edge case tests
+
+ @Test
+ fun trailingPercent() {
+ assertEquals("hello", formatString("hello%"))
+ }
+
+ @Test
+ fun outOfBoundsArgIndex() {
+ assertEquals("null", formatString("%3\$s", "only_one"))
+ }
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
new file mode 100644
index 000000000..b602a4a62
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -0,0 +1,123 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MetricFormatterTest {
+
+ @Test
+ fun temperatureCelsius() {
+ assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
+ }
+
+ @Test
+ fun temperatureFahrenheit() {
+ assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureNegative() {
+ assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
+ }
+
+ @Test
+ fun voltage() {
+ assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
+ }
+
+ @Test
+ fun voltageOneDecimal() {
+ assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
+ }
+
+ @Test
+ fun current() {
+ assertEquals("150.3 mA", MetricFormatter.current(150.3f))
+ }
+
+ @Test
+ fun percentFloat() {
+ assertEquals("85.5%", MetricFormatter.percent(85.5f))
+ }
+
+ @Test
+ fun percentInt() {
+ assertEquals("85%", MetricFormatter.percent(85))
+ }
+
+ @Test
+ fun humidity() {
+ assertEquals("65%", MetricFormatter.humidity(65.4f))
+ }
+
+ @Test
+ fun pressure() {
+ assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
+ }
+
+ @Test
+ fun snr() {
+ assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
+ }
+
+ @Test
+ fun rssi() {
+ assertEquals("-90 dBm", MetricFormatter.rssi(-90))
+ }
+
+ @Test
+ fun temperatureFreezingFahrenheit() {
+ assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureBoilingFahrenheit() {
+ assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun voltageZero() {
+ assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
+ }
+
+ @Test
+ fun currentZero() {
+ assertEquals("0.0 mA", MetricFormatter.current(0.0f))
+ }
+
+ @Test
+ fun percentZero() {
+ assertEquals("0%", MetricFormatter.percent(0))
+ }
+
+ @Test
+ fun percentHundred() {
+ assertEquals("100%", MetricFormatter.percent(100))
+ }
+
+ @Test
+ fun rssiZero() {
+ assertEquals("0 dBm", MetricFormatter.rssi(0))
+ }
+
+ @Test
+ fun snrNegative() {
+ assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
+ }
+}
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
deleted file mode 100644
index c2e95a5b0..000000000
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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 .
- */
-package org.meshtastic.core.common.util
-
-/**
- * Apple (iOS) implementation of string formatting.
- *
- * Implements a subset of Java's `String.format()` patterns used in this codebase:
- * - `%s`, `%d` — positional or sequential string/integer
- * - `%N$s`, `%N$d` — explicit positional string/integer
- * - `%N$.Nf`, `%.Nf` — float with decimal precision
- * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
- * - `%%` — literal percent
- *
- * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
- */
-actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
- var i = 0
- var autoIndex = 0
- while (i < pattern.length) {
- if (pattern[i] != '%') {
- append(pattern[i])
- i++
- continue
- }
- i++ // skip '%'
- if (i >= pattern.length) break
-
- // Literal %%
- if (pattern[i] == '%') {
- append('%')
- i++
- continue
- }
-
- // Parse optional positional index (N$)
- var explicitIndex: Int? = null
- val startPos = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i < pattern.length && pattern[i] == '$' && i > startPos) {
- explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
- i++ // skip '$'
- } else {
- i = startPos // rewind — digits are part of width/precision, not positional index
- }
-
- // Parse optional flags (zero-pad)
- var zeroPad = false
- if (i < pattern.length && pattern[i] == '0') {
- zeroPad = true
- i++
- }
-
- // Parse optional width
- var width: Int? = null
- val widthStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > widthStart) {
- width = pattern.substring(widthStart, i).toInt()
- }
-
- // Parse optional precision (.N)
- var precision: Int? = null
- if (i < pattern.length && pattern[i] == '.') {
- i++ // skip '.'
- val precStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > precStart) {
- precision = pattern.substring(precStart, i).toInt()
- }
- }
-
- // Parse conversion character
- if (i >= pattern.length) break
- val conversion = pattern[i]
- i++
-
- val argIndex = explicitIndex ?: autoIndex++
- val arg = args.getOrNull(argIndex)
-
- when (conversion) {
- 's' -> append(arg?.toString() ?: "null")
- 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
- 'f' -> {
- val value = (arg as? Number)?.toDouble() ?: 0.0
- val places = precision ?: DEFAULT_FLOAT_PRECISION
- append(NumberFormatter.format(value, places))
- }
- 'x',
- 'X',
- -> {
- val value = (arg as? Number)?.toLong() ?: 0L
- // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
- val masked = if (arg is Int) value and INT_MASK else value
- var hex = masked.toString(HEX_RADIX)
- if (conversion == 'X') hex = hex.uppercase()
- val padChar = if (zeroPad) '0' else ' '
- val padWidth = width ?: 0
- append(hex.padStart(padWidth, padChar))
- }
- else -> {
- // Unknown conversion — reproduce original token
- append('%')
- if (explicitIndex != null) append("${explicitIndex + 1}$")
- if (zeroPad) append('0')
- if (width != null) append(width)
- if (precision != null) append(".$precision")
- append(conversion)
- }
- }
- }
-}
-
-private const val DEFAULT_FLOAT_PRECISION = 6
-private const val HEX_RADIX = 16
-private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
index 35e2906ff..7556105b3 100644
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
@@ -22,20 +22,6 @@ actual object BuildUtils {
actual val sdkInt: Int = 0
}
-actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) {
- actual fun getQueryParameter(key: String): String? = null
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
-
- actual override fun toString(): String = ""
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
- }
-}
-
-actual fun CommonUri.toPlatformUri(): Any = Any()
-
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""
diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
deleted file mode 100644
index a450b9856..000000000
--- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * 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 .
- */
-package org.meshtastic.core.common.util
-
-/** JVM/Android implementation of string formatting. */
-actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
deleted file mode 100644
index c10c015bc..000000000
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * 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.common.util
-
-import java.net.URI
-
-actual class CommonUri(private val uri: URI) {
- private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) }
-
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
-
- actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
- val value = getQueryParameter(key) ?: return defaultValue
- return value != "false" && value != "0"
- }
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
- }
-
- fun toUri(): URI = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
index 4b8abdbd3..43ead91a2 100644
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
@@ -17,9 +17,6 @@
package org.meshtastic.core.common.util
import java.net.InetAddress
-import java.net.URLDecoder
-import java.nio.charset.StandardCharsets
-import java.text.DateFormat
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -76,7 +73,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
- DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
+ shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
}
@Suppress("MagicNumber")
@@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean {
}
}
-internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery
- ?.split('&')
- ?.filter { it.isNotBlank() }
- ?.groupBy(
- keySelector = { segment ->
- val key = segment.substringBefore('=', missingDelimiterValue = segment)
- URLDecoder.decode(key, StandardCharsets.UTF_8.name())
- },
- valueTransform = { segment ->
- val value = segment.substringAfter('=', missingDelimiterValue = "")
- URLDecoder.decode(value, StandardCharsets.UTF_8.name())
- },
- )
- .orEmpty()
-
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?.
- */
-package org.meshtastic.core.common.util
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class CommonUriTest {
-
- @Test
- fun testParse() {
- val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment")
- assertEquals("meshtastic.org", uri.host)
- assertEquals("fragment", uri.fragment)
- assertEquals(listOf("path", "to", "page"), uri.pathSegments)
- assertEquals("value1", uri.getQueryParameter("param1"))
- assertTrue(uri.getBooleanQueryParameter("param2", false))
- }
-
- @Test
- fun testBooleanParameters() {
- val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
- assertTrue(uri.getBooleanQueryParameter("t1", false))
- assertTrue(uri.getBooleanQueryParameter("t2", false))
- assertTrue(uri.getBooleanQueryParameter("t3", false))
- assertTrue(!uri.getBooleanQueryParameter("f1", true))
- assertTrue(!uri.getBooleanQueryParameter("f2", true))
- }
-}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index b0b9e8c5f..628528391 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
@@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
"lastRequest=$lastRequest window=$window max=$max",
)
- runCatching {
+ safeCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index 5fd34e02e..975b2f5e8 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
@@ -93,7 +94,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
- runCatching {
+ safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index 31e4f331d..94b405953 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -289,7 +289,7 @@ class MeshConnectionManagerImpl(
override fun onRadioConfigLoaded() {
scope.handledLaunch {
- val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
+ val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 000d0b41d..7a6ec3320 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -96,7 +96,7 @@ class MeshMessageProcessorImpl(
}
.onFailure { _ ->
Logger.e(primaryException) {
- "Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
+ "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
}
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 338a0d6ea..fdcc6d344 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
- runCatching {
+ safeCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List,
- ): Result = runCatching {
+ ): Result = safeCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index a47a5381f..8f3154815 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
- runCatching {
+ safeCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
- runCatching {
+ safeCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index f6a49f190..04e09eaf7 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -108,7 +108,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dao.upsertContactSettings(listOf(updated))
}
- override suspend fun getQueuedPackets(): List? =
+ override suspend fun getQueuedPackets(): List =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
new file mode 100644
index 000000000..c26991ac4
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
@@ -0,0 +1,1052 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 38,
+ "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
index c917ee066..b2c89ad73 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.database
import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.common.util.normalizeAddress
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
@@ -40,17 +41,6 @@ object DatabaseConstants {
const val ADDRESS_ANON_EDGE_LEN: Int = 2
}
-fun normalizeAddress(addr: String?): String {
- val u = addr?.trim()?.uppercase()
- val normalized =
- when {
- u.isNullOrBlank() -> "DEFAULT"
- u == "N" || u == "NULL" -> "DEFAULT"
- else -> u.replace(":", "")
- }
- return normalized
-}
-
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index ba5887f95..108345265 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -241,6 +241,7 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
@@ -266,6 +267,7 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 7bf9014ce..13451e5fc 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
+ AutoMigration(from = 37, to = 38),
],
- version = 37,
+ version = 38,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
index 967a97ec5..35d29c161 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
@@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
@Dao
interface MeshLogDao {
- @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem")
fun getAllLogs(maxItem: Int): Flow>
- @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow>
/**
@@ -40,7 +40,7 @@ interface MeshLogDao {
"""
SELECT * FROM log
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
- ORDER BY received_date DESC LIMIT 0,:maxItem
+ ORDER BY received_date DESC LIMIT :maxItem
""",
)
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow>
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index eb3c27b7e..407a4d853 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -35,6 +35,9 @@ interface NodeInfoDao {
companion object {
const val KEY_SIZE = 32
+
+ /** SQLite has a limit of ~999 bind parameters per query. */
+ const val MAX_BIND_PARAMS = 999
}
/**
@@ -281,9 +284,15 @@ interface NodeInfoDao {
@Transaction
suspend fun getNodeByNum(num: Int): NodeWithRelations?
+ @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)")
+ suspend fun getNodeEntitiesByNums(nodeNums: List): List
+
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
+ @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)")
+ suspend fun findNodesByPublicKeys(publicKeys: List): List
+
@Upsert suspend fun doUpsert(node: NodeEntity)
@Transaction
@@ -297,11 +306,77 @@ interface NodeInfoDao {
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)
+ /**
+ * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
+ * queries instead of N individual queries, then processes each node in memory.
+ */
+ @Suppress("NestedBlockDepth")
+ private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List {
+ // Prepare all incoming nodes (populate denormalized fields)
+ incomingNodes.forEach { node ->
+ node.publicKey = node.user.public_key
+ if (node.user.hw_model != HardwareModel.UNSET) {
+ node.longName = node.user.long_name
+ node.shortName = node.user.short_name
+ } else {
+ node.longName = null
+ node.shortName = null
+ }
+ }
+
+ // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit)
+ val existingNodesMap =
+ incomingNodes
+ .map { it.num }
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { getNodeEntitiesByNums(it) }
+ .associateBy { it.num }
+
+ // Partition into updates vs. inserts and resolve existing nodes in-memory
+ val result = mutableListOf()
+ val newNodes = mutableListOf()
+ for (incoming in incomingNodes) {
+ val existing = existingNodesMap[incoming.num]
+ if (existing != null) {
+ result.add(handleExistingNodeUpsertValidation(existing, incoming))
+ } else {
+ newNodes.add(incoming)
+ }
+ }
+
+ // Batch validate new nodes' public keys (one query instead of N)
+ val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
+ val pkConflicts =
+ if (publicKeysToCheck.isNotEmpty()) {
+ publicKeysToCheck
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { findNodesByPublicKeys(it) }
+ .associateBy { it.publicKey }
+ } else {
+ emptyMap()
+ }
+
+ for (newNode in newNodes) {
+ if ((newNode.publicKey?.size ?: 0) > 0) {
+ val conflicting = pkConflicts[newNode.publicKey]
+ if (conflicting != null && conflicting.num != newNode.num) {
+ result.add(conflicting)
+ } else {
+ result.add(newNode)
+ }
+ } else {
+ result.add(newNode)
+ }
+ }
+
+ return result
+ }
+
@Transaction
suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
clearMyNodeInfo()
setMyNodeInfo(mi)
- putAll(nodes.map { getVerifiedNodeForUpsert(it) })
+ putAll(getVerifiedNodesForUpsert(nodes))
}
/**
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 1419d51e7..71017799c 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room3.Dao
+import androidx.room3.Insert
import androidx.room3.MapColumn
+import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
@@ -326,8 +328,15 @@ interface PacketDao {
)
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
- @Transaction
- suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
+ @Query(
+ """
+ SELECT data FROM packet
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND json_extract(data, '${"$"}.status') = 'QUEUED'
+ ORDER BY received_time ASC
+ """,
+ )
+ suspend fun getQueuedPackets(): List
@Query(
"""
@@ -359,23 +368,24 @@ interface PacketDao {
@Upsert suspend fun upsertContactSettings(contacts: List)
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertContactSettingsIgnore(contacts: List)
+
+ @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
+ suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long)
+
@Transaction
suspend fun setMuteUntil(contacts: List, until: Long) {
- val contactList = contacts.map { contact ->
- // Always mute
- val absoluteMuteUntil =
- if (until == Long.MAX_VALUE) {
- Long.MAX_VALUE
- } else if (until == 0L) { // unmute
- 0L
- } else {
- nowMillis + until
- }
-
- getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)
- ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil)
- }
- upsertContactSettings(contactList)
+ val absoluteMuteUntil =
+ when {
+ until == Long.MAX_VALUE -> Long.MAX_VALUE
+ until == 0L -> 0L
+ else -> nowMillis + until
+ }
+ // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data)
+ insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) })
+ // Atomic column-level update — no read-then-write race
+ updateMuteUntil(contacts, absoluteMuteUntil)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@@ -479,9 +489,10 @@ interface PacketDao {
val indexMap =
oldSettings
.mapIndexed { oldIndex, oldChannel ->
- val pskMatches = newSettings.mapIndexedNotNull { index, channel ->
- if (channel.psk == oldChannel.psk) index to channel else null
- }
+ val pskMatches =
+ newSettings.mapIndexedNotNull { index, channel ->
+ if (channel.psk == oldChannel.psk) index to channel else null
+ }
val newIndex =
when {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
index 13d10193c..fed88eef9 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
@@ -118,6 +118,7 @@ data class MetadataEntity(
Index(value = ["hops_away"]),
Index(value = ["is_favorite"]),
Index(value = ["last_heard", "is_favorite"]),
+ Index(value = ["public_key"]),
],
)
data class NodeEntity(
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 16b1e66e4..d01171751 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -74,6 +74,9 @@ data class PacketEntity(
Index(value = ["contact_key"]),
Index(value = ["contact_key", "port_num", "received_time"]),
Index(value = ["packet_id"]),
+ Index(value = ["received_time"]),
+ Index(value = ["filtered"]),
+ Index(value = ["read"]),
],
)
data class Packet(
@@ -98,9 +101,12 @@ data class Packet(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
deleted file mode 100644
index 473e482e2..000000000
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * 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.model.util
-
-import org.meshtastic.core.common.util.nowInstant
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import java.text.DateFormat
-import kotlin.time.Duration.Companion.hours
-
-private val DAY_DURATION = 24.hours
-
-/**
- * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
- * representing the date.
- *
- * @param time The time in milliseconds
- * @return Formatted date or time string, or null if time is 0
- */
-fun getShortDate(time: Long): String? {
- if (time == 0L) return null
- val instant = time.toInstant()
- val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
-
- return if (isWithin24Hours) {
- DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
- } else {
- DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
- }
-}
-
-/**
- * Calculates the remaining mute time in days and hours.
- *
- * @param remainingMillis The remaining time in milliseconds
- * @return Pair of (days, hours), where days is Int and hours is Double
- */
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
index 13b0789de..99debb5ab 100644
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
@@ -17,12 +17,13 @@
package org.meshtastic.core.model.util
import android.net.Uri
+import com.eygraber.uri.toKmpUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
-fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
+fun Uri.toCommonUri(): CommonUri = this.toKmpUri()
/** Bridge extension for Android clients. */
fun Uri.dispatchMeshtasticUri(
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 13eccae2a..70dea8574 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -19,10 +19,9 @@ package org.meshtastic.core.model
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.bearing
-import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
-import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
@@ -143,34 +142,26 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List {
val temp =
if ((temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
- } else {
- formatString("%.1f°C", temperature)
- }
+ MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
} else {
null
}
- val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
+ val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
- } else {
- formatString("%.1f°C", soil_temperature)
- }
+ MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
- formatString("%d%%", soil_moisture)
+ MetricFormatter.percent(soil_moisture ?: 0)
} else {
null
}
- val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
- val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
+ val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null
+ val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
@@ -199,9 +190,12 @@ data class Node(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 6f27bb0e6..47d812f68 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -32,7 +32,7 @@ val Any?.anonymize: String
get() = this.anonymize()
/** A version of anonymize that allows passing in a custom minimum length */
-fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
+fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null"
// A toString that makes sure all newlines are removed (for nice logging).
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
index ca035a7fd..ebdcc0f5e 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
@@ -16,7 +16,27 @@
*/
package org.meshtastic.core.model.util
+import okio.ByteString.Companion.toByteString
+
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
-expect object SfppHasher {
- fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
+object SfppHasher {
+ private const val HASH_SIZE = 16
+ private const val INT_BYTES = 4
+ private const val INT_COUNT = 3
+ private const val SHIFT_8 = 8
+ private const val SHIFT_16 = 16
+ private const val SHIFT_24 = 24
+
+ fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
+ val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
+ encryptedPayload.copyInto(input)
+ var offset = encryptedPayload.size
+ for (value in intArrayOf(to, from, id)) {
+ input[offset++] = value.toByte()
+ input[offset++] = (value shr SHIFT_8).toByte()
+ input[offset++] = (value shr SHIFT_16).toByte()
+ input[offset++] = (value shr SHIFT_24).toByte()
+ }
+ return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index b2e175382..4b3f5d149 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
return if (changes.isEmpty()) {
"No changes detected."
} else {
- "Changes:\n" + changes.joinToString("\n")
+ "Changes:\n${changes.joinToString("\n")}"
}
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
similarity index 95%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
index 51f6a5c76..14dfd72c8 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
@@ -14,12 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.common
+package org.meshtastic.core.model.util
import kotlin.test.Test
import kotlin.test.assertEquals
-class ByteUtilsTest {
+class CommonUtilsTest {
@Test
fun testByteArrayOfInts() {
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
new file mode 100644
index 000000000..917414e3d
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
@@ -0,0 +1,87 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.model.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class SfppHasherTest {
+
+ @Test
+ fun outputIsAlways16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun emptyPayloadProduces16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun deterministicOutput() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ assertEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentPayloadsProduceDifferentHashes() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentIdsProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentFromProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun maxIntValues() {
+ val hash =
+ SfppHasher.computeMessageHash(
+ byteArrayOf(0xFF.toByte()),
+ to = Int.MAX_VALUE,
+ from = Int.MAX_VALUE,
+ id = Int.MAX_VALUE,
+ )
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun littleEndianByteOrder() {
+ // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian)
+ val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0)
+ val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0)
+ // Different byte orderings must produce different hashes
+ assertNotEquals(hashA.toList(), hashB.toList())
+ }
+}
diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
index 7545a00a7..d17abd4a3 100644
--- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
+++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
@@ -20,7 +20,3 @@ package org.meshtastic.core.model.util
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
-
-actual object SfppHasher {
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
-}
diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
deleted file mode 100644
index b1c25110b..000000000
--- a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * 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.model.util
-
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import java.security.MessageDigest
-
-actual object SfppHasher {
- private const val HASH_SIZE = 16
- private const val INT_BYTES = 4
-
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- digest.update(encryptedPayload)
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
- return digest.digest().copyOf(HASH_SIZE)
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
index db558bedb..87c317024 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -28,4 +28,7 @@ object HttpClientDefaults {
/** Maximum number of automatic retries on server errors (5xx). */
const val MAX_RETRIES = 3
+
+ /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */
+ const val API_BASE_URL = "https://api.meshtastic.org/"
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
index 78d3d4ceb..b14c1bfe4 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
@@ -326,8 +326,8 @@ class MockRadioTransport(
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
- long_name = "Sim " + numIn.toString(16),
- short_name = getInitials("Sim " + numIn.toString(16)),
+ long_name = "Sim ${numIn.toString(16)}",
+ short_name = getInitials("Sim ${numIn.toString(16)}"),
hw_model = HardwareModel.ANDROID_SIM,
),
position =
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
index ed7461058..6c15478d9 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
@@ -35,14 +35,14 @@ interface ApiService {
/**
* Ktor-based [ApiService] implementation.
*
+ * Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules.
+ *
* Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`)
* provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines.
*/
@Single(binds = [])
class ApiServiceImpl(private val client: HttpClient) : ApiService {
- override suspend fun getDeviceHardware(): List =
- client.get("https://api.meshtastic.org/resource/deviceHardware").body()
+ override suspend fun getDeviceHardware(): List = client.get("resource/deviceHardware").body()
- override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
- client.get("https://api.meshtastic.org/github/firmware/list").body()
+ override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
}
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
index 1b46232bf..34b9e49a3 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
@@ -17,12 +17,12 @@
package org.meshtastic.core.network.repository
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
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.di.CoroutineDispatchers
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
@@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
@Single
-class JvmServiceDiscovery : ServiceDiscovery {
+class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow> =
callbackFlow {
@@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery {
}
}
}
- .flowOn(Dispatchers.IO)
+ .flowOn(dispatchers.io)
companion object {
/**
diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
index e03076f39..5884daaaf 100644
--- a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
+++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
@@ -17,16 +17,23 @@
package org.meshtastic.core.network.repository
import app.cash.turbine.test
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.di.CoroutineDispatchers
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class JvmServiceDiscoveryTest {
+ private val testDispatchers =
+ UnconfinedTestDispatcher().let { dispatcher ->
+ CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ }
+
@Test
fun `resolvedServices emits initial empty list immediately`() = runTest {
- val discovery = JvmServiceDiscovery()
+ val discovery = JvmServiceDiscovery(testDispatchers)
discovery.resolvedServices.test {
val first = awaitItem()
assertNotNull(first, "First emission should not be null")
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
index ad982e6a6..2292ea3ab 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.normalizeAddress
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.cachedFlow
import org.meshtastic.core.repository.MeshPrefs
@@ -95,15 +96,6 @@ class MeshPrefsImpl(
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
- private fun normalizeAddress(address: String?): String {
- val raw = address?.trim()?.takeIf { it.isNotEmpty() }
- return when {
- raw == null -> "DEFAULT"
- raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
- else -> raw.uppercase().replace(":", "")
- }
- }
-
companion object {
val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
index dca2a6bf3..9f7cbe0dd 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.repository
import okio.BufferedSink
import okio.BufferedSource
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
/**
* Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain
@@ -29,11 +29,11 @@ interface FileService {
* Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
- suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean
+ suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean
/**
* Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
- suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean
+ suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index a0977c582..6bd33a4cf 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -71,7 +71,7 @@ interface PacketRepository {
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long)
/** Returns all packets currently queued for transmission. */
- suspend fun getQueuedPackets(): List?
+ suspend fun getQueuedPackets(): List
/**
* Persists a packet in the database.
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 77c923d94..a958ce1ee 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -384,9 +384,9 @@
Battery
ChUtil
AirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s
%1$s: %2$s
Temp
Hum
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
index 91eb97484..8b939fa9b 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
@@ -16,9 +16,11 @@
*/
package org.meshtastic.core.service
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@@ -27,10 +29,15 @@ import kotlin.test.assertNotNull
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidFileServiceTest {
+ private val testDispatchers =
+ UnconfinedTestDispatcher().let { dispatcher ->
+ CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ }
+
@Test
fun testInitialization() = runTest {
val context = RuntimeEnvironment.getApplication()
- val service = AndroidFileService(context)
+ val service = AndroidFileService(context, testDispatchers)
assertNotNull(service)
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
index 010fcdc89..8924cdcc8 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.service
import android.app.Application
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
+import com.eygraber.uri.toAndroidUri
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@@ -26,15 +26,16 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.MeshtasticUri
-import org.meshtastic.core.common.util.toAndroidUri
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import java.io.FileOutputStream
@Single
-class AndroidFileService(private val context: Application) : FileService {
- override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) :
+ FileService {
+ override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt")
if (pfd == null) {
@@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService {
}
}
- override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+ override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
val success =
context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream ->
diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
index 8f8e08d45..5b3d6df0d 100644
--- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
+++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
@@ -17,7 +17,6 @@
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@@ -25,17 +24,18 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import java.io.File
@Single
-class JvmFileService : FileService {
- override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService {
+ override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
- // Treat uriString as a local file path
- val file = File(uri.uriString)
+ // Treat URI string as a local file path
+ val file = File(uri.toString())
file.parentFile?.mkdirs()
file.sink().buffer().use { sink -> block(sink) }
true
@@ -45,10 +45,10 @@ class JvmFileService : FileService {
}
}
- override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+ override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
- val file = File(uri.uriString)
+ val file = File(uri.toString())
file.source().buffer().use { source -> block(source) }
true
} catch (e: Exception) {
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
index cd616417d..732d03064 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
@@ -20,47 +20,41 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
-fun CoTMessage.toXml(): String {
- val sb = StringBuilder()
- sb.append(
+fun CoTMessage.toXml(): String = buildString {
+ append(
"",
)
contact?.let {
- sb.append(
+ append(
"",
)
}
- group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
+ group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
- status?.let { sb.append("") }
+ status?.let { append("") }
- track?.let { sb.append("") }
+ track?.let { append("") }
if (chat != null) {
val senderUid = uid.geoChatSenderUid()
val messageId = uid.geoChatMessageId()
- sb.append(
+ append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>",
)
- sb.append("")
- sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
- sb.append(
+ append("")
+ append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
+ append(
"${chat.message.xmlEscaped()}",
)
} else if (!remarks.isNullOrEmpty()) {
- sb.append("${remarks.xmlEscaped()}")
+ append("${remarks.xmlEscaped()}")
}
- rawDetailXml?.let {
- if (it.isNotEmpty()) {
- sb.append(it)
- }
- }
+ rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
- sb.append("")
- return sb.toString()
+ append("")
}
private fun Instant.toXmlString(): String = this.toString()
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
index 65d7077f9..48c635560 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
@@ -16,12 +16,16 @@
*/
package org.meshtastic.core.takserver.fountain
+import okio.ByteString.Companion.toByteString
+
internal expect object ZlibCodec {
fun compress(data: ByteArray): ByteArray?
fun decompress(data: ByteArray): ByteArray?
}
-internal expect object CryptoCodec {
- fun sha256Prefix8(data: ByteArray): ByteArray
+internal object CryptoCodec {
+ private const val PREFIX_SIZE = 8
+
+ fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
}
diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
similarity index 83%
rename from core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
index 4473fc521..b0e4f1030 100644
--- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
@@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
-import platform.CoreCrypto.CC_SHA256
-import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.zlib.Z_BUF_ERROR
import platform.zlib.Z_OK
import platform.zlib.compress
@@ -105,20 +103,3 @@ internal actual object ZlibCodec {
return null
}
}
-
-internal actual object CryptoCodec {
- @OptIn(ExperimentalForeignApi::class)
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
- if (data.isNotEmpty()) {
- data.usePinned { dataPin ->
- digest.usePinned { digestPin ->
- CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
- }
- }
- } else {
- digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
- }
- return digest.copyOf(8)
- }
-}
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
similarity index 90%
rename from core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
rename to core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
index 9db28ac66..fca9f0f52 100644
--- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
@@ -17,7 +17,6 @@
package org.meshtastic.core.takserver.fountain
import java.io.ByteArrayOutputStream
-import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
@@ -66,10 +65,3 @@ internal actual object ZlibCodec {
}
}
}
-
-internal actual object CryptoCodec {
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- return digest.digest(data).copyOf(8)
- }
-}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index bebed2f46..231c84d40 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -20,7 +20,6 @@ package org.meshtastic.core.ui.util
import android.content.ActivityNotFoundException
import android.content.Intent
-import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -36,13 +35,14 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
+import com.eygraber.uri.toAndroidUri
+import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
-import org.meshtastic.core.common.util.MeshtasticUri
import java.net.URLEncoder
@Composable
@@ -107,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit {
@Composable
@Suppress("Wrapping")
actual fun rememberSaveFileLauncher(
- onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
+ onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit {
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
- result.data?.data?.let { uri ->
- onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) })
- }
+ result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) }
}
}
@@ -137,7 +135,7 @@ actual fun rememberSaveFileLauncher(
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
- onUriReceived(uri?.let { CommonUri(it) })
+ onUriReceived(uri?.let { it.toKmpUri() })
}
return remember(launcher) { { mimeType -> launcher.launch(mimeType) } }
}
@@ -151,7 +149,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
withContext(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
- val androidUri = Uri.parse(uri.toString())
+ val androidUri = uri.toAndroidUri()
context.contentResolver.openInputStream(androidUri)?.use { stream ->
stream.bufferedReader().use { reader ->
val buffer = CharArray(maxChars)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index 9d41d5f5a..22c6bfaf5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -62,12 +62,13 @@ fun > DropDownPreference(
enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() }
}
- val items = enumConstants.map {
- val label = itemLabel?.invoke(it) ?: it.name
- val icon = itemIcon?.invoke(it)
- val color = itemColor?.invoke(it)
- DropDownItem(it, label, icon, color)
- }
+ val items =
+ enumConstants.map {
+ val label = itemLabel?.invoke(it) ?: it.name
+ val icon = itemIcon?.invoke(it)
+ val color = itemColor?.invoke(it)
+ DropDownItem(it, label, icon, color)
+ }
DropDownPreference(
title = title,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt
index 2dce97aa5..10b83ce41 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt
@@ -23,7 +23,7 @@ import androidx.compose.material3.IconToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
@@ -49,7 +49,7 @@ fun EditPasswordPreference(
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
- var isPasswordVisible by remember { mutableStateOf(false) }
+ var isPasswordVisible by rememberSaveable { mutableStateOf(false) }
EditTextPreference(
title = title,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
index c461a065f..d8df4101b 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -90,10 +91,10 @@ fun MeshtasticImportFAB(
) {
sharedContact?.let { importDialog(it, onDismissSharedContact) }
- var expanded by remember { mutableStateOf(false) }
- var showUrlDialog by remember { mutableStateOf(false) }
- var isNfcScanning by remember { mutableStateOf(false) }
- var showNfcDisabledDialog by remember { mutableStateOf(false) }
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ var showUrlDialog by rememberSaveable { mutableStateOf(false) }
+ var isNfcScanning by rememberSaveable { mutableStateOf(false) }
+ var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) }
val openNfcSettings = rememberOpenNfcSettings()
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index 216ec2108..753468600 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
- text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr),
+ text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}",
color = color,
style = MaterialTheme.typography.labelSmall,
)
@@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
}
Text(
modifier = modifier,
- text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi),
+ text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}",
color = color,
style = MaterialTheme.typography.labelSmall,
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
index 7e8bd9b6a..1445bdedf 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
@@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.icon.BatteryEmpty
@@ -49,7 +49,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
-private const val FORMAT = "%d%%"
private const val SIZE_ICON = 16
@Suppress("MagicNumber", "LongMethod")
@@ -60,7 +59,7 @@ fun MaterialBatteryInfo(
voltage: Float? = null,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
- val levelString = formatString(FORMAT, level)
+ val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown)
Row(
modifier = modifier,
@@ -130,7 +129,7 @@ fun MaterialBatteryInfo(
?.takeIf { it > 0 }
?.let {
Text(
- text = formatString("%.2fV", it),
+ text = MetricFormatter.voltage(it),
color = contentColor.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
index 5a6c58c23..f817ec4e4 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
@@ -65,7 +65,10 @@ fun SignalInfo(
tint = signalColor,
)
Text(
- text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)),
+ text =
+ "${MetricFormatter.snr(
+ node.snr,
+ )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
index b0e01011e..4a710b0b3 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
@@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -117,8 +118,8 @@ fun EmojiPickerDialog(
onConfirm: (String) -> Unit,
) {
val viewModel: EmojiPickerViewModel = koinViewModel()
- var searchQuery by remember { mutableStateOf("") }
- var selectedCategoryIndex by remember { mutableStateOf(0) }
+ var searchQuery by rememberSaveable { mutableStateOf("") }
+ var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) }
val recentEmojis by
remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } }
@@ -427,7 +428,7 @@ private fun SectionHeader(title: String) {
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) {
- var showSkinTonePopup by remember { mutableStateOf(false) }
+ var showSkinTonePopup by rememberSaveable { mutableStateOf(false) }
Box {
Box(
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
index d5f4e31ec..7e5271148 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
@@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -89,7 +90,7 @@ fun ScannedQrCodeDialog(
onDismiss: () -> Unit,
onConfirm: (ChannelSet) -> Unit,
) {
- var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) }
+ var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) }
val channelSet =
remember(shouldReplace, channels, incoming) {
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 38e870314..9d3169c1a 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
-import org.meshtastic.core.common.util.MeshtasticUri
/** Returns a function to open the platform's NFC settings. */
@Composable expect fun rememberOpenNfcSettings(): () -> Unit
@@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
@Composable
expect fun rememberSaveFileLauncher(
- onUriReceived: (MeshtasticUri) -> Unit,
+ onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index 12f1ea0f5..edfda074c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
-import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
@@ -99,18 +98,16 @@ class UIViewModel(
* 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
*/
- fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
- val commonUri = CommonUri.parse(uri.uriString)
-
+ fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) {
// Try navigation routing first
- val navKeys = DeepLinkRouter.route(commonUri)
+ val navKeys = DeepLinkRouter.route(uri)
if (navKeys != null) {
_navigationDeepLink.tryEmit(navKeys)
return
}
// Fallback to channel/contact importing
- commonUri.dispatchMeshtasticUri(
+ uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt
index b85e68888..905d50c2b 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt
@@ -21,6 +21,7 @@ package org.meshtastic.core.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@@ -37,7 +38,6 @@ import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.unknown_error
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
-import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
index 0621463bd..ebe791f8e 100644
--- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
-import org.meshtastic.core.common.util.MeshtasticUri
actual fun createClipEntry(text: String, label: String): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
@@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
@Composable
actual fun rememberSaveFileLauncher(
- onUriReceived: (MeshtasticUri) -> Unit,
+ onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
@Composable
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 08c414490..031e1fe35 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
-import org.meshtastic.core.common.util.MeshtasticUri
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
@@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
/** JVM — Opens a native file dialog to save a file. */
@Composable
actual fun rememberSaveFileLauncher(
- onUriReceived: (MeshtasticUri) -> Unit,
+ onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ ->
val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE)
dialog.file = defaultFilename
@@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher(
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
- onUriReceived(MeshtasticUri(path.toURI().toString()))
+ onUriReceived(CommonUri.parse(path.toURI().toString()))
}
}
@@ -83,7 +82,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
- onUriReceived(CommonUri(path.toURI()))
+ onUriReceived(CommonUri.parse(path.toURI().toString()))
}
}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 11111dd7a..80e049bce 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -51,6 +51,7 @@ import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
+import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
@@ -62,7 +63,7 @@ import org.jetbrains.compose.resources.decodeToSvgPainter
import org.koin.compose.koinInject
import org.koin.core.context.startKoin
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
@@ -130,7 +131,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U
arg.startsWith("http://meshtastic.org") ||
arg.startsWith("https://meshtastic.org")
) {
- uiViewModel.handleDeepLink(MeshtasticUri(arg)) {
+ uiViewModel.handleDeepLink(CommonUri.parse(arg)) {
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
}
}
@@ -141,7 +142,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
Desktop.getDesktop().setOpenURIHandler { event ->
val uriStr = event.uri.toString()
- uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
+ uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
}
}
}
@@ -304,7 +305,12 @@ private fun CoilImageLoaderSetup() {
val cacheDir = desktopDataDir() + "/image_cache_v3"
ImageLoader.Builder(context)
.components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ add(
+ KtorNetworkFetcherFactory(
+ httpClient = httpClient,
+ concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
+ ),
+ )
// Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
// that show up as solid/black hardware images.
add(SvgDecoder.Factory(renderToBitmap = true))
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 5b3b03f9d..8ac634112 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -14,18 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports
+@file:Suppress(
+ "ktlint:standard:no-unused-imports",
+) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule()
package org.meshtastic.desktop.di
// Generated Koin module extensions from core KMP modules
import io.ktor.client.HttpClient
import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.request.url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.dsl.module
@@ -183,6 +187,7 @@ private fun desktopPlatformStubsModule() = module {
single {
HttpClient(Java) {
install(ContentNegotiation) { json(get()) }
+ install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
install(HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
@@ -195,7 +200,7 @@ private fun desktopPlatformStubsModule() = module {
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger
- level = LogLevel.HEADERS
+ level = LogLevel.BODY
}
}
}
diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index bea19e8c3..1e6552437 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -1,6 +1,6 @@
# KMP Migration Status
-> Last updated: 2026-04-13
+> Last updated: 2026-04-15
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
@@ -79,7 +79,7 @@ Working Compose Desktop application with:
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
| CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated |
| DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
-| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils, desktop navigation graphs |
+| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils |
## Completion Estimates
@@ -109,12 +109,14 @@ Based on the latest codebase investigation, the following steps are proposed to
| Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target |
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints |
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
-| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
+| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) |
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. |
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
+| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` |
+| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) |
## Navigation Parity Note
diff --git a/docs/roadmap.md b/docs/roadmap.md
index d97995bb4..8cff42c1f 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap
-> Last updated: 2026-04-10
+> Last updated: 2026-04-15
Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md).
@@ -18,6 +18,8 @@ These items address structural gaps identified in the March 2026 architecture re
| Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ |
| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ |
| **iOS CI gate (compile-only validation)** | High | Medium | ✅ |
+| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ |
+| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ |
## Active Work
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt
index 57f06e225..8f5347e01 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt
@@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import org.jetbrains.compose.resources.stringResource
@@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo(
while (bleDevice.device.isConnected) {
try {
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
+ } catch (_: TimeoutCancellationException) {
+ Logger.d { "RSSI read timed out" }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
- // RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise
Logger.d(e) { "Failed to read RSSI ${e.message}" }
}
delay(RSSI_DELAY.seconds)
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
index 1647a5af7..3fa26d1cd 100644
--- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
+++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
@@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware
import android.content.Context
import co.touchlab.kermit.Logger
+import com.eygraber.uri.toAndroidUri
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.head
@@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
-import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
@@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
if (!tempDir.exists()) tempDir.mkdirs()
try {
- val platformUri = uri.toPlatformUri() as android.net.Uri
+ val platformUri = uri.toAndroidUri()
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
ZipInputStream(inputStream).use { zipInput ->
var entry = zipInput.nextEntry
@@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) {
file.toLocalFileOrNull()?.takeIf { it.exists() }?.length()
- ?: context.contentResolver
- .openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r")
- ?.use { descriptor -> descriptor.length.takeIf { it >= 0L } }
+ ?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor ->
+ descriptor.length.takeIf { it >= 0L }
+ }
?: 0L
}
@@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
if (localFile != null && localFile.exists()) {
localFile.readBytes()
} else {
- context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use {
- it.readBytes()
- } ?: throw IOException("Cannot open artifact: ${artifact.uri}")
+ context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() }
+ ?: throw IOException("Cannot open artifact: ${artifact.uri}")
}
}
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) {
- val inputStream =
- context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
- ?: return@withContext null
+ val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null
val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin")
tempFile.parentFile?.mkdirs()
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
@@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
withContext(ioDispatcher) {
val inputStream =
source.toLocalFileOrNull()?.inputStream()
- ?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri)
+ ?: context.contentResolver.openInputStream(source.uri.toAndroidUri())
?: throw IOException("Cannot open source URI")
val outputStream =
- context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
+ context.contentResolver.openOutputStream(destinationUri.toAndroidUri())
?: throw IOException("Cannot open content URI for writing")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
index eee6637af..1b5c0c803 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
@@ -163,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
uri?.let { viewModel.startUpdateFromFile(it) }
}
- val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri ->
- viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString))
- }
+ val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) }
val actions =
remember(viewModel, onNavigateUp) {
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index b82e26432..dc1c45971 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
@@ -123,9 +124,12 @@ class FirmwareUpdateViewModel(
override fun onCleared() {
super.onCleared()
- // viewModelScope is already cancelled when onCleared() runs, so use a standalone scope
- // for fire-and-forget cleanup of temporary firmware files.
- kotlinx.coroutines.CoroutineScope(NonCancellable).launch {
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
+ // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
+ // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
+ // is cancelled concurrently.
+ @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
+ kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
@@ -147,7 +151,7 @@ class FirmwareUpdateViewModel(
updateJob =
viewModelScope.launch {
_state.value = FirmwareUpdateState.Checking
- runCatching {
+ safeCatching {
val ourNode = nodeRepository.myNodeInfo.value
val address = radioPrefs.devAddr.value?.drop(1)
if (address == null || ourNode == null) {
@@ -200,7 +204,6 @@ class FirmwareUpdateViewModel(
}
}
.onFailure { e ->
- if (e is CancellationException) throw e
Logger.e(e) { "Error checking for updates" }
val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error)
_state.value =
@@ -390,7 +393,7 @@ private suspend fun cleanupTemporaryFiles(
fileHandler: FirmwareFileHandler,
tempFirmwareFile: FirmwareArtifact?,
): FirmwareArtifact? {
- runCatching {
+ safeCatching {
tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) }
fileHandler.cleanupAllTemporaryFiles()
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
index 3bdb0f1d7..8565b3dcc 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
+import org.meshtastic.core.common.util.safeCatching
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -78,7 +79,7 @@ class BleOtaTransport(
}
@Suppress("MagicNumber")
- override suspend fun connect(): Result = runCatching {
+ override suspend fun connect(): Result = safeCatching {
Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." }
delay(REBOOT_DELAY)
@@ -152,7 +153,7 @@ class BleOtaTransport(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
- ): Result = runCatching {
+ ): Result = safeCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
val packetsSent = sendCommand(command)
@@ -189,7 +190,7 @@ class BleOtaTransport(
data: ByteArray,
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
- ): Result = runCatching {
+ ): Result = safeCatching {
val totalBytes = data.size
var sentBytes = 0
@@ -215,7 +216,7 @@ class BleOtaTransport(
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
sentBytes = nextSentBytes
onProgress(1.0f)
- return@runCatching Unit
+ return@safeCatching Unit
}
}
is OtaResponse.Error -> {
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt
index 97fced4c6..fa9966b66 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt
@@ -45,7 +45,7 @@ internal fun calculateMacPlusOne(macAddress: String): String {
if (parts.size != MAC_PARTS_COUNT) return macAddress
val lastByte = parts[MAC_PARTS_COUNT - 1].toIntOrNull(HEX_RADIX) ?: return macAddress
val incremented = ((lastByte + 1) and BYTE_MASK).toString(HEX_RADIX).uppercase().padStart(2, '0')
- return parts.take(MAC_PARTS_COUNT - 1).joinToString(":") + ":" + incremented
+ return "${parts.take(MAC_PARTS_COUNT - 1).joinToString(":")}:$incremented"
}
/**
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
index 3694c4e6a..53e8ed977 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.common.util.ioDispatcher
+import org.meshtastic.core.common.util.safeCatching
/**
* WiFi/TCP transport implementation for ESP32 Unified OTA protocol.
@@ -54,7 +55,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
/** Connect to the device via TCP using Ktor raw sockets. */
override suspend fun connect(): Result = withContext(ioDispatcher) {
- runCatching {
+ safeCatching {
Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" }
val selector = SelectorManager(ioDispatcher)
@@ -82,7 +83,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
- ): Result = runCatching {
+ ): Result = safeCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
sendCommand(command)
@@ -116,7 +117,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result = withContext(ioDispatcher) {
- runCatching {
+ safeCatching {
if (!isConnected) {
throw OtaProtocolException.TransferFailed("Not connected")
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
index 83d0deecc..10320e6e5 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
import org.meshtastic.feature.firmware.ota.scanForBleDevice
import kotlin.time.Duration
@@ -91,7 +92,7 @@ class SecureDfuTransport(
*
* The caller must have already released the mesh-service BLE connection before calling this.
*/
- suspend fun triggerButtonlessDfu(): Result = runCatching {
+ suspend fun triggerButtonlessDfu(): Result = safeCatching {
Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." }
val device =
@@ -152,7 +153,7 @@ class SecureDfuTransport(
* Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling
* notifications on the Control Point.
*/
- suspend fun connectToDfuMode(): Result = runCatching {
+ suspend fun connectToDfuMode(): Result = safeCatching {
val dfuAddress = calculateMacPlusOne(address)
val targetAddresses = setOf(address, dfuAddress)
Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." }
@@ -210,7 +211,7 @@ class SecureDfuTransport(
* PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet
* is small (<512 bytes, fits in a single object) and does not benefit from flow control.
*/
- suspend fun transferInitPacket(initPacket: ByteArray): Result = runCatching {
+ suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching {
Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." }
setPrn(0)
transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null)
@@ -231,12 +232,13 @@ class SecureDfuTransport(
* @param firmware Raw bytes of the `.bin` file.
* @param onProgress Callback receiving progress in [0.0, 1.0].
*/
- suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = runCatching {
- Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." }
- setPrn(PRN_INTERVAL)
- transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress)
- Logger.i { "DFU: Firmware transferred and executed." }
- }
+ suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result =
+ safeCatching {
+ Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." }
+ setPrn(PRN_INTERVAL)
+ transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress)
+ Logger.i { "DFU: Firmware transferred and executed." }
+ }
// ---------------------------------------------------------------------------
// Abort & teardown
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt
similarity index 100%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
similarity index 100%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt
similarity index 100%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index d8f7eeae0..1607ffa5d 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.ChannelsRoute
import org.meshtastic.core.navigation.ContactsRoute
import org.meshtastic.core.navigation.NodesRoute
@@ -35,7 +35,7 @@ fun AdaptiveContactsScreen(
scrollToTopEvents: Flow,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
+ onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
) {
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index ac6232ac2..7abaf6db6 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -61,7 +62,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
@@ -117,7 +118,7 @@ fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
+ onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
@@ -131,8 +132,8 @@ fun ContactsScreen(
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
- var showMuteDialog by remember { mutableStateOf(false) }
- var showDeleteDialog by remember { mutableStateOf(false) }
+ var showMuteDialog by rememberSaveable { mutableStateOf(false) }
+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// State for managing selected contacts
val selectedContactKeys = remember { mutableStateListOf() }
@@ -255,7 +256,7 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
- onHandleDeepLink(MeshtasticUri(uriString)) {
+ onHandleDeepLink(CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index 51f131bda..036fd3404 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@@ -263,7 +263,7 @@ private fun SignalRow(node: Node) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
- value = formatString("%.1f dB", node.snr),
+ value = MetricFormatter.snr(node.snr),
icon = MeshtasticIcons.Snr,
modifier = Modifier.weight(1f),
)
@@ -273,7 +273,7 @@ private fun SignalRow(node: Node) {
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
- value = formatString("%d dBm", node.rssi),
+ value = MetricFormatter.rssi(node.rssi),
icon = MeshtasticIcons.Rssi,
modifier = Modifier.weight(1f),
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index ad6714db7..22f4422ad 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -46,12 +46,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
-import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
@@ -260,14 +259,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
label = stringResource(Res.string.channel_utilization),
- text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization),
+ text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
- text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx),
+ text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f),
contentColor = contentColor,
)
}
@@ -320,31 +319,24 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
}
if ((env.temperature ?: 0f) != 0f) {
- val temp =
- if (tempInFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f))
- } else {
- formatString("%.1f°C", env.temperature ?: 0f)
- }
+ val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
items.add {
- HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor)
+ HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)
}
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
- PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor)
+ PressureInfo(
+ pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f),
+ contentColor = contentColor,
+ )
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
- val temp =
- if (tempInFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f))
- } else {
- formatString("%.1f°C", env.soil_temperature ?: 0f)
- }
+ val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit)
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
@@ -353,7 +345,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.voltage ?: 0f) != 0f) {
items.add {
PowerInfo(
- value = formatString("%.2fV", env.voltage ?: 0f),
+ value = MetricFormatter.voltage(env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
@@ -362,7 +354,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
- value = formatString("%.1fmA", env.current ?: 0f),
+ value = MetricFormatter.current(env.current ?: 0f),
label = stringResource(Res.string.current),
contentColor = contentColor,
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 5a156b836..2e8093ad8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -72,7 +72,7 @@ fun NodeListScreen(
onNavigateToChannels: () -> Unit = {},
scrollToTopEvents: Flow? = null,
activeNodeId: Int? = null,
- onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
+ onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@@ -125,7 +125,7 @@ fun NodeListScreen(
alignment = androidx.compose.ui.Alignment.BottomEnd,
),
onImport = { uriString ->
- onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) {
+ onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
index 5725da604..1e749d22e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
@@ -55,6 +55,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.MetricFormatter
+import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
@@ -230,12 +232,13 @@ private fun DeviceMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
+ val formatted = NumberFormatter.format(value, 1)
when (color) {
- batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
- voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
- chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
- airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value)
- else -> formatString(numericValueTemplate, value)
+ batteryColor -> formatString(percentValueTemplate, batteryLabel, formatted)
+ voltageColor -> formatString(voltageValueTemplate, voltageLabel, formatted)
+ chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, formatted)
+ airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, formatted)
+ else -> formatString(numericValueTemplate, formatted)
}
},
)
@@ -337,7 +340,7 @@ private fun DeviceMetricsChart(
if (leftLayer != null) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
- valueFormatter = { _, value, _ -> formatString("%.0f%%", value) },
+ valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) },
)
} else {
null
@@ -346,7 +349,7 @@ private fun DeviceMetricsChart(
if (rightLayer != null) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
- valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
+ valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
)
} else {
null
@@ -441,7 +444,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
formatString(
percentValueTemplate,
channelUtilizationLabel,
- deviceMetrics.channel_utilization ?: 0f,
+ NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1),
),
)
Spacer(Modifier.width(12.dp))
@@ -453,7 +456,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
formatString(
percentValueTemplate,
airUtilizationLabel,
- deviceMetrics.air_util_tx ?: 0f,
+ NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1),
),
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 4967e65d5..10a3fe427 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -37,7 +37,7 @@ import okio.ByteString.Companion.decodeBase64
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
@@ -333,7 +333,7 @@ open class MetricsViewModel(
* epoch-seconds timestamp extracted by [epochSeconds].
*/
private fun exportCsv(
- uri: MeshtasticUri,
+ uri: CommonUri,
header: String,
rows: List,
epochSeconds: (T) -> Long,
@@ -351,11 +351,10 @@ open class MetricsViewModel(
}
}
- fun savePositionCSV(uri: MeshtasticUri, data: List) {
+ fun savePositionCSV(uri: CommonUri, data: List) {
exportCsv(
uri = uri,
- header =
- "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n",
+ header = "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { pos ->
@@ -366,7 +365,7 @@ open class MetricsViewModel(
}
}
- fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List) {
+ fun saveDeviceMetricsCSV(uri: CommonUri, data: List) {
exportCsv(
uri = uri,
header =
@@ -382,7 +381,7 @@ open class MetricsViewModel(
}
}
- fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) {
+ fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List) {
val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
exportCsv(
uri = uri,
@@ -405,7 +404,7 @@ open class MetricsViewModel(
}
}
- fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List) {
+ fun saveSignalMetricsCSV(uri: CommonUri, data: List) {
exportCsv(
uri = uri,
header = "\"date\",\"time\",\"rssi\",\"snr\"\n",
@@ -416,7 +415,7 @@ open class MetricsViewModel(
}
}
- fun savePowerMetricsCSV(uri: MeshtasticUri, data: List) {
+ fun savePowerMetricsCSV(uri: CommonUri, data: List) {
exportCsv(
uri = uri,
header =
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
index c815f6622..5e7560bcb 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
@@ -54,7 +54,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
+import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
@@ -194,9 +195,9 @@ private fun PowerMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color) {
- currentColor -> formatString("Current: %.0f mA", value)
- voltageColor -> formatString("Voltage: %.1f V", value)
- else -> formatString("%.1f", value)
+ currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}"
+ voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V"
+ else -> NumberFormatter.format(value.toFloat(), 1)
}
},
)
@@ -256,7 +257,7 @@ private fun PowerMetricsChart(
if (currentData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
- valueFormatter = { _, value, _ -> formatString("%.0f mA", value) },
+ valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) },
)
} else {
null
@@ -265,7 +266,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
- valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
+ valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
)
} else {
null
@@ -369,8 +370,8 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
- MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage))
- MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current))
+ MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage))
+ MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current))
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index e8b184427..4931d8c59 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -47,7 +47,7 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import org.meshtastic.core.common.util.DateFormatter
-import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
@@ -157,9 +157,9 @@ private fun SignalMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color == rssiColor) {
- formatString("RSSI: %.0f dBm", value)
+ "RSSI: ${MetricFormatter.rssi(value.toInt())}"
} else {
- formatString("SNR: %.1f dB", value)
+ "SNR: ${MetricFormatter.snr(value.toFloat())}"
}
},
)
@@ -189,7 +189,7 @@ private fun SignalMetricsChart(
if (rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
- valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) },
+ valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) },
)
} else {
null
@@ -198,7 +198,7 @@ private fun SignalMetricsChart(
if (snrData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
- valueFormatter = { _, value, _ -> formatString("%.1f dB", value) },
+ valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) },
)
} else {
null
@@ -234,15 +234,9 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
- MetricValueRow(
- color = SignalMetric.RSSI.color,
- text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
- )
+ MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi))
Spacer(Modifier.width(12.dp))
- MetricValueRow(
- color = SignalMetric.SNR.color,
- text = formatString("%.1f dB", meshPacket.rx_snr),
- )
+ MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr))
}
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
index 163bdb4f9..d4d8c0d17 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
@@ -56,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
@@ -113,7 +114,7 @@ fun TracerouteLogScreen(
val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest)
val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us)
- val durationTemplate = stringResource(Res.string.traceroute_duration, "%SECS%")
+ val durationFormatStr = stringResource(Res.string.traceroute_duration)
val threshold = timeFrame.timeThreshold()
val filteredRequests =
@@ -176,7 +177,7 @@ fun TracerouteLogScreen(
getUsername = ::getUsername,
headerTowards = headerTowardsStr,
headerBack = headerBackStr,
- durationTemplate = durationTemplate,
+ durationTemplate = durationFormatStr,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
@@ -335,7 +336,7 @@ private fun showTracerouteDetail(
statusYellow = statusYellow,
statusOrange = statusOrange,
)
- val durationText = durationTemplate.replace("%SECS%", formatString("%.1f", seconds))
+ val durationText = formatString(durationTemplate, NumberFormatter.format(seconds, 1))
buildAnnotatedString {
append(annotatedBase)
append("\n\n$durationText")
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
index cca1b67bf..dc72fac5e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
@@ -31,7 +31,7 @@ import org.meshtastic.feature.node.list.NodeListViewModel
fun AdaptiveNodeListScreen(
backStack: NavBackStack,
scrollToTopEvents: Flow,
- onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
+ onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index 778c8b220..233942f00 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -73,7 +73,7 @@ import kotlin.reflect.KClass
fun EntryProviderScope.nodesGraph(
backStack: NavBackStack,
scrollToTopEvents: Flow = MutableSharedFlow(),
- onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
+ onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry(metadata = { ListDetailSceneStrategy.listPane() }) {
AdaptiveNodeListScreen(
@@ -99,7 +99,7 @@ fun EntryProviderScope.nodesGraph(
fun EntryProviderScope.nodeDetailGraph(
backStack: NavBackStack,
scrollToTopEvents: Flow,
- onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
+ onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry(metadata = { ListDetailSceneStrategy.listPane() }) { args ->
AdaptiveNodeListScreen(
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
index 961a34dd6..956c20175 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
@@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
@@ -210,7 +210,7 @@ class MetricsViewModelTest {
awaitItem() // Empty
awaitItem() // with position
- val uri = MeshtasticUri("content://test")
+ val uri = CommonUri.parse("content://test")
vm.savePositionCSV(uri, listOf(testPosition))
runCurrent()
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index eeab3b873..82cd4b7be 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -30,15 +30,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
@@ -89,14 +90,14 @@ fun SettingsScreen(
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var deviceProfile by remember { mutableStateOf(null) }
- var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
+ var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) }
val importConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true
it.data?.data?.let { uri ->
- viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile }
+ viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile }
}
}
}
@@ -104,7 +105,7 @@ fun SettingsScreen(
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) }
+ it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) }
}
}
@@ -143,12 +144,12 @@ fun SettingsScreen(
)
}
- var showLanguagePickerDialog by remember { mutableStateOf(false) }
+ var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) }
if (showLanguagePickerDialog) {
LanguagePickerDialog { showLanguagePickerDialog = false }
}
- var showThemePickerDialog by remember { mutableStateOf(false) }
+ var showThemePickerDialog by rememberSaveable { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
@@ -249,7 +250,7 @@ fun SettingsScreen(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
- onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) },
+ onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) },
)
AppInfoSection(
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
index 96e6890b2..15cd0e11d 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
@@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.export_keys
import org.meshtastic.core.resources.export_keys_confirmation
@@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton(
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) }
+ it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) }
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index d4b39565b..ddad8296e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -28,7 +28,7 @@ import okio.BufferedSink
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@@ -187,7 +187,7 @@ class SettingsViewModel(
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
- fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) {
+ fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) {
safeLaunch(tag = "saveDataCsv") {
fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) }
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
index 6ed8cb427..1600ce947 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt
@@ -35,7 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -158,7 +158,7 @@ fun DebugSearchState(
onExportLogs: (() -> Unit)? = null,
) {
val colorScheme = MaterialTheme.colorScheme
- var customFilterText by remember { mutableStateOf("") }
+ var customFilterText by rememberSaveable { mutableStateOf("") }
Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) {
Row(
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
index 682e0e8c3..f04ade2e8 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
@@ -61,15 +61,6 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
-data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
-
-data class SearchState(
- val searchText: String = "",
- val currentMatchIndex: Int = -1,
- val allMatches: List = emptyList(),
- val hasMatches: Boolean = false,
-)
-
enum class FilterMode {
AND,
OR,
@@ -387,17 +378,15 @@ class DebugViewModel(
val nodeIdStr = nodeId.toUInt().toString()
// Only match if whitespace before and after
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""")
- regex.find(this)?.let { _ ->
- regex.findAll(this).toList().asReversed().forEach {
- val idx = it.range.last + 1
- insert(idx, " (${nodeId.toHex(8)})")
- }
- return true
+ if (!regex.containsMatchIn(this)) return false
+ regex.findAll(this).toList().asReversed().forEach {
+ val idx = it.range.last + 1
+ insert(idx, " (${nodeId.toHex(8)})")
}
- return false
+ return true
}
- private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0')
+ private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}"
fun requestDeleteAllLogs() {
alertManager.showAlert(
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index 54f0f7100..1ee791620 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -80,7 +79,7 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod
.lastOrNull { it is SettingsRoute.SettingsGraph }
?.let { (it as SettingsRoute.SettingsGraph).destNum }
}
- SideEffect { viewModel.initDestNum(destNum) }
+ LaunchedEffect(destNum) { viewModel.initDestNum(destNum) }
return viewModel
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 4b8427c87..7a946b78b 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.update
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
@@ -384,7 +384,7 @@ open class RadioConfigViewModel(
safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) }
}
- fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) {
+ fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) {
safeLaunch(tag = "importProfile") {
var profile: DeviceProfile? = null
fileService.read(uri) { source ->
@@ -394,7 +394,7 @@ open class RadioConfigViewModel(
}
}
- fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) {
+ fun exportProfile(uri: CommonUri, profile: DeviceProfile) {
safeLaunch(tag = "exportProfile") {
fileService.write(uri) { sink ->
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
@@ -402,7 +402,7 @@ open class RadioConfigViewModel(
}
}
- fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) {
+ fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) {
safeLaunch(tag = "exportSecurityConfig") {
fileService.write(uri) { sink ->
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
index 650898747..885e64219 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
@@ -113,9 +113,9 @@ private fun ChannelConfigScreen(
onPositiveClicked: (List) -> Unit,
) {
val primarySettings = settingsList.getOrNull(0) ?: return
- val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) }
- val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) }
- val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) }
+ val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name }
+ val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) }
+ val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val focusManager = LocalFocusManager.current
val settingsListInput =
@@ -141,7 +141,7 @@ private fun ChannelConfigScreen(
if (showEditChannelDialog != null) {
val index = showEditChannelDialog ?: return
EditChannelDialog(
- channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() },
+ channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(),
modemPresetName = modemPresetName,
onAddClick = {
if (settingsListInput.size > index) {
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
index 0a943a70b..8c7386db5 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
@@ -124,7 +124,7 @@ fun ChannelScreen(
val modemPresetName by
remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) }
- var showResetDialog by remember { mutableStateOf(false) }
+ var showResetDialog by rememberSaveable { mutableStateOf(false) }
var shouldAddChannelsState by remember { mutableStateOf(true) }
@@ -211,7 +211,7 @@ fun ChannelScreen(
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
- var showShareDialog by remember { mutableStateOf(false) }
+ var showShareDialog by rememberSaveable { mutableStateOf(false) }
if (showShareDialog) {
ChannelShareDialog(
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
index e4f91ece6..f57306799 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
@@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val primarySettings = state.channelList.getOrNull(0) ?: return
val formState = rememberConfigState(initialValue = loraConfig)
- val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) }
+ val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
index 03330dc3e..1723e6df6 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
@@ -36,6 +36,7 @@ import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.feature.wifiprovision.NymeaBleConstants
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN
@@ -88,7 +89,7 @@ class NymeaWifiService(
* @return The discovered device's advertised name on success.
* @throws IllegalStateException if no device is found within [SCAN_TIMEOUT].
*/
- suspend fun connect(address: String? = null): Result = runCatching {
+ suspend fun connect(address: String? = null): Result = safeCatching {
Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" }
val device =
@@ -138,7 +139,7 @@ class NymeaWifiService(
*
* Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0).
*/
- suspend fun scanNetworks(): Result> = runCatching {
+ suspend fun scanNetworks(): Result> = safeCatching {
// Trigger scan
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
val scanAck = NymeaJson.decodeFromString(waitForResponse())
@@ -180,7 +181,7 @@ class NymeaWifiService(
NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)),
)
- return runCatching {
+ return safeCatching {
sendCommand(json)
val response = NymeaJson.decodeFromString(waitForResponse())
if (response.responseCode == RESPONSE_SUCCESS) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2c9978463..c3b4c24ca 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -68,6 +68,7 @@ firebase-crashlytics-gradle = "3.0.7"
google-services-gradle = "4.4.4"
markdownRenderer = "0.40.2"
okio = "3.17.0"
+uri-kmp = "0.0.21"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
wire = "6.2.0"
@@ -104,7 +105,6 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi
# lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0;
# use jetbrains-lifecycle-runtime (JB KMP fork) instead.
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
-androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" }
# JetBrains KMP lifecycle (use in commonMain and androidMain)
jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" }
@@ -228,6 +228,7 @@ kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref =
jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
+uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }
osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" }
From a2763bdfebb0885a229057f7a812d8b5775b7400 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 21:20:33 -0500
Subject: [PATCH 15/62] fix(charts): apply Vico 3.1.0 best-practice audit fixes
(#5138)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../feature/node/metrics/BaseMetricChart.kt | 75 +++++++++++--------
.../feature/node/metrics/ChartStyling.kt | 42 +++++++----
.../feature/node/metrics/DeviceMetrics.kt | 3 +-
.../feature/node/metrics/EnvironmentCharts.kt | 18 +++--
.../feature/node/metrics/PaxMetrics.kt | 17 ++++-
.../feature/node/metrics/TracerouteChart.kt | 17 ++++-
6 files changed, 110 insertions(+), 62 deletions(-)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
index 8f65bf6d8..a425e272d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
@@ -35,6 +35,7 @@ import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -79,6 +80,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
+/** Minimum x-step (in seconds) to prevent the default GCD from producing a value of 1 with irregular timestamps. */
+private const val MIN_X_STEP_SECONDS = 60.0
+
/**
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
* selection synchronization.
@@ -100,43 +104,50 @@ fun GenericMetricChart(
onPointSelected: ((Double) -> Unit)? = null,
vicoScrollState: VicoScrollState = rememberVicoScrollState(),
) {
- // Hoist zoom state above rememberCartesianChart so that the variable slot count
- // from the vararg layers spread does not shift this remember call during recomposition
- // (toggling legend chips changes the layer count, which corrupts the slot table).
- val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content)
+ // Key on layer count so Compose rebuilds the entire subtree when legend chip toggles
+ // add/remove layers. rememberCartesianChart uses vararg internally, so changing the
+ // argument count without a key corrupts the slot table.
+ key(layers.size) {
+ val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content)
- val markerVisibilityListener =
- remember(onPointSelected) {
- object : CartesianMarkerVisibilityListener {
- override fun onShown(marker: CartesianMarker, targets: List) {
- targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
- }
+ val markerVisibilityListener =
+ remember(onPointSelected) {
+ object : CartesianMarkerVisibilityListener {
+ override fun onShown(marker: CartesianMarker, targets: List) {
+ targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
+ }
- override fun onUpdated(marker: CartesianMarker, targets: List) {
- targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
+ override fun onUpdated(marker: CartesianMarker, targets: List) {
+ targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
+ }
}
}
- }
- CartesianChartHost(
- chart =
- @Suppress("SpreadOperator")
- rememberCartesianChart(
- *layers.toTypedArray(),
- startAxis = startAxis,
- endAxis = endAxis,
- bottomAxis = bottomAxis,
- marker = marker,
- markerVisibilityListener = markerVisibilityListener,
- persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
- fadingEdges = rememberFadingEdges(),
- decorations = decorations,
- ),
- modelProducer = modelProducer,
- modifier = modifier,
- scrollState = vicoScrollState,
- zoomState = zoomState,
- )
+ CartesianChartHost(
+ chart =
+ @Suppress("SpreadOperator")
+ rememberCartesianChart(
+ *layers.toTypedArray(),
+ startAxis = startAxis,
+ endAxis = endAxis,
+ bottomAxis = bottomAxis,
+ marker = marker,
+ markerVisibilityListener = markerVisibilityListener,
+ persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
+ fadingEdges = rememberFadingEdges(),
+ decorations = decorations,
+ // Telemetry timestamps arrive at irregular intervals. Without an explicit
+ // x-step, Vico computes the GCD of consecutive x-value differences which can
+ // be as small as 1 second, making the chart logically enormous. A 60-second
+ // floor keeps the internal slot count reasonable for any practical interval.
+ getXStep = { model -> maxOf(model.getXDeltaGcd(), MIN_X_STEP_SECONDS) },
+ ),
+ modelProducer = modelProducer,
+ modifier = modifier,
+ scrollState = vicoScrollState,
+ zoomState = zoomState,
+ )
+ }
}
/**
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
index c1cf0e04e..da8b16e47 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt
@@ -57,7 +57,7 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
* **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)):
* - Default to thin lines **without** point markers to avoid clutter on dense timeseries.
* - Show a single dot only at the marker/cursor position (handled by [rememberMarker]).
- * - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point.
+ * - Use `Interpolator.cubic()` for smooth monotone curves that won't overshoot between sparse points.
* - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data.
*/
@Suppress("TooManyFunctions")
@@ -73,15 +73,21 @@ object ChartStyling {
*
* @param lineColor The color of the line
* @param lineWidth Width of the line in dp
+ * @param interpolator The line interpolation strategy. Defaults to monotone
+ * [cubic][LineCartesianLayer.Interpolator.cubic] which won't overshoot between sparse data points (unlike
+ * catmull-rom). Use [Sharp][LineCartesianLayer.Interpolator.Sharp] for discrete/integer metrics like hop counts.
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
- fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line =
- LineCartesianLayer.rememberLine(
- fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
- stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
- interpolator = LineCartesianLayer.Interpolator.catmullRom(),
- )
+ fun createStyledLine(
+ lineColor: Color,
+ lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
+ interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
+ ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
+ fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
+ stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
+ interpolator = interpolator,
+ )
/**
* Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The
@@ -92,14 +98,18 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
- fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line {
+ fun createGradientLine(
+ lineColor: Color,
+ lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
+ interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
+ ): LineCartesianLayer.Line {
val gradientBrush =
Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f)))
return LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)),
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
- interpolator = LineCartesianLayer.Interpolator.catmullRom(),
+ interpolator = interpolator,
)
}
@@ -110,8 +120,11 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
- fun createBoldLine(lineColor: Color): LineCartesianLayer.Line =
- createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP)
+ fun createBoldLine(
+ lineColor: Color,
+ interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
+ ): LineCartesianLayer.Line =
+ createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator)
/**
* Creates a subtle line suitable for secondary metrics that should not dominate the chart.
@@ -131,7 +144,10 @@ object ChartStyling {
* @return Configured [LineCartesianLayer.Line]
*/
@Composable
- fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
+ fun createDashedLine(
+ lineColor: Color,
+ interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(),
+ ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
stroke =
LineCartesianLayer.LineStroke.Dashed(
@@ -139,7 +155,7 @@ object ChartStyling {
dashLength = 6.dp,
gapLength = 3.dp,
),
- interpolator = LineCartesianLayer.Interpolator.catmullRom(),
+ interpolator = interpolator,
)
/**
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
index 1e749d22e..609048a92 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt
@@ -307,12 +307,13 @@ private fun DeviceMetricsChart(
}
}
+ val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) }
val leftLayer =
rememberConditionalLayer(
hasData = leftLayerSeriesStyles.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
+ rangeProvider = percentRangeProvider,
)
val rightLayer =
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
index 0f809ef81..5029729ca 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
@@ -158,11 +158,11 @@ fun EnvironmentMetricsChart(
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
- // Legend toggle state: tracks indices into allLegendData that are hidden
- var hiddenIndices by remember { mutableStateOf(emptySet()) }
- val hiddenMetrics =
- remember(hiddenIndices, allLegendData) {
- hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
+ // Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering.
+ var hiddenMetrics by remember { mutableStateOf(emptySet()) }
+ val hiddenIndices =
+ remember(hiddenMetrics, allLegendData) {
+ allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet()
}
val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) }
@@ -233,6 +233,7 @@ fun EnvironmentMetricsChart(
},
)
+ val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) }
val layers = mutableListOf()
if (showPressure && pressureData.isNotEmpty()) {
layers.add(
@@ -244,7 +245,7 @@ fun EnvironmentMetricsChart(
verticalAxisPosition = Axis.Position.Vertical.Start,
// Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale,
// otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude.
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0),
+ rangeProvider = pressureRangeProvider,
),
)
}
@@ -254,7 +255,7 @@ fun EnvironmentMetricsChart(
when (metric) {
Environment.RADIATION,
Environment.WIND_SPEED,
- -> CartesianLayerRangeProvider.fixed(minY = 0.0)
+ -> CartesianLayerRangeProvider.auto()
else -> null
}
val lineStyle =
@@ -310,7 +311,8 @@ fun EnvironmentMetricsChart(
modifier = Modifier.padding(top = 0.dp),
hiddenSet = hiddenIndices,
onToggle = { index ->
- hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index
+ val metric = allLegendData.getOrNull(index)?.metricKey as? Environment ?: return@Legend
+ hiddenMetrics = if (metric in hiddenMetrics) hiddenMetrics - metric else hiddenMetrics + metric
},
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
index 598cd5ca9..b3b0b36e0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt
@@ -141,11 +141,20 @@ private fun PaxMetricsChart(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
- ChartStyling.createGradientLine(lineColor = bleColor),
- ChartStyling.createGradientLine(lineColor = wifiColor),
- ChartStyling.createBoldLine(lineColor = paxColor),
+ ChartStyling.createGradientLine(
+ lineColor = bleColor,
+ interpolator = LineCartesianLayer.Interpolator.Sharp,
+ ),
+ ChartStyling.createGradientLine(
+ lineColor = wifiColor,
+ interpolator = LineCartesianLayer.Interpolator.Sharp,
+ ),
+ ChartStyling.createBoldLine(
+ lineColor = paxColor,
+ interpolator = LineCartesianLayer.Interpolator.Sharp,
+ ),
),
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ rangeProvider = CartesianLayerRangeProvider.auto(),
),
),
startAxis = VerticalAxis.rememberStart(label = axisLabel),
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
index c1e5e69fe..c27f111d1 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt
@@ -189,17 +189,26 @@ internal fun TracerouteMetricsChart(
val forwardLayer =
rememberConditionalLayer(
hasData = forwardData.isNotEmpty(),
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
+ lineProvider =
+ LineCartesianLayer.LineProvider.series(
+ ChartStyling.createStyledLine(
+ forwardColor,
+ interpolator = LineCartesianLayer.Interpolator.Sharp,
+ ),
+ ),
verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ rangeProvider = CartesianLayerRangeProvider.auto(),
)
val returnLayer =
rememberConditionalLayer(
hasData = returnData.isNotEmpty(),
- lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
+ lineProvider =
+ LineCartesianLayer.LineProvider.series(
+ ChartStyling.createDashedLine(returnColor, interpolator = LineCartesianLayer.Interpolator.Sharp),
+ ),
verticalAxisPosition = Axis.Position.Vertical.Start,
- rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
+ rangeProvider = CartesianLayerRangeProvider.auto(),
)
val rttLayer =
From 401f59489a733330f2b1b9a27ebbbb1661bc34a2 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 22:10:23 -0500
Subject: [PATCH 16/62] chore: remove deprecated mesh_service_example module
(#5055)
---
.github/workflows/pull-request.yml | 3 +-
.skills/project-overview/SKILL.md | 1 -
.skills/testing-ci/SKILL.md | 2 +-
codecov.yml | 4 -
.../core/service/ServiceBroadcasts.kt | 2 +-
docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md | 4 +-
gradle/libs.versions.toml | 1 -
mesh_service_example/README.md | 20 -
mesh_service_example/build.gradle.kts | 54 --
mesh_service_example/detekt-baseline.xml | 5 -
mesh_service_example/proguard-rules.pro | 21 -
.../src/main/AndroidManifest.xml | 33 -
.../meshserviceexample/MainActivity.kt | 187 ------
.../android/meshserviceexample/MainScreen.kt | 585 ------------------
.../MeshServiceViewModel.kt | 363 -----------
.../ic_launcher_background.xml | 170 -----
.../ic_launcher_foreground.xml | 30 -
.../main/res/mipmap-anydpi/ic_launcher.xml | 6 -
.../src/main/res/values-ar-rSA/strings.xml | 21 -
.../src/main/res/values-b+sr+Latn/strings.xml | 21 -
.../src/main/res/values-be-rBY/strings.xml | 21 -
.../src/main/res/values-bg-rBG/strings.xml | 21 -
.../src/main/res/values-ca-rES/strings.xml | 21 -
.../src/main/res/values-cs-rCZ/strings.xml | 21 -
.../src/main/res/values-de-rDE/strings.xml | 21 -
.../src/main/res/values-el-rGR/strings.xml | 21 -
.../src/main/res/values-es-rES/strings.xml | 21 -
.../src/main/res/values-et-rEE/strings.xml | 21 -
.../src/main/res/values-fi-rFI/strings.xml | 21 -
.../src/main/res/values-fr-rFR/strings.xml | 21 -
.../src/main/res/values-ga-rIE/strings.xml | 21 -
.../src/main/res/values-gl-rES/strings.xml | 21 -
.../src/main/res/values-hr-rHR/strings.xml | 21 -
.../src/main/res/values-ht-rHT/strings.xml | 21 -
.../src/main/res/values-hu-rHU/strings.xml | 21 -
.../src/main/res/values-is-rIS/strings.xml | 21 -
.../src/main/res/values-it-rIT/strings.xml | 21 -
.../src/main/res/values-iw-rIL/strings.xml | 21 -
.../src/main/res/values-ja-rJP/strings.xml | 21 -
.../src/main/res/values-ko-rKR/strings.xml | 21 -
.../src/main/res/values-lt-rLT/strings.xml | 21 -
.../src/main/res/values-nl-rNL/strings.xml | 21 -
.../src/main/res/values-no-rNO/strings.xml | 21 -
.../src/main/res/values-pl-rPL/strings.xml | 21 -
.../src/main/res/values-pt-rBR/strings.xml | 21 -
.../src/main/res/values-pt-rPT/strings.xml | 21 -
.../src/main/res/values-ro-rRO/strings.xml | 21 -
.../src/main/res/values-ru-rRU/strings.xml | 21 -
.../src/main/res/values-sk-rSK/strings.xml | 21 -
.../src/main/res/values-sl-rSI/strings.xml | 21 -
.../src/main/res/values-sq-rAL/strings.xml | 21 -
.../src/main/res/values-srp/strings.xml | 21 -
.../src/main/res/values-sv-rSE/strings.xml | 21 -
.../src/main/res/values-tr-rTR/strings.xml | 21 -
.../src/main/res/values-uk-rUA/strings.xml | 21 -
.../src/main/res/values-zh-rCN/strings.xml | 21 -
.../src/main/res/values-zh-rTW/strings.xml | 21 -
.../src/main/res/values/colors.xml | 2 -
.../src/main/res/values/strings.xml | 21 -
.../src/main/res/values/themes.xml | 9 -
.../src/main/res/xml/backup_rules.xml | 13 -
.../main/res/xml/data_extraction_rules.xml | 19 -
settings.gradle.kts | 1 -
63 files changed, 5 insertions(+), 2370 deletions(-)
delete mode 100644 mesh_service_example/README.md
delete mode 100644 mesh_service_example/build.gradle.kts
delete mode 100644 mesh_service_example/detekt-baseline.xml
delete mode 100644 mesh_service_example/proguard-rules.pro
delete mode 100644 mesh_service_example/src/main/AndroidManifest.xml
delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt
delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt
delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt
delete mode 100644 mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml
delete mode 100644 mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml
delete mode 100644 mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml
delete mode 100644 mesh_service_example/src/main/res/values-ar-rSA/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-be-rBY/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-bg-rBG/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ca-rES/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-cs-rCZ/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-de-rDE/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-el-rGR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-es-rES/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-et-rEE/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-fi-rFI/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-fr-rFR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ga-rIE/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-gl-rES/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-hr-rHR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ht-rHT/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-hu-rHU/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-is-rIS/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-it-rIT/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-iw-rIL/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ja-rJP/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ko-rKR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-lt-rLT/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-nl-rNL/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-no-rNO/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-pl-rPL/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-pt-rBR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-pt-rPT/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ro-rRO/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-ru-rRU/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-sk-rSK/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-sl-rSI/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-sq-rAL/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-srp/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-sv-rSE/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-tr-rTR/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-uk-rUA/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-zh-rCN/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values-zh-rTW/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values/colors.xml
delete mode 100644 mesh_service_example/src/main/res/values/strings.xml
delete mode 100644 mesh_service_example/src/main/res/values/themes.xml
delete mode 100644 mesh_service_example/src/main/res/xml/backup_rules.xml
delete mode 100644 mesh_service_example/src/main/res/xml/data_extraction_rules.xml
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 209d6e35c..d450711ce 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -70,8 +70,7 @@ jobs:
}
allowed_extra_roots = {'baselineprofile'}
- excluded_roots = {'mesh_service_example'}
- expected_roots = (module_roots | allowed_extra_roots) - excluded_roots
+ expected_roots = module_roots | allowed_extra_roots
filter_paths = {
path.split('/')[0]
diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md
index 291cff488..2224fa7ad 100644
--- a/.skills/project-overview/SKILL.md
+++ b/.skills/project-overview/SKILL.md
@@ -39,7 +39,6 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
-| `mesh_service_example/` | **DEPRECATED.** Legacy sample app; not yet removed. See `core/api/README.md` for the current integration guide. |
## Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
index 2c20258c1..1c8b7b901 100644
--- a/.skills/testing-ci/SKILL.md
+++ b/.skills/testing-ci/SKILL.md
@@ -48,7 +48,7 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
- `shard-core`: `allTests` for all `core:*` KMP modules.
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
- - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`).
+ - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
diff --git a/codecov.yml b/codecov.yml
index 6e0989227..7f77510ff 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -57,10 +57,6 @@ component_management:
name: Desktop
paths:
- desktop/**
- - component_id: example
- name: Example
- paths:
- - mesh_service_example/**
ignore:
- "**/build/**"
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
index 57408cff1..22bacf43a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
@@ -133,7 +133,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit
explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED))
}
- // Restore legacy action for other consumers (e.g. mesh_service_example)
+ // Restore legacy action for other consumers (e.g. ATAK plugins)
val legacyIntent =
Intent(ACTION_CONNECTION_CHANGED).apply {
putExtra(EXTRA_CONNECTED, stateStr)
diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md
index 5898f7f94..d3dd5ad93 100644
--- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md
+++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md
@@ -121,8 +121,8 @@ kotlin {
```
**What the plugin provides automatically:**
-- `commonMain`: `compose-multiplatform-material3`, `compose-multiplatform-materialIconsExtended`, `jetbrains-lifecycle-viewmodel-compose`, `koin-compose-viewmodel`, `kermit`
-- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-material-iconsExtended`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview`
+- `commonMain`: `compose-multiplatform-material3`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-runtime-compose`, `koin-compose-viewmodel`, `kermit`
+- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview`
- `commonTest`: `core:testing`
**Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin`).
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c3b4c24ca..d1051dc2a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -122,7 +122,6 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" }
# AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority)
-androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-material" } # Only used by deprecated mesh_service_example — remove when that module is deleted
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity)
diff --git a/mesh_service_example/README.md b/mesh_service_example/README.md
deleted file mode 100644
index 3804db328..000000000
--- a/mesh_service_example/README.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# mesh_service_example
-
-> **DEPRECATED — scheduled for removal in a future release.**
->
-> This module is no longer maintained and will be deleted once the new public API documentation is
-> available. Do not add new code here. Do not use it as a template for new integrations.
->
-> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module
-> README at [`core/api/README.md`](../core/api/README.md).
-
-## What this was
-
-`mesh_service_example` was a sample Android application demonstrating how to bind to the
-`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in
-the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`)
-and the JitPack publication that consumers may reference, until those are formally retired.
-
-## License
-
-See the root `LICENSE` file.
diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts
deleted file mode 100644
index 793735dda..000000000
--- a/mesh_service_example/build.gradle.kts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * 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 .
- */
-
-import com.android.build.api.dsl.ApplicationExtension
-import org.meshtastic.buildlogic.FlavorDimension
-import org.meshtastic.buildlogic.MeshtasticFlavor
-
-plugins {
- alias(libs.plugins.meshtastic.android.application)
- alias(libs.plugins.meshtastic.android.application.compose)
- alias(libs.plugins.kotlin.parcelize)
- alias(libs.plugins.kotlin.serialization)
-}
-
-configure {
- namespace = "com.meshtastic.android.meshserviceexample"
- defaultConfig {
- // Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin
- missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name)
- }
-
- testOptions { unitTests.isReturnDefaultValues = true }
-}
-
-dependencies {
- implementation(projects.core.api)
- implementation(projects.core.model)
- implementation(projects.core.proto)
-
- implementation(libs.androidx.activity.compose)
- implementation(libs.jetbrains.lifecycle.viewmodel.compose)
- implementation(libs.jetbrains.lifecycle.runtime)
- implementation(libs.compose.multiplatform.material3)
- implementation(libs.androidx.compose.material.iconsExtended)
- implementation(libs.material)
-
- testImplementation(libs.junit)
- testRuntimeOnly(libs.junit.vintage.engine)
- testImplementation(libs.kotlinx.coroutines.test)
-}
diff --git a/mesh_service_example/detekt-baseline.xml b/mesh_service_example/detekt-baseline.xml
deleted file mode 100644
index ecf2e0cce..000000000
--- a/mesh_service_example/detekt-baseline.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro
deleted file mode 100644
index 481bb4348..000000000
--- a/mesh_service_example/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml
deleted file mode 100644
index b8ffa4cae..000000000
--- a/mesh_service_example/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt
deleted file mode 100644
index d61c6f192..000000000
--- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("DEPRECATION")
-
-package com.meshtastic.android.meshserviceexample
-
-import android.content.BroadcastReceiver
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.content.ServiceConnection
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Bundle
-import android.os.IBinder
-import android.util.Log
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.viewModels
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import org.meshtastic.core.api.MeshtasticIntent
-import org.meshtastic.core.service.IMeshService
-
-private const val TAG: String = "MeshServiceExample"
-
-/**
- * MainActivity for the MeshServiceExample application.
- *
- * **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use
- * it as a template for new integrations. See `:core:api` README for the current public API surface.
- */
-@Deprecated(
- message =
- "mesh_service_example is deprecated and will be removed in a future release. " +
- "See core/api/README.md for integration guidance.",
-)
-class MainActivity : ComponentActivity() {
-
- private var meshService: IMeshService? = null
- private var isMeshServiceBound = false
-
- private val viewModel: MeshServiceViewModel by viewModels()
-
- private val serviceConnection =
- object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
- meshService = IMeshService.Stub.asInterface(service)
- Log.i(TAG, "Connected to MeshService")
- isMeshServiceBound = true
- viewModel.onServiceConnected(meshService)
- }
-
- override fun onServiceDisconnected(name: ComponentName?) {
- meshService = null
- isMeshServiceBound = false
- viewModel.onServiceDisconnected()
- }
- }
-
- private val meshtasticReceiver =
- object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}")
- intent?.let { viewModel.handleIncomingIntent(it) }
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
-
- bindMeshService()
-
- val intentFilter =
- IntentFilter().apply {
- addAction(MeshtasticIntent.ACTION_NODE_CHANGE)
- addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED)
- addAction(MeshtasticIntent.ACTION_MESH_CONNECTED)
- addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED)
- addAction(MeshtasticIntent.ACTION_MESSAGE_STATUS)
- addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP)
- addAction(MeshtasticIntent.ACTION_RECEIVED_POSITION_APP)
- addAction(MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP)
- addAction(MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP)
- addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN)
- addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER)
- addAction(MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP)
- addAction(MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP)
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED)
- } else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(meshtasticReceiver, intentFilter)
- }
-
- setContent { ExampleTheme { MainScreen(viewModel) } }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- unregisterReceiver(meshtasticReceiver)
- unbindMeshService()
- }
-
- private fun bindMeshService() {
- try {
- Log.i(TAG, "Attempting to bind to Mesh Service...")
- val intent = Intent("com.geeksville.mesh.Service")
-
- val resolveInfo =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0))
- } else {
- @Suppress("DEPRECATION")
- packageManager.queryIntentServices(intent, 0)
- }
-
- if (resolveInfo.isNotEmpty()) {
- val serviceInfo = resolveInfo[0].serviceInfo
- intent.setClassName(serviceInfo.packageName, serviceInfo.name)
- Log.i(TAG, "Found service in package: ${serviceInfo.packageName}")
- } else {
- Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.")
- intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
- }
-
- val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE)
- if (!success) {
- Log.e(TAG, "bindService returned false")
- }
- } catch (e: SecurityException) {
- Log.e(TAG, "SecurityException while binding: ${e.message}")
- }
- }
-
- private fun unbindMeshService() {
- if (isMeshServiceBound) {
- try {
- unbindService(serviceConnection)
- } catch (e: IllegalArgumentException) {
- Log.w(TAG, "MeshService not registered or already unbound: ${e.message}")
- }
- isMeshServiceBound = false
- meshService = null
- }
- }
-}
-
-@Composable
-fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
- val colorScheme =
- when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
- darkTheme -> darkColorScheme()
- else -> lightColorScheme()
- }
-
- MaterialTheme(colorScheme = colorScheme, content = content)
-}
diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt
deleted file mode 100644
index 408a37d25..000000000
--- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt
+++ /dev/null
@@ -1,585 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("TooManyFunctions")
-
-package com.meshtastic.android.meshserviceexample
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.Message
-import androidx.compose.material.icons.automirrored.rounded.Send
-import androidx.compose.material.icons.rounded.AccountCircle
-import androidx.compose.material.icons.rounded.BatteryUnknown
-import androidx.compose.material.icons.rounded.ExpandLess
-import androidx.compose.material.icons.rounded.ExpandMore
-import androidx.compose.material.icons.rounded.GpsFixed
-import androidx.compose.material.icons.rounded.GpsOff
-import androidx.compose.material.icons.rounded.Hub
-import androidx.compose.material.icons.rounded.Info
-import androidx.compose.material.icons.rounded.MyLocation
-import androidx.compose.material.icons.rounded.PersonSearch
-import androidx.compose.material.icons.rounded.Refresh
-import androidx.compose.material.icons.rounded.RestartAlt
-import androidx.compose.material.icons.rounded.Route
-import androidx.compose.material.icons.rounded.Router
-import androidx.compose.material.icons.rounded.SignalCellularAlt
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.launch
-import org.meshtastic.core.model.NodeInfo
-import org.meshtastic.proto.PortNum
-
-@Composable
-fun ListItem(
- text: String,
- supportingText: String? = null,
- leadingIcon: ImageVector? = null,
- trailingIcon: ImageVector? = null,
-) {
- androidx.compose.material3.ListItem(
- headlineContent = { Text(text) },
- supportingContent = supportingText?.let { { Text(it) } },
- leadingContent = leadingIcon?.let { { Icon(it, contentDescription = null) } },
- trailingContent = trailingIcon?.let { { Icon(it, contentDescription = null) } },
- )
-}
-
-@Composable
-fun TitledCard(title: String, content: @Composable () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth()) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(bottom = 12.dp),
- )
- content()
- }
- }
-}
-
-@Composable
-fun SectionHeader(title: String, expanded: Boolean, onExpandClick: () -> Unit, modifier: Modifier = Modifier) {
- Card(
- modifier = modifier.fillMaxWidth().clickable { onExpandClick() },
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
- Icon(
- imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
- contentDescription = if (expanded) "Collapse" else "Expand",
- tint = MaterialTheme.colorScheme.primary,
- )
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun MainScreen(viewModel: MeshServiceViewModel) {
- val isConnected by viewModel.serviceConnectionStatus.collectAsState()
- val connectionState by viewModel.connectionState.collectAsState()
- val snackbarHostState = remember { SnackbarHostState() }
- val scope = rememberCoroutineScope()
-
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- snackbarHost = { SnackbarHost(snackbarHostState) },
- topBar = {
- TopAppBar(
- title = { TopBarTitle(isConnected, connectionState) },
- actions = {
- IconButton(
- onClick = {
- viewModel.requestNodes()
- scope.launch { snackbarHostState.showSnackbar("Refreshing nodes...") }
- },
- ) {
- Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Nodes")
- }
- },
- colors =
- TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface,
- titleContentColor = MaterialTheme.colorScheme.onSurface,
- ),
- )
- },
- ) { innerPadding ->
- MainContent(viewModel, innerPadding, snackbarHostState)
- }
-}
-
-@Composable
-private fun TopBarTitle(isConnected: Boolean, connectionState: String) {
- Column {
- Text(
- text = "Mesh Service Example",
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.titleLarge,
- )
- Row(verticalAlignment = Alignment.CenterVertically) {
- val statusColor =
- if (isConnected) {
- Color.Green
- } else {
- MaterialTheme.colorScheme.error
- }
- Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(statusColor))
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = if (isConnected) "Connected ($connectionState)" else "Disconnected",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-
-@Composable
-@Suppress("LongMethod")
-private fun MainContent(
- viewModel: MeshServiceViewModel,
- innerPadding: PaddingValues,
- snackbarHostState: SnackbarHostState,
-) {
- val myNodeInfo by viewModel.myNodeInfo.collectAsState()
- val myId by viewModel.myId.collectAsState()
- val nodes by viewModel.nodes.collectAsState()
- val lastMessage by viewModel.message.collectAsState()
- val packetLog by viewModel.packetLog.collectAsState()
-
- var nodesExpanded by remember { mutableStateOf(false) }
- var logExpanded by remember { mutableStateOf(false) }
- val scope = rememberCoroutineScope()
-
- LazyColumn(
- modifier = Modifier.padding(innerPadding).fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- item { MyInfoSection(myId, myNodeInfo) }
- item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } }
- item { TitledCard(title = "Test Special PortNums") { SpecialAppSection(viewModel) } }
-
- item {
- SectionHeader(
- title = "Mesh Nodes (${nodes.size})",
- expanded = nodesExpanded,
- onExpandClick = { nodesExpanded = !nodesExpanded },
- )
- }
-
- if (nodesExpanded) {
- if (nodes.isEmpty()) {
- item { EmptyNodeState() }
- } else {
- items(nodes) { node ->
- Card(modifier = Modifier.fillMaxWidth()) {
- val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node"
- NodeItem(node) { action ->
- scope.launch {
- when (action) {
- "traceroute" -> {
- viewModel.requestTraceroute(node.num)
- snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel")
- }
- "telemetry" -> {
- viewModel.requestTelemetry(node.num)
- snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel")
- }
- "neighbors" -> {
- viewModel.requestNeighborInfo(node.num)
- snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel")
- }
- "position" -> {
- viewModel.requestPosition(node.num)
- snackbarHostState.showSnackbar("Position requested for $nodeLabel")
- }
- "userinfo" -> {
- viewModel.requestUserInfo(node.num)
- snackbarHostState.showSnackbar("User info requested for $nodeLabel")
- }
- "connstatus" -> {
- viewModel.requestDeviceConnectionStatus(node.num)
- snackbarHostState.showSnackbar("Connection status requested for $nodeLabel")
- }
- }
- }
- }
- }
- }
- }
- }
-
- item {
- SectionHeader(title = "Packet Log", expanded = logExpanded, onExpandClick = { logExpanded = !logExpanded })
- }
-
- if (logExpanded) {
- item {
- Card(modifier = Modifier.fillMaxWidth()) {
- Box(modifier = Modifier.padding(16.dp)) { PacketLogContent(packetLog) }
- }
- }
- }
-
- item { ActionButtons(viewModel, snackbarHostState) }
- item { Spacer(modifier = Modifier.height(16.dp)) }
- }
-}
-
-@Composable
-fun SpecialAppSection(viewModel: MeshServiceViewModel) {
- Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = { viewModel.sendSpecialPacket(PortNum.ATAK_PLUGIN) }, modifier = Modifier.weight(1f)) {
- Text("Send ATAK")
- }
- Button(
- onClick = { viewModel.sendSpecialPacket(PortNum.DETECTION_SENSOR_APP) },
- modifier = Modifier.weight(1f),
- ) {
- Text("Send Sensor")
- }
- }
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = { viewModel.sendSpecialPacket(PortNum.PRIVATE_APP) }, modifier = Modifier.weight(1f)) {
- Text("Send Private")
- }
- }
- }
-}
-
-@Composable
-private fun PacketLogContent(log: List) {
- Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) {
- if (log.isEmpty()) {
- Text(
- text = "No packets yet.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 8.dp),
- )
- } else {
- log.forEach { entry ->
- Text(
- text = entry,
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
- modifier = Modifier.padding(vertical = 2.dp),
- )
- HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
- }
- }
- }
-}
-
-@Composable
-private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) {
- TitledCard(title = "My Node Information") {
- ListItem(
- text = "Long ID",
- supportingText = myId ?: "N/A",
- leadingIcon = Icons.Rounded.AccountCircle,
- trailingIcon = null,
- )
- ListItem(
- text = "Firmware",
- supportingText = myNodeInfo?.firmwareString ?: "N/A",
- leadingIcon = Icons.Rounded.Info,
- trailingIcon = null,
- )
- }
-}
-
-@Composable
-private fun EmptyNodeState() {
- Text(
- text = "No mesh nodes discovered yet.",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
- textAlign = TextAlign.Center,
- )
-}
-
-@Composable
-fun MessagingSection(viewModel: MeshServiceViewModel, lastMessage: String) {
- var textToSend by remember { mutableStateOf("") }
-
- Column(modifier = Modifier.padding(16.dp)) {
- if (lastMessage.isNotEmpty()) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors =
- CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
- ),
- ) {
- ListItem(
- text = "Last Received",
- supportingText = lastMessage,
- leadingIcon = Icons.AutoMirrored.Rounded.Message,
- trailingIcon = null,
- )
- }
- Spacer(modifier = Modifier.height(12.dp))
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- OutlinedTextField(
- value = textToSend,
- onValueChange = { textToSend = it },
- modifier = Modifier.weight(1f),
- label = { Text("Send broadcast message") },
- shape = MaterialTheme.shapes.large,
- )
- Spacer(modifier = Modifier.width(8.dp))
- Button(
- onClick = {
- if (textToSend.isNotBlank()) {
- viewModel.sendMessage(textToSend)
- textToSend = ""
- }
- },
- modifier = Modifier.size(56.dp),
- shape = MaterialTheme.shapes.large,
- contentPadding = PaddingValues(0.dp),
- ) {
- Icon(imageVector = Icons.AutoMirrored.Rounded.Send, contentDescription = "Send")
- }
- }
- }
-}
-
-@Composable
-fun NodeItem(node: NodeInfo, onAction: (String) -> Unit) {
- Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
- NodeItemHeader(node)
- Spacer(modifier = Modifier.height(8.dp))
- NodeItemActions(node.isOnline, onAction)
- }
-}
-
-@Composable
-private fun NodeItemHeader(node: NodeInfo) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(contentAlignment = Alignment.BottomEnd) {
- Icon(
- imageVector = Icons.Rounded.AccountCircle,
- contentDescription = null,
- modifier = Modifier.size(48.dp),
- tint = MaterialTheme.colorScheme.outline,
- )
- if (node.isOnline) {
- Box(
- modifier =
- Modifier.size(14.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.surface)
- .padding(2.dp)
- .clip(CircleShape)
- .background(Color.Green),
- )
- }
- }
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = node.user?.longName ?: "Unknown Node",
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = "ID: ${node.user?.id ?: "N/A"}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
-}
-
-@Composable
-private fun NodeItemActions(isOnline: Boolean, onAction: (String) -> Unit) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = { onAction("traceroute") }, modifier = Modifier.size(40.dp)) {
- Icon(Icons.Rounded.Route, "Traceroute", Modifier.size(20.dp), MaterialTheme.colorScheme.primary)
- }
- IconButton(onClick = { onAction("telemetry") }, modifier = Modifier.size(40.dp)) {
- Icon(
- @Suppress("DEPRECATION") // AutoMirrored variant not available in current icons version
- Icons.Rounded.BatteryUnknown,
- "Telemetry",
- Modifier.size(20.dp),
- MaterialTheme.colorScheme.secondary,
- )
- }
- IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) {
- Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary)
- }
- IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) {
- Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary)
- }
- IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) {
- Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline)
- }
- IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) {
- Icon(
- Icons.Rounded.SignalCellularAlt,
- "Conn Status",
- Modifier.size(20.dp),
- MaterialTheme.colorScheme.outline,
- )
- }
- if (isOnline) {
- Icon(
- imageVector = Icons.Rounded.Router,
- contentDescription = "Online",
- tint = androidx.compose.ui.graphics.Color.Green.copy(alpha = 0.5f),
- modifier = Modifier.padding(start = 8.dp).size(20.dp),
- )
- }
- }
-}
-
-@Composable
-private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) {
- val scope = rememberCoroutineScope()
- TitledCard(title = "Device Controls") {
- Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
- GpsButtons(viewModel, snackbarHostState)
- Button(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- viewModel.rebootLocalDevice()
- scope.launch { snackbarHostState.showSnackbar("Reboot Requested") }
- },
- shape = MaterialTheme.shapes.medium,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer,
- ),
- ) {
- Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null)
- Spacer(modifier = Modifier.width(8.dp))
- Text("Reboot Radio")
- }
- }
- }
-}
-
-@Composable
-private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) {
- val scope = rememberCoroutineScope()
- val colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer,
- contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
- )
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- modifier = Modifier.weight(1f),
- onClick = {
- viewModel.startProvideLocation()
- scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") }
- },
- shape = MaterialTheme.shapes.medium,
- colors = colors,
- ) {
- Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null)
- Spacer(modifier = Modifier.width(8.dp))
- Text("Start GPS", style = MaterialTheme.typography.labelLarge)
- }
- Button(
- modifier = Modifier.weight(1f),
- onClick = {
- viewModel.stopProvideLocation()
- scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") }
- },
- shape = MaterialTheme.shapes.medium,
- colors = colors,
- ) {
- Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null)
- Spacer(modifier = Modifier.width(8.dp))
- Text("Stop GPS", style = MaterialTheme.typography.labelLarge)
- }
- }
-}
diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt
deleted file mode 100644
index 7c72516bf..000000000
--- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt
+++ /dev/null
@@ -1,363 +0,0 @@
-/*
- * 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 .
- */
-@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding
-
-package com.meshtastic.android.meshserviceexample
-
-import android.content.Intent
-import android.os.Build
-import android.os.Parcelable
-import android.os.RemoteException
-import android.util.Log
-import androidx.lifecycle.ViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.MessageStatus
-import org.meshtastic.core.model.MyNodeInfo
-import org.meshtastic.core.model.NodeInfo
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.service.IMeshService
-import org.meshtastic.proto.PortNum
-import java.text.SimpleDateFormat
-import java.util.Locale
-import kotlin.random.Random
-
-private const val TAG = "MeshServiceViewModel"
-
-/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */
-@Suppress("TooManyFunctions")
-class MeshServiceViewModel : ViewModel() {
-
- private var meshService: IMeshService? = null
-
- private val _myNodeInfo = MutableStateFlow(null)
- val myNodeInfo: StateFlow = _myNodeInfo.asStateFlow()
-
- private val _myId = MutableStateFlow(null)
- val myId: StateFlow = _myId.asStateFlow()
-
- private val _nodes = MutableStateFlow>(emptyList())
- val nodes: StateFlow> = _nodes.asStateFlow()
-
- private val _serviceConnectionStatus = MutableStateFlow(false)
- val serviceConnectionStatus: StateFlow = _serviceConnectionStatus.asStateFlow()
-
- private val _message = MutableStateFlow("")
- val message: StateFlow = _message.asStateFlow()
-
- private val _connectionState = MutableStateFlow("UNKNOWN")
- val connectionState: StateFlow = _connectionState.asStateFlow()
-
- private val _packetLog = MutableStateFlow>(emptyList())
- val packetLog: StateFlow> = _packetLog.asStateFlow()
-
- fun onServiceConnected(service: IMeshService?) {
- meshService = service
- _serviceConnectionStatus.value = true
- updateAllData()
- addToLog("Service Connected")
- }
-
- fun onServiceDisconnected() {
- meshService = null
- _serviceConnectionStatus.value = false
- addToLog("Service Disconnected")
- }
-
- private fun updateAllData() {
- requestMyNodeInfo()
- requestNodes()
- updateConnectionState()
- updateMyId()
- }
-
- fun updateMyId() {
- meshService?.let {
- try {
- _myId.value = it.myId
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to get MyId", e)
- }
- }
- }
-
- fun updateConnectionState() {
- meshService?.let {
- try {
- val state = it.connectionState() ?: "UNKNOWN"
- _connectionState.value = state
- addToLog("Connection State: $state")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to get connection state", e)
- }
- }
- }
-
- fun sendMessage(text: String) {
- meshService?.let { service ->
- try {
- val packet =
- DataPacket(
- to = DataPacket.ID_BROADCAST,
- bytes = text.encodeToByteArray().toByteString(),
- dataType = PortNum.TEXT_MESSAGE_APP.value,
- from = DataPacket.ID_LOCAL,
- time = nowMillis,
- id = service.packetId,
- status = MessageStatus.UNKNOWN,
- hopLimit = 3,
- channel = 0,
- wantAck = true,
- )
- service.send(packet)
- Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}")
- addToLog("Sent: $text (ID: ${packet.id})")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to send message", e)
- addToLog("Failed to send message: ${e.message}")
- }
- } ?: Log.w(TAG, "MeshService is not bound, cannot send message")
- }
-
- fun sendSpecialPacket(portNum: PortNum) {
- meshService?.let { service ->
- try {
- val packet =
- DataPacket(
- to = DataPacket.ID_BROADCAST,
- bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(),
- dataType = portNum.value,
- from = DataPacket.ID_LOCAL,
- time = nowMillis,
- id = service.packetId,
- status = MessageStatus.UNKNOWN,
- hopLimit = 3,
- channel = 0,
- wantAck = true,
- )
- service.send(packet)
- addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to send special packet", e)
- addToLog("Failed to send ${portNum.name} packet: ${e.message}")
- }
- }
- }
-
- fun requestMyNodeInfo() {
- meshService?.let {
- try {
- _myNodeInfo.value = it.myNodeInfo
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to get MyNodeInfo", e)
- }
- }
- }
-
- fun requestNodes() {
- meshService?.let {
- try {
- _nodes.value = it.nodes ?: emptyList()
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to get nodes", e)
- }
- }
- }
-
- fun startProvideLocation() {
- try {
- meshService?.startProvideLocation()
- addToLog("Started GPS sharing")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to start providing location", e)
- }
- }
-
- fun stopProvideLocation() {
- try {
- meshService?.stopProvideLocation()
- addToLog("Stopped GPS sharing")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to stop providing location", e)
- }
- }
-
- fun requestTraceroute(nodeNum: Int) {
- meshService?.let {
- try {
- it.requestTraceroute(Random.nextInt(), nodeNum)
- Log.i(TAG, "Traceroute requested for node $nodeNum")
- addToLog("Requested Traceroute for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request traceroute", e)
- }
- }
- }
-
- fun requestTelemetry(nodeNum: Int) {
- meshService?.let {
- try {
- it.requestTelemetry(Random.nextInt(), nodeNum, 1)
- Log.i(TAG, "Telemetry requested for node $nodeNum")
- addToLog("Requested Telemetry for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request telemetry", e)
- }
- }
- }
-
- fun requestNeighborInfo(nodeNum: Int) {
- meshService?.let {
- try {
- it.requestNeighborInfo(Random.nextInt(), nodeNum)
- Log.i(TAG, "Neighbor info requested for node $nodeNum")
- addToLog("Requested Neighbors for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request neighbor info", e)
- }
- }
- }
-
- fun requestPosition(nodeNum: Int) {
- meshService?.let {
- try {
- it.requestPosition(nodeNum, Position(0.0, 0.0, 0))
- Log.i(TAG, "Position requested for node $nodeNum")
- addToLog("Requested Position for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request position", e)
- }
- }
- }
-
- fun requestUserInfo(nodeNum: Int) {
- meshService?.let {
- try {
- it.requestUserInfo(nodeNum)
- Log.i(TAG, "User info requested for node $nodeNum")
- addToLog("Requested User Info for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request user info", e)
- }
- }
- }
-
- fun requestDeviceConnectionStatus(nodeNum: Int) {
- meshService?.let {
- try {
- it.getDeviceConnectionStatus(Random.nextInt(), nodeNum)
- Log.i(TAG, "Device connection status requested for node $nodeNum")
- addToLog("Requested Connection Status for $nodeNum")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request device connection status", e)
- }
- }
- }
-
- fun rebootLocalDevice() {
- meshService?.let {
- try {
- it.requestReboot(Random.nextInt(), 0)
- Log.w(TAG, "Local reboot requested!")
- addToLog("Requested Local Reboot")
- } catch (e: RemoteException) {
- Log.e(TAG, "Failed to request reboot", e)
- }
- }
- }
-
- fun handleIncomingIntent(intent: Intent) {
- val action = intent.action ?: return
- Log.d(TAG, "Received broadcast: $action")
-
- when (action) {
- "com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent)
- "com.geeksville.mesh.CONNECTION_CHANGED",
- "com.geeksville.mesh.MESH_CONNECTED",
- "com.geeksville.mesh.MESH_DISCONNECTED",
- -> updateConnectionState()
-
- "com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent)
- else ->
- if (action.startsWith("com.geeksville.mesh.RECEIVED.")) {
- handleReceivedPacket(action, intent)
- }
- }
- }
-
- private fun handleNodeChange(intent: Intent) {
- val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java)
- nodeInfo?.let { ni ->
- Log.d(TAG, "Node updated: ${ni.num}")
- _nodes.value =
- _nodes.value.toMutableList().apply {
- val index = indexOfFirst { it.num == ni.num }
- if (index != -1) set(index, ni) else add(ni)
- }
- }
- }
-
- private fun handleMessageStatus(intent: Intent) {
- val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0)
- val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java)
- Log.d(TAG, "Message Status for ID $id: $status")
- addToLog("Msg Status ID $id: $status")
- }
-
- private fun handleReceivedPacket(action: String, intent: Intent) {
- val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java)
- if (packet == null) {
- Log.e(TAG, "Received packet extra was NULL for action: $action")
- addToLog("Error: Packet payload was null for $action")
- return
- }
-
- Log.d(TAG, "Packet received: $packet")
-
- if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) {
- val receivedText = packet.bytes?.utf8() ?: ""
- _message.value = "From ${packet.from}: $receivedText"
- addToLog("Received Text from ${packet.from}: $receivedText")
- } else {
- val type = action.substringAfterLast(".")
- addToLog("Received $type from ${packet.from}. Check Logcat for details.")
- }
- }
-
- private fun addToLog(entry: String) {
- val date = nowMillis.toInstant().toDate()
- val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date)
- val logEntry = "[$timestamp] $entry"
- Log.d(TAG, "Log: $logEntry")
- @Suppress("MagicNumber")
- _packetLog.value = (listOf(logEntry) + _packetLog.value).take(50)
- }
-
- private fun Intent.getParcelableCompat(key: String, clazz: Class): T? =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- getParcelableExtra(key, clazz)
- } else {
- @Suppress("DEPRECATION")
- getParcelableExtra(key)
- }
-}
diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml
deleted file mode 100644
index 07d5da9cb..000000000
--- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml
deleted file mode 100644
index 2b068d114..000000000
--- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml
deleted file mode 100644
index 6f3b755bf..000000000
--- a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-be-rBY/strings.xml b/mesh_service_example/src/main/res/values-be-rBY/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-be-rBY/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml
deleted file mode 100644
index bebf8fbdd..000000000
--- a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Изпратете съобщение за здравей
-
diff --git a/mesh_service_example/src/main/res/values-ca-rES/strings.xml b/mesh_service_example/src/main/res/values-ca-rES/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ca-rES/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-de-rDE/strings.xml b/mesh_service_example/src/main/res/values-de-rDE/strings.xml
deleted file mode 100644
index 968230ec2..000000000
--- a/mesh_service_example/src/main/res/values-de-rDE/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- Beispiel MeshService
- Hallo Nachricht senden
-
diff --git a/mesh_service_example/src/main/res/values-el-rGR/strings.xml b/mesh_service_example/src/main/res/values-el-rGR/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-el-rGR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-es-rES/strings.xml b/mesh_service_example/src/main/res/values-es-rES/strings.xml
deleted file mode 100644
index 8abd298f5..000000000
--- a/mesh_service_example/src/main/res/values-es-rES/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- Ejemplo de servicio de red
- Enviar Mensaje Hola
-
diff --git a/mesh_service_example/src/main/res/values-et-rEE/strings.xml b/mesh_service_example/src/main/res/values-et-rEE/strings.xml
deleted file mode 100644
index dd6ff8304..000000000
--- a/mesh_service_example/src/main/res/values-et-rEE/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceNäidis
- Saada Tere sõnum
-
diff --git a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml
deleted file mode 100644
index 2da506dda..000000000
--- a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExamplebled
- Lähetä tervehdysviesti
-
diff --git a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml
deleted file mode 100644
index 2b9ff6e40..000000000
--- a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- Exemple de service de maillage
- Envoyer un message d’annonce
-
diff --git a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-gl-rES/strings.xml b/mesh_service_example/src/main/res/values-gl-rES/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-gl-rES/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml
deleted file mode 100644
index 1cff8d920..000000000
--- a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Hello Üzenet Küldés
-
diff --git a/mesh_service_example/src/main/res/values-is-rIS/strings.xml b/mesh_service_example/src/main/res/values-is-rIS/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-is-rIS/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-it-rIT/strings.xml b/mesh_service_example/src/main/res/values-it-rIT/strings.xml
deleted file mode 100644
index dd7addd1d..000000000
--- a/mesh_service_example/src/main/res/values-it-rIT/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Invia Messaggio di Saluto
-
diff --git a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-no-rNO/strings.xml b/mesh_service_example/src/main/res/values-no-rNO/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-no-rNO/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml
deleted file mode 100644
index 4e232be75..000000000
--- a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- ExemploServiçoMesh
- Enviar Mensagem de Olá
-
diff --git a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml
deleted file mode 100644
index ba088c7e3..000000000
--- a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Отправить приветственное сообщение
-
diff --git a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-srp/strings.xml b/mesh_service_example/src/main/res/values-srp/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-srp/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml
deleted file mode 100644
index f9271ce44..000000000
--- a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- Mesh-service exempel
- Skicka Hej-meddelande
-
diff --git a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml
deleted file mode 100644
index 37d7a2bb2..000000000
--- a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Надіслати привітальне повідомлення
-
diff --git a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml
deleted file mode 100644
index 30fbd6de5..000000000
--- a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- MeshServiceExample
- Send Hello Message
-
diff --git a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml
deleted file mode 100644
index 16c04c5d3..000000000
--- a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- Mesh 服務範例
- 發送打招呼訊息
-
diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml
deleted file mode 100644
index a6b3daec9..000000000
--- a/mesh_service_example/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/values/strings.xml b/mesh_service_example/src/main/res/values/strings.xml
deleted file mode 100644
index e194d4b9b..000000000
--- a/mesh_service_example/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
- MeshServiceExample
-
diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml
deleted file mode 100644
index e8f8fe799..000000000
--- a/mesh_service_example/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/xml/backup_rules.xml b/mesh_service_example/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index 4df925582..000000000
--- a/mesh_service_example/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/mesh_service_example/src/main/res/xml/data_extraction_rules.xml b/mesh_service_example/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997b0..000000000
--- a/mesh_service_example/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 656d6f831..f9664baaa 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -47,7 +47,6 @@ include(
":feature:firmware",
":feature:wifi-provision",
":feature:widget",
- ":mesh_service_example",
":desktop",
)
rootProject.name = "MeshtasticAndroid"
From 60ff495037465601c37851eec60c5fa22f51662d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 22:26:39 -0500
Subject: [PATCH 17/62] chore(r8): clean up ProGuard rules and enable Compose
Hot Reload (#5139)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
app/build.gradle.kts | 2 --
app/proguard-rules.pro | 14 ++++----------
desktop/proguard-rules.pro | 6 ------
gradle.properties | 1 +
4 files changed, 5 insertions(+), 18 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 39e6bbcc7..c0d16a86b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -171,8 +171,6 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
- isMinifyEnabled = true
- isShrinkResources = true
isDebuggable = false
}
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index f504e7bb6..190d9b891 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -13,6 +13,10 @@
# Open-source — no need to obfuscate
-dontobfuscate
+# Dump the full merged R8 configuration (app rules + all library consumer rules)
+# for auditing. Inspect this file after a release build to see what libraries inject.
+-printconfiguration build/outputs/mapping/r8-merged-config.txt
+
# ---- Networking (transitive references from Ktor) ---------------------------
-dontwarn org.conscrypt.**
@@ -43,13 +47,3 @@
# R8 exception-class merging.
-keep class org.jetbrains.compose.resources.** { *; }
-keep class org.meshtastic.core.resources.** { *; }
-
-# Compose Animation: prevent R8 from merging animation spec classes (easing
-# curves, transition specs, Animatable internals) which can cause animations to
-# silently snap in release builds.
-#
-# We use a full -keep here without allowshrinking/allowobfuscation. While it
-# might keep some unused transition APIs, R8's aggressive shrinking is known
-# to incorrectly remove internal states or merging empty transitions (like None)
-# causing AnimatedVisibility and others to snap.
--keep class androidx.compose.animation.** { *; }
diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro
index 3a074d9ac..ef1576555 100644
--- a/desktop/proguard-rules.pro
+++ b/desktop/proguard-rules.pro
@@ -147,12 +147,6 @@
-keep class org.jetbrains.compose.resources.** { *; }
-keep class org.meshtastic.core.resources.** { *; }
-# ---- Compose Animation (anti-merge) ----------------------------------------
-
-# Prevent ProGuard from merging animation spec class hierarchies (same issue
-# as R8 on Android). We use a full keep to prevent incorrect tree-shaking
-# of internal transitions.
--keep class androidx.compose.animation.** { *; }
# ---- AboutLibraries ---------------------------------------------------------
diff --git a/gradle.properties b/gradle.properties
index 8e67ce164..2f265135a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -29,3 +29,4 @@ org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDu
org.gradle.parallel=true
org.gradle.vfs.watch=true
org.gradle.welcome=never
+compose.hot.reload=true
From 96419f3251760efde8523665b25df4ae38555ebe Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 06:07:21 -0500
Subject: [PATCH 18/62] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5140)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../composeResources/values-bg/strings.xml | 4 +---
.../composeResources/values-cs/strings.xml | 2 ++
.../composeResources/values-de/strings.xml | 6 +++---
.../composeResources/values-et/strings.xml | 12 +++++++++---
.../composeResources/values-fi/strings.xml | 5 ++---
.../composeResources/values-fr/strings.xml | 1 +
.../composeResources/values-it/strings.xml | 2 ++
.../composeResources/values-ja/strings.xml | 1 +
.../composeResources/values-pl/strings.xml | 1 +
.../composeResources/values-ro/strings.xml | 4 +---
.../composeResources/values-ru/strings.xml | 12 +++++++++---
.../composeResources/values-sr/strings.xml | 1 +
.../composeResources/values-srp/strings.xml | 1 +
.../composeResources/values-zh-rCN/strings.xml | 5 ++---
.../composeResources/values-zh-rTW/strings.xml | 3 +++
15 files changed, 39 insertions(+), 21 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 14fc7aae5..e12d5506c 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -301,9 +301,7 @@
Батерия
Използване на канала
Използване на ефира
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s
%1$s: %2$s
записа
Брой отскоци
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index 868a84993..a0ccafea5 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -216,6 +216,7 @@
Tmavý
Podle systému
Vyberte vzhled
+ Vysoká
Poskytnout polohu síti
Úsporné kódování pro cyriliku
@@ -312,6 +313,7 @@
Baterie
ChUtil
AirUtil
+ %1$s
%1$s: %2$s
Teplota
Vlhkost
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 01c1aaa2a..4657dc925 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -257,6 +257,8 @@
Dunkel
System
Design auswählen
+ Medium Fast
+ Hoch
Standort zum Mesh angeben
Kompakte Kodierung für Kyrillisch
@@ -354,9 +356,7 @@
Akku
Kanalauslastung
Sendezeit
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s
%1$s: %2$s
Temperatur
Feuchtigkeit
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 6f5a7fa4d..651eb5d2f 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -253,10 +253,15 @@
Taasta vaikesätted
Rakenda
Teema
+ Kontrastsus
Hele
Tume
Süsteemi vaikesäte
Vali teema
+ Kontrastsuse tase
+ Standard
+ Keskmine
+ Kõrge
Jaga telefoni asukohta mesh-võrku
Kompaktne kodeering kirillitsa jaoks
@@ -354,9 +359,9 @@
Aku
Kanali kasutus
Saate kasutus
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s
%1$s: %2$s
Temperatuur
Niiskus
@@ -723,6 +728,7 @@
Vihm (24h)
Kaal
Radiatsioon
+ 1-juhtmeline temperatuur
Siseõhu kvaliteet (IAQ)
URL
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 504b821b2..4b91c49b5 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -354,9 +354,7 @@
Akku
Kanavan käyttöaste
Lähetysajan käyttöaste
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s
%1$s: %2$s
Lämpötila
Kosteus
@@ -723,6 +721,7 @@
Sademäärä (24 h)
Paino
Säteily
+ Lämpötila (1-Wire)
Sisäilmanlaatu (IAQ)
URL-osoite
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 16da56ad7..af88601ba 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -326,6 +326,7 @@
Batterie
UtilCanal
UtilAir
+ %1$s
Temp
Hum
Temp sol
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 406626027..350db6cb2 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -239,6 +239,8 @@
Scuro
Predefinito di sistema
Scegli tema
+ Medium
+ Alto
Fornire la posizione alla mesh
Codifica compatta per cirillico
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 5b53fd292..490a0a2ec 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -263,6 +263,7 @@
WiFi認証のQRコードの形式が無効です
前に戻る
バッテリー
+ %1$s
ログ
ホップ数
情報
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 64f32551d..394a20bd3 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -223,6 +223,7 @@
Ciemny
Domyślne ustawienie systemowe
Wybierz motyw
+ Standardowy
Podaj lokalizację telefonu do sieci
- Usunąć wiadomość?
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index ff5de3636..7cf64363b 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -354,9 +354,7 @@
Baterie
ChUtil
AirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s
%1$s:%2$s
Temp
Hum
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index ef0e89a45..fd964e294 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -257,10 +257,15 @@
Сброс значений по умолчанию
Применить
Тема
+ Контрастность
Светлая
Темная
По умолчанию
Выберите тему
+ Уровень контрастности
+ Стандартный
+ Средний
+ Высокий
Предоставление местоположения для сети
Компактная кодировка кириллицы
@@ -360,9 +365,9 @@
Батарея
ChUtil
AirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f В
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s В
+ %1$s
%1$s: %2$s
Темп
Влажн
@@ -731,6 +736,7 @@
Дождь (24ч)
Вес
Радиация
+ Темп. 1-Wire
Качество воздуха в помещении (IAQ)
URL-адрес
diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
index 29b856819..21a9c14c1 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -151,6 +151,7 @@
Тамна
Прати систем
Одабери тему
+ Стандардно
Обезбедите локацију телефона меш мрежи
- Обриши поруку?
diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
index 13135d394..516a963f4 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -151,6 +151,7 @@
Тамна
Прати систем
Одабери тему
+ Стандардно
Обезбедите локацију телефона меш мрежи
- Обриши поруку?
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index bfb4e6fc0..9d6e0fbf6 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -243,6 +243,7 @@
深色
系统默认设置
选择主题
+ 标准
向网格提供手机位置
紧凑的Cyrillic编码
@@ -339,9 +340,7 @@
电池
ChUtil
AirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s
%1$s: %2$s
温度
湿度
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index b4d05cfdb..030532869 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -229,6 +229,8 @@
深色
系統預設
選擇主題
+ 中等
+ 高
將手機位置提供給Mesh網路
使用同形異意字元編碼處理西里爾字母
@@ -323,6 +325,7 @@
電池
頻道利用率
空中時間使用率
+ %1$s
溫度
濕度
土壤溫度
From 84621acb04046ac178fcb0e70c5712d21d457d05 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 06:55:15 -0500
Subject: [PATCH 19/62] fix: align BLE connection handshake with firmware
protocol expectations (#5141)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../data/manager/DataLayerHeartbeatSender.kt | 54 +++++++++++++++++++
.../data/manager/MeshConfigFlowManagerImpl.kt | 48 ++++++++---------
.../data/manager/MeshConnectionManagerImpl.kt | 27 +++++++++-
.../manager/MeshConfigFlowManagerImplTest.kt | 46 +++++++++++++++-
.../manager/MeshConnectionManagerImplTest.kt | 54 +++++++++++++++++++
5 files changed, 199 insertions(+), 30 deletions(-)
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt
new file mode 100644
index 000000000..6ca10df26
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt
@@ -0,0 +1,54 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.proto.Heartbeat
+import org.meshtastic.proto.ToRadio
+
+/**
+ * Centralized heartbeat sender for the data layer.
+ *
+ * Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
+ * per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
+ *
+ * This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
+ * with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
+ */
+@Single
+class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
+ private val nonce = atomic(0)
+
+ /**
+ * Enqueues a heartbeat with a unique nonce.
+ *
+ * @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
+ */
+ @Suppress("TooGenericExceptionCaught")
+ fun sendHeartbeat(tag: String = "handshake") {
+ try {
+ val n = nonce.incrementAndGet()
+ packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
+ Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
+ }
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index b7b27aa4e..cc5cc4319 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -20,18 +20,17 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
-import okio.IOException
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -39,9 +38,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
-import org.meshtastic.proto.ToRadio
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@@ -56,7 +53,7 @@ class MeshConfigFlowManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
- private val packetHandler: PacketHandler,
+ private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigFlowManager {
private val wantConfigDelay = 100L
@@ -90,10 +87,8 @@ class MeshConfigFlowManagerImpl(
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
- data class ReceivingNodeInfo(
- val myNodeInfo: SharedMyNodeInfo,
- val nodes: MutableList = mutableListOf(),
- ) : HandshakeState()
+ data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) :
+ HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
@@ -139,28 +134,31 @@ class MeshConfigFlowManagerImpl(
return
}
+ // Warn if firmware is below the absolute minimum supported version.
+ // The UI layer already enforces this via FirmwareVersionCheck, so we just log here
+ // for diagnostics rather than hard-disconnecting.
+ finalizedInfo.firmwareVersion?.let { fwVersion ->
+ if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
+ Logger.w {
+ "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " +
+ "protocol incompatibilities may occur"
+ }
+ }
+ }
+
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
- sendHeartbeat()
+ heartbeatSender.sendHeartbeat("inter-stage")
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.value.startNodeInfoOnly()
}
}
- private fun sendHeartbeat() {
- try {
- packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
- Logger.d { "Heartbeat sent between nonce stages" }
- } catch (ex: IOException) {
- Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
- }
- }
-
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Logger.i { "NodeInfo complete (Stage 2)" }
@@ -168,16 +166,12 @@ class MeshConfigFlowManagerImpl(
// Transition state immediately (synchronously) to prevent duplicate handling.
// The async work below (DB writes, broadcasts) proceeds without the guard.
+ // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
+ // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
- // Snapshot and clear immediately so that a concurrent stall-guard retry (which
- // resends want_config_id and causes the firmware to restart the node_info burst)
- // starts accumulating into a fresh list rather than doubling this batch.
- val nodesToProcess = state.nodes.toList()
- state.nodes.clear()
-
val entities =
- nodesToProcess.mapNotNull { nodeInfo ->
+ state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
@@ -242,7 +236,7 @@ class MeshConfigFlowManagerImpl(
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
- state.nodes.add(info)
+ handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index 94b405953..a60dc85c5 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -84,6 +84,7 @@ class MeshConnectionManagerImpl(
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
+ private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
/**
@@ -92,6 +93,7 @@ class MeshConnectionManagerImpl(
*/
private val connectionMutex = Mutex()
+ private var preHandshakeJob: Job? = null
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
@@ -172,6 +174,8 @@ class MeshConnectionManagerImpl(
sleepTimeout?.cancel()
sleepTimeout = null
+ preHandshakeJob?.cancel()
+ preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -192,9 +196,19 @@ class MeshConnectionManagerImpl(
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
- Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
- startConfigOnly()
+
+ // Send a wake-up heartbeat before the config request. The firmware may be in a
+ // power-saving state where the NimBLE callback context needs warming up. The 100ms
+ // delay ensures the heartbeat BLE write is enqueued before the want_config_id
+ // (sendToRadio is fire-and-forget through async coroutine launches).
+ preHandshakeJob =
+ scope.handledLaunch {
+ heartbeatSender.sendHeartbeat("pre-handshake")
+ delay(PRE_HANDSHAKE_SETTLE_MS)
+ Logger.i { "Starting mesh handshake (Stage 1)" }
+ startConfigOnly()
+ }
}
private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
@@ -381,6 +395,15 @@ class MeshConnectionManagerImpl(
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
+ /**
+ * Delay between the pre-handshake heartbeat and the want_config_id send.
+ *
+ * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
+ * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
+ * negligible connection latency.
+ */
+ private const val PRE_HANDSHAKE_SETTLE_MS = 100L
+
private val HANDSHAKE_TIMEOUT = 30.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
index e05c6f20a..fdcd8ed44 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
@@ -97,7 +98,7 @@ class MeshConfigFlowManagerImplTest {
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
- packetHandler = packetHandler,
+ heartbeatSender = DataLayerHeartbeatSender(packetHandler),
scope = testScope,
)
}
@@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest {
verify { connectionManager.startNodeInfoOnly() }
}
+ @Test
+ fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(metadata)
+ advanceUntilIdle()
+
+ sentPackets.clear() // Clear any packets from prior phases
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ val heartbeats = sentPackets.filter { it.heartbeat != null }
+ assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
+ assertEquals(
+ true,
+ heartbeats[0].heartbeat!!.nonce != 0,
+ "Inter-stage heartbeat should have a non-zero nonce",
+ )
+ }
+
+ @Test
+ fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
+ val oldMetadata =
+ DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(oldMetadata)
+ advanceUntilIdle()
+
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ // Handshake should still progress despite old firmware
+ verify { connectionManager.onRadioConfigLoaded() }
+ verify { connectionManager.startNodeInfoOnly() }
+ }
+
@Test
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index c6dfa7f43..07c8914ad 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -129,6 +129,7 @@ class MeshConnectionManagerImplTest {
packetRepository,
workerManager,
appWidgetUpdater,
+ DataLayerHeartbeatSender(packetHandler),
scope,
)
@@ -148,6 +149,59 @@ class MeshConnectionManagerImplTest {
verify { serviceBroadcasts.broadcastConnection() }
}
+ @Test
+ fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
+ advanceTimeBy(200)
+
+ // First ToRadio should be a heartbeat, second should be want_config_id
+ assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
+ val heartbeat = sentPackets[0]
+ val wantConfig = sentPackets[1]
+
+ assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
+ assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
+ assertEquals(
+ org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
+ wantConfig.want_config_id,
+ "Second packet should be want_config_id with CONFIG_NONCE",
+ )
+ }
+
+ @Test
+ fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+ every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance only 50ms — within the 100ms settle window
+ advanceTimeBy(50)
+
+ // Should have sent only the heartbeat so far, not want_config_id
+ assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
+
+ // Disconnect before the settle delay completes — should cancel the pending config start
+ radioConnectionState.value = ConnectionState.Disconnected
+ advanceTimeBy(200)
+
+ // The want_config_id should NOT have been sent because the job was cancelled
+ val configPackets = sentPackets.filter { it.want_config_id != null }
+ assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
+ }
+
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
From d0057752f69f34e7ade73e7c3b7adbda94b0e11f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 07:23:20 -0500
Subject: [PATCH 20/62] fix(ci): remove Renovate groupings and decouple
AndroidX Compose version ref (#5143)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/renovate.json | 227 +-------------------------------------
gradle/libs.versions.toml | 8 +-
2 files changed, 9 insertions(+), 226 deletions(-)
diff --git a/.github/renovate.json b/.github/renovate.json
index e08e3d2f3..dda9390c3 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -49,236 +49,15 @@
"automerge": true
},
{
+ "description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [
"https://github.com/meshtastic/protobufs.git"
],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
- "groupName": "Meshtastic Protobufs",
- "groupSlug": "meshtastic-protobufs",
"automerge": true
},
{
- "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
- "groupName": "AndroidX (General)",
- "groupSlug": "androidx-general",
- "matchPackageNames": [
- "/^androidx\\./",
- "!/^androidx\\.room/",
- "!/^androidx\\.lifecycle/",
- "!/^androidx\\.navigation/",
- "!/^androidx\\.datastore/",
- "!/^androidx\\.compose\\.material3\\.adaptive/",
- "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
- "!/^androidx\\.test\\.espresso/",
- "!/^androidx\\.test\\.ext/",
- "!/^androidx\\.hilt/"
- ]
- },
- {
- "description": "Group JetBrains Compose Multiplatform plugin and libraries (separate versioning from AndroidX Compose)",
- "groupName": "Compose Multiplatform (JetBrains)",
- "groupSlug": "compose-multiplatform",
- "matchPackageNames": [
- "/^org\\.jetbrains\\.compose/"
- ]
- },
- {
- "description": "Group Kotlin standard library, coroutines, and serialization",
- "groupName": "Kotlin Ecosystem",
- "groupSlug": "kotlin",
- "matchPackageNames": [
- "/^org\\.jetbrains\\.kotlin/",
- "/^org\\.jetbrains\\.kotlinx/"
- ]
- },
- {
- "description": "Group Dagger and Hilt dependencies",
- "groupName": "Dagger & Hilt",
- "groupSlug": "hilt",
- "matchPackageNames": [
- "/^com\\.google\\.dagger/",
- "/^androidx\\.hilt/"
- ]
- },
- {
- "description": "Group Accompanist libraries",
- "groupName": "Accompanist",
- "groupSlug": "accompanist",
- "matchPackageNames": [
- "/^com\\.google\\.accompanist/"
- ]
- },
- {
- "description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
- "groupName": "JVM Testing Libraries",
- "groupSlug": "jvm-testing",
- "matchPackageNames": [
- "/^junit:junit$/",
- "/^org\\.mockito:/",
- "/^org\\.robolectric:robolectric$/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Testing libraries",
- "groupName": "AndroidX Testing",
- "groupSlug": "androidx-testing",
- "matchPackageNames": [
- "/^androidx\\.test\\.espresso/",
- "/^androidx\\.test\\.ext/"
- ],
- "automerge": true
- },
- {
- "description": "Group Static Analysis tools (Detekt, Spotless)",
- "groupName": "Static Analysis",
- "groupSlug": "static-analysis",
- "matchPackageNames": [
- "/^io\\.gitlab\\.arturbosch\\.detekt/",
- "/^io\\.nlopez\\.compose\\.rules/",
- "/^com\\.diffplug\\.spotless/"
- ],
- "automerge": true
- },
- {
- "description": "Group Square networking libraries (OkHttp, Retrofit)",
- "groupName": "Square Networking",
- "groupSlug": "square-network",
- "matchPackageNames": [
- "/^com\\.squareup\\.okhttp3/",
- "/^com\\.squareup\\.retrofit2/"
- ],
- "automerge": true
- },
- {
- "description": "Group Coil image loading library",
- "groupName": "Coil",
- "groupSlug": "coil",
- "matchPackageNames": [
- "/^io\\.coil-kt\\.coil3/"
- ],
- "automerge": true
- },
- {
- "description": "Group ZXing barcode scanning libraries",
- "groupName": "ZXing",
- "groupSlug": "zxing",
- "matchPackageNames": [
- "/^com\\.journeyapps:zxing-android-embedded/",
- "/^com\\.google\\.zxing:core/"
- ],
- "automerge": true
- },
- {
- "description": "Group Eclipse Paho MQTT client libraries",
- "groupName": "MQTT Paho Client",
- "groupSlug": "mqtt-paho",
- "matchPackageNames": [
- "/^org\\.eclipse\\.paho/"
- ],
- "automerge": true
- },
- {
- "description": "Group Mike Penz Markdown renderer libraries",
- "groupName": "Markdown Renderer (Mike Penz)",
- "groupSlug": "markdown-renderer-mikepenz",
- "matchPackageNames": [
- "/^com\\.mikepenz/"
- ],
- "automerge": true
- },
- {
- "description": "Group Firebase libraries",
- "groupName": "Firebase",
- "groupSlug": "firebase",
- "matchPackageNames": [
- "/^com\\.google\\.firebase/"
- ],
- "automerge": true
- },
- {
- "description": "Group Datadog libraries",
- "groupName": "Datadog",
- "groupSlug": "datadog",
- "matchPackageNames": [
- "/^com\\.datadoghq/"
- ],
- "automerge": true
- },
- {
- "description": "Group OpenStreetMap (OSM) libraries",
- "groupName": "OSM Libraries",
- "groupSlug": "osm-libraries",
- "matchPackageNames": [
- "/^org\\.osmdroid/",
- "/^com\\.github\\.MKergall\\.osmbonuspack/",
- "/^mil\\.nga/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Maps Compose libraries",
- "groupName": "Google Maps Compose",
- "groupSlug": "google-maps-compose",
- "matchPackageNames": [
- "/^com\\.google\\.android\\.gms:play-services-location/",
- "/^com\\.google\\.maps\\.android/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Protobuf runtime libraries",
- "groupName": "Protobuf Runtime",
- "groupSlug": "protobuf-runtime",
- "matchPackageNames": [
- "/^com\\.google\\.protobuf/",
- "!https://github.com/meshtastic/protobufs.git"
- ]
- },
- {
- "description": "Group AndroidX Room libraries",
- "groupName": "AndroidX Room",
- "groupSlug": "androidx-room",
- "matchPackageNames": [
- "/^androidx\\.room/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Lifecycle libraries",
- "groupName": "AndroidX Lifecycle",
- "groupSlug": "androidx-lifecycle",
- "matchPackageNames": [
- "/^androidx\\.lifecycle/"
- ]
- },
- {
- "description": "Group AndroidX Navigation libraries",
- "groupName": "AndroidX Navigation",
- "groupSlug": "androidx-navigation",
- "matchPackageNames": [
- "/^androidx\\.navigation/"
- ]
- },
- {
- "description": "Group AndroidX DataStore libraries",
- "groupName": "AndroidX DataStore",
- "groupSlug": "androidx-datastore",
- "matchPackageNames": [
- "/^androidx\\.datastore/"
- ]
- },
- {
- "description": "Group AndroidX Adaptive UI libraries",
- "groupName": "AndroidX Adaptive UI",
- "groupSlug": "androidx-adaptive-ui",
- "matchPackageNames": [
- "/^androidx\\.compose\\.material3\\.adaptive/",
- "/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
- ]
- },
- {
- "description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
+ "description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
"minor"
],
@@ -305,4 +84,4 @@
"automerge": false
}
]
-}
\ No newline at end of file
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d1051dc2a..46299b360 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -40,6 +40,10 @@ compose-multiplatform = "1.11.0-beta02"
# CMP-bundled version. Bump this together with compose-multiplatform.
skiko = "0.144.5"
compose-multiplatform-material3 = "1.11.0-alpha06"
+# AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved
+# independently by Maven. Pinning them to their own ref prevents Renovate from bumping the
+# CMP plugin version when a new AndroidX Compose pre-release appears.
+androidx-compose = "1.11.0-beta02"
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-alpha06"
@@ -122,8 +126,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" }
# AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority)
-androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" }
-androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity)
+androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose" }
+androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" } # Required by Robolectric Compose tests (registers ComponentActivity)
# Compose Multiplatform
compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" }
From f72b91328d6af53ad17b020a9242ea27d3007a26 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 15 Apr 2026 07:47:53 -0500
Subject: [PATCH 21/62] chore(deps): update androidx.compose to v1.11.0-rc01
(#5144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 46299b360..4d3c73074 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -43,7 +43,7 @@ compose-multiplatform-material3 = "1.11.0-alpha06"
# AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved
# independently by Maven. Pinning them to their own ref prevents Renovate from bumping the
# CMP plugin version when a new AndroidX Compose pre-release appears.
-androidx-compose = "1.11.0-beta02"
+androidx-compose = "1.11.0-rc01"
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-alpha06"
From c7d2a768513b7d26d7ccf44ce69b5411f689ce4b Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 07:48:12 -0500
Subject: [PATCH 22/62] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5145)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../src/commonMain/composeResources/values-fi/strings.xml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 4b91c49b5..3fdd6afaf 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -253,10 +253,15 @@
Palauta oletusasetukset
Hyväksy
Teema
+ Kontrasti
Vaalea
Tumma
Järjestelmän oletus
Valitse teema
+ Kontrastin taso
+ Normaali
+ Keskitaso
+ Korkea
Jaa puhelimen sijaintitietoa mesh-verkkoon
Kyrillisten merkkien tiivis koodaus
@@ -354,6 +359,8 @@
Akku
Kanavan käyttöaste
Lähetysajan käyttöaste
+ %1$s: %2$s%%
+ %1$s: %2$s V
%1$s
%1$s: %2$s
Lämpötila
From dea364dd172bc3405f7dc76bda7ffb6caf7a695f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 09:30:33 -0500
Subject: [PATCH 23/62] fix(app): add R8 keep rules for Compose
animation/runtime/ui (#5146)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
app/proguard-rules.pro | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 190d9b891..8340fdd10 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -39,6 +39,22 @@
# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable).
-keep class org.koin.core.error.** { *; }
+# ---- Compose Runtime & Animation --------------------------------------------
+
+# R8's optimization passes (bundled with AGP 9.x) can inline and dead-code-
+# eliminate parts of the Compose frame-clock / recomposer / animation state
+# machines, causing every animation to silently freeze on its first frame in
+# release builds — indeterminate progress spinners, crossfade transitions,
+# animateFloatAsState, AnimatedVisibility, etc.
+#
+# The frame clock lives in compose.runtime, the draw loop in compose.ui,
+# and the animation drivers in compose.animation.core. Keep all three so
+# R8 does not break the chain.
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.compose.ui.** { *; }
+-keep class androidx.compose.animation.core.** { *; }
+-keep class androidx.compose.animation.** { *; }
+
# ---- Compose Multiplatform --------------------------------------------------
# Keep resource library internals and generated Res accessor classes so R8 does
From 878905aea3776316a2dbed2e441a2c4a16a8eba4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 15 Apr 2026 10:48:26 -0500
Subject: [PATCH 24/62] perf(messaging): batch node + reply lookups in message
loading (#5149)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../data/repository/PacketRepositoryImpl.kt | 57 +++++++++++++------
.../meshtastic/core/database/dao/PacketDao.kt | 10 ++++
2 files changed, 49 insertions(+), 18 deletions(-)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index 04e09eaf7..a5664b1a0 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
+import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.toReaction
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactSettings
@@ -154,13 +155,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
+ val cachedGetNode = memoize(getNode)
+ val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct()
+ val replyMap = batchGetPacketsByIds(replyIds)
packets.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
}
@@ -177,13 +179,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -204,13 +209,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -230,6 +238,19 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
+ private suspend fun batchGetPacketsByIds(ids: List