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