From 145c256972df4385bdde6161c0e8727fedf82adf Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:22:36 -0600
Subject: [PATCH 001/440] Merge pull request #4668
* Disable generate_release_notes in release workflow
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b745d0850..0cdde8668 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -306,7 +306,7 @@ jobs:
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
tag_name: ${{ inputs.tag_name }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
- generate_release_notes: true
+ generate_release_notes: false
files: ./artifacts/*/*
draft: false
prerelease: true
From 225dc232b67d8561308e2f818a4645230f38afc5 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:25:41 -0600
Subject: [PATCH 002/440] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4667)
---
.../values-zh-rTW/strings.xml | 136 ++++++++++--------
1 file changed, 75 insertions(+), 61 deletions(-)
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 97b7a5632..17a1812e8 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -97,7 +97,7 @@
僅限本地
忽略來自開放的或無法解密的外部 Mesh 觀察到的訊息。僅轉播來自本地節點的主要/次要頻道的訊息。
僅限已知節點
- 近似於 LOCAL_ONLY 角色,將忽略來自外部Mesh節點的訊息,同時也忽略已知節點列表以外節點的訊息。
+ 近似於 LOCAL_ONLY 角色,將忽略來自外部 Mesh 節點的訊息,同時也忽略已知節點列表以外節點的訊息。
無
僅允許 SENSOR、TRACKER 和 TAK_TRACKER 角色,與 CLIENT_MUTE 角色不同,此模式將禁止所有重新廣播行為。
僅轉發基本通訊封包
@@ -121,18 +121,18 @@
可選的預設參數組,預設值是 Long Fast。
設定訊息的最大跳數,預設為 3。注意:增加跳數將導致網路擁塞,建議謹慎使用。此外,0 跳的廣播訊息將不會收到確認 (ACK)。
節點工作頻率是透過地區、預設參數組和此欄位計算的。當設為 0 時,時隙將根據主頻道名稱自動計算,並會與公共預設時隙不同。若同時配置了私人主頻道和公共副頻道,請務必切換回公共預設時隙。
- Very Long Range - Slow
- Long Range - Fast
- 長距離 - 快速模式
- Long Range - Moderate
- Long Range - Slow
- Medium Range - Fast
- Medium Range - Slow
- Short Range - Turbo
- Short Range - Fast
- Short Range - Slow
- 啟用 Wi-Fi 後,藍牙與應用程式的連線將會停用。
- 啟用乙太網路會導致與 App 的藍牙連線中斷。此外,TCP 節點連線在 Apple 設備上不可用。
+ Very Long - Slow
+ Long - Fast
+ Long - Turbo
+ Long - Moderate
+ Long - Slow
+ Medium - Fast
+ Medium - Slow
+ Short - Turbo
+ Short - Fast
+ Short - Slow
+ 啟用 Wi-Fi 後,節點裝置的藍牙連線功能將會停用。
+ 啟用乙太網路後,節點裝置的藍牙連線功能將會停用。此外,TCP 節點連線在 Apple 設備上無法使用。
允許透過本地網路上的 UDP 廣播封包。
位置廣播的最大間隔時間。
滿足最小距離限制時,位置更新的最快發送間隔。
@@ -161,7 +161,7 @@
GPS 傳送腳位
GPS 啟用腳位
腳位
- 调试
+ 除錯
頻道
頻道名稱
QRCODE
@@ -178,7 +178,7 @@
Meshtastic需要啟用定位及藍芽才能尋找新裝置,可以選擇在不使用時停用。
回報BUG
回報問題
- 您確定要報告錯誤嗎?報告後,請在 https://github.com/orgs/meshtastic/discussions 上貼文,以便我們可以將報告與您發現的問題匹配。
+ 您確定要報告錯誤嗎?報告後,請在 https://github.com/orgs/meshtastic/discussions 上貼文,以便我們可以將報告與您發現的問題比對。
報告
配對完成,開始服務
配對失敗,請重新選擇
@@ -189,7 +189,7 @@
設備休眠中
已連接:線上 %1$s
IP地址:
- Ip_ 埠:
+ IP連接埠:
已連線
已連接至設備 (%1$s)
目前連線:
@@ -201,7 +201,7 @@
已連接裝置,但該裝置正在休眠中
需要應用程式更新
您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件。
- 無(停用)
+ 無(停用)
服務通知
致謝
此頻道 URL 無效,無法使用
@@ -263,7 +263,7 @@
系統預設
選擇主題
將手機位置提供給Mesh網路
- 西里爾字母緊湊編碼
+ 使用同形異意字元編碼處理西里爾字母
- 刪除 %1$s 訊息?
@@ -286,12 +286,12 @@
此裝置不支援關機功能
⚠️ 這將會關閉節點。需要實體操作才能重新開啟。
⚠️ 這是關鍵基礎設施節點。請輸入節點名稱以確認:
- 裝置: %1$s
- 請輸入: %1$s
+ 裝置:%1$s
+ 請輸入:%1$s
重新開機
路由追蹤
顯示介紹指南
- 訊息:
+ 訊息:
快速聊天選項
新的快速聊天
編輯快速聊天
@@ -303,7 +303,7 @@
恢復出廠設置
藍芽已關閉,請至手機設定內開啟藍芽功能。
開啟設定
- 韌體版本: %1$s
+ 韌體版本:%1$s
Meshtastic 應用程式需要啟用「鄰近裝置」權限,才能透過藍牙尋找並連接到裝置,可以選擇在不使用時停用。
直通訊息
重設節點資料庫
@@ -314,7 +314,7 @@
將 '%1$s' 加入忽略清單嗎?
從忽略清單中移除 '%1$s' 嗎?
選擇下載地區
- 圖磚下載估計:
+ 圖磚下載估計:
開始下載
交換位置
關閉
@@ -329,14 +329,14 @@
清除下載的圖磚
圖磚來源
清除 %1$s 的 SQL 快取
- SQL快取清除失敗,請查看logcat以獲取詳細資訊。
+ SQL快取清除失敗,請查看 logcat 以獲取詳細資訊。
快取管理
下載已完成!
下載完成,但有 %1$d 個錯誤
%1$d 圖磚
方位:%1$d° 距離:%2$s
編輯航點
- 刪除航點?
+ 刪除航點?
新建航點
收到編輯航點:%1$s
達到循環工作週期限制。目前無法發送訊息,請稍後再試。
@@ -356,8 +356,8 @@
將「%1$s」的通知設為靜音?
取消「%1$s」的通知靜音?
替換
- 掃描WiFi QR code
- 錯誤的 WiFi 驗證QR code格式
+ 掃描Wi-Fi QR code
+ 錯誤的 Wi-Fi 驗證QR code格式
返回上一頁
電池
頻道利用率
@@ -414,10 +414,10 @@
在地圖上檢視
此路由追蹤尚未包含任何可標記於地圖的節點。
顯示 %1$d / %2$d 個節點
- 持續時間: %1$s 秒
+ 持續時間:%1$s 秒
%1$s - %2$s
- 追蹤至目的地的路由: \n\n
- 追蹤回到本機的路由: \n\n
+ 追蹤至目的地的路由:\n\n
+ 追蹤回到本機的路由:\n\n
1小時
二十四小時
四十八小時
@@ -446,16 +446,16 @@
我知道我在做什麼。
節點 %1$s 電量過低 (%2$d%%)
低電量通知
- 低電量:%1$s
+ 低電量:%1$s
低電量通知(收藏節點)
氣壓
已啟用
UDP 廣播
- UDP設置
+ UDP 設置
最後接收: %2$s
最後位置: %3$s
電量: %4$s]]>
切換我的位置
- 以北為上
- 用戶
+ 定位朝北
+ 使用者
頻道
裝置
位置
@@ -463,8 +463,8 @@
網路
顯示
LoRa
- 藍芽
- 安全
+ 藍牙
+ 安全性
MQTT
序列埠
外部通知
@@ -541,7 +541,7 @@
轉發模式
節點資訊廣播間隔
雙擊觸發按鈕功能
- 三擊執行臨時 Ping
+ 三擊執行 Ad Hoc Ping
時區
LED 心跳指示
裝置列表
@@ -616,7 +616,7 @@
網路
Wi-Fi 選項
已啟用
- 啟用WiFi
+ 啟用Wi-Fi
SSID
PSK
取得文件
@@ -628,12 +628,12 @@
IP
網閘
子網路
- Paxcount設置
- 啟用Paxcount
+ 人流計數(Paxcount)設置
+ 已啟用人流計數(Paxcount)
狀態訊息
狀態訊息設定
實際狀態字串
- WiFi RSSI 閾值(預設為-80)
+ Wi-Fi RSSI 閾值(預設為-80)
藍牙 RSSI 閾值(預設為-80)
位置
位置廣播間隔(秒)
@@ -678,7 +678,7 @@
管理員金鑰
託管模式
序列控制台
- 啟用調適日誌 API
+ 啟用除錯日誌 API
舊版管理頻道
序列埠設定
啟用序列埠
@@ -731,10 +731,10 @@
節點編號
使用者 ID
運行時間
- 負載:%1$d
+ 負載:%1$d
正在取得頻道 %1$d / %2$d
正在取得 %1$s
- 硬碟可用空間:%1$d
+ 硬碟可用空間:%1$d
時間戳記
航向
速度
@@ -847,7 +847,7 @@
未加密頻道,精確定位
紅色開鎖表示該頻道未進行安全加密,啟用了精確定位資訊,且未使用任何金鑰或使用 1 位元組已知金鑰。
- 警告:未加密頻道,精確定位 & MQTT Uplink
+ 警告:未加密頻道,已啟用精確定位 & MQTT Uplink
帶有警告的紅色開鎖表示該頻道未進行安全加密,啟用了精確定位資訊,且正在透過MQTT上傳資料至網路,以及未使用任何金鑰或使用 1 位元組已知金鑰。
頻道安全性
@@ -867,7 +867,7 @@
PAX 人流計量
PAX
無可用的 PAX 人流計量資料。
- WiFi 裝置
+ Wi-Fi 裝置
藍牙裝置
已配對的裝置
連接裝置
@@ -929,7 +929,9 @@
地形
混合
管理地圖圖層
+ 自訂圖層支援 .kml、.kmz 或 GeoJSON 檔案。
地圖圖層
+ 未載入自訂圖層。
添加圖層
隱藏圖層
顯示圖層
@@ -938,6 +940,10 @@
位於此處的節點
已選擇的地圖類型
管理自定義圖磚來源
+ 加入自定義圖磚來源
+ 沒有自定義圖專來源
+ 編輯自定義圖磚來源
+ 刪除自定義圖磚來源
名稱不得空白。
服務供應商名稱已存在。
URL 不得空白。
@@ -968,8 +974,8 @@
系統設定
沒有可用的統計資料
我們會收集分析數據以協助改善 Android 應用程式(感謝您的支持),我們將收到匿名化的使用者行為資訊,包括當機報告、應用程式使用畫面等。
- 分析平台:
- 欲了解更多資訊,請查閱我們的隱私權政策。
+ 分析平台:
+ 如欲了解更多資訊,請查閱我們的隱私權政策。
預設值 - 0
經由:%1$s
@@ -977,8 +983,9 @@
%1$s 裝置出廠時預載的開機載入程式通常不支援 OTA 更新功能。在執行 OTA 韌體更新前,您可能需要先透過 USB 連線刷入具備 OTA 功能的開機載入程式。
瞭解詳情
- 針對 RAK WisBlock RAK4631 裝置,必須使用 ' 提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以使用 adafruit-nrfutil dfu serial 命令配合提供的 bootloader .zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式。
- ' 此裝置不再顯示
+ 針對 RAK WisBlock RAK4631 裝置,必須使用原廠提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以搭配使用 adafruit-nrfutil dfu serial 隨附的 bootloader.zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式(Bootloader)。
+
+ 不再顯示此裝置的提示
保留我的最愛?
USB 裝置
@@ -1014,27 +1021,27 @@
處理中,請稍候⋯⋯
選擇本機檔案
本機檔案
- 來源: 本機檔案
+ 來源:本機檔案
無法識別的遠端版本
更新警告
- 您即將為裝置刷入新韌體,此過程存在風險。\n\n 請確保裝置電量充足。\n 請將裝置保持在手機附近。\n 更新期間請勿關閉應用程式。\n\n 請確認您已為您的硬體選擇正確的韌體。
- Chirpy 小提醒:「別忘了準備梯子!」
+ 您即將為裝置刷入新韌體,此過程存在風險。\n\n• 請確保裝置電量充足。\n• 請將裝置保持在手機附近。\n• 更新期間請勿關閉應用程式。\n\n請確認您已為您的硬體選擇正確的韌體。
+ Chirpy 小提醒:「緊握扶手!」
Chirpy
正在進入 DFU 模式⋯⋯
等待裝置進入 DFU 模式⋯⋯
正在複製韌體⋯⋯記得要強調是史上最快喔!
- 請將 .uf2 檔案儲存到您 ' 裝置 DFU 磁碟機。
+ 請將 .uf2 檔案複製到您裝置 DFU 的磁碟機。
刷入韌體中,請稍等⋯⋯
USB 檔案傳輸
BLE OTA
- WiFi OTA
+ Wi-Fi OTA
更新方式 %1$s
選擇 DFU USB 磁碟機
- 您的裝置已重新啟動進入 DFU 模式,應該會顯示為 USB 磁碟機(例如:RAK4631)。\n\n 當檔案選擇器開啟時,請選擇該磁碟機的根目錄以儲存韌體檔案。
+ 您的裝置已重新啟動進入 DFU 模式,應該會顯示為 USB 磁碟機(例如:RAK4631)。\n\n當檔案管理器開啟時,請選擇該磁碟機的根目錄以儲存韌體檔案。
正在驗證更新⋯⋯
驗證逾時。裝置未能在時限內重新連線。
等待裝置重新連線⋯⋯
- 目標裝置: %1$s
+ 目標裝置:%1$s
版本說明
未知錯誤
本機更新失敗
@@ -1047,9 +1054,9 @@
USB 更新失敗
韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。
OTA 更新失敗: %1$s
- Loading firmware⋯⋯
+ 正在載入韌體⋯⋯
等待裝置重新啟動至 OTA 模式⋯⋯
- 正在連線至裝置(第 %1$d / %2$d次嘗試 )⋯⋯
+ 正在連線至裝置(第 %1$d / %2$d次嘗試)⋯⋯
正在檢查裝置版本⋯⋯
正在啟動 OTA 更新⋯⋯
正在上傳韌體⋯⋯
@@ -1073,8 +1080,8 @@
指南針
開啟指南針
- 距離: %1$s
- 方位: %1$s
+ 距離:%1$s
+ 方位:%1$s
方位:無資料
此裝置沒有指南針感測器,無法取得方向資訊。
需要位置權限才能顯示距離和方位。
@@ -1147,4 +1154,11 @@
重新整理
已更新
+ 新增線上圖層
+ 重新整理圖層
+ 本機 MBTiles 檔案
+ 新增本機 MBTiles 檔案
+ 自訂圖磚來源的名稱、URL 範本或本機 URI 無效。
+ 已存在相同名稱的自訂圖磚來源。
+ 無法將 MBTiles 檔案複製至內部儲存空間。
From a07992530c964de430c2c3793b12f77a44f63ec8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:33:43 -0600
Subject: [PATCH 003/440] feat: Improve edge-to-edge and display cutout
handling (#4669)
---
.../java/com/geeksville/mesh/MainActivity.kt | 29 +++++++++++++------
.../main/java/com/geeksville/mesh/ui/Main.kt | 4 +--
2 files changed, 21 insertions(+), 12 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 3b5dffc1e..0fbe657ce 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -26,6 +26,7 @@ import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
@@ -75,6 +76,19 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Explicitly set the cutout mode to ALWAYS for Android 15+ to satisfy Play Console recommendations.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ window.attributes.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
+ }
+
+ // Ensure the navigation bar remains seamless on modern Android versions
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+
setContent {
val theme by model.theme.collectAsStateWithLifecycle()
val dynamic = theme == MODE_DYNAMIC
@@ -85,15 +99,12 @@ class MainActivity : ComponentActivity() {
else -> isSystemInDarkTheme()
}
- // Apply modern edge-to-edge drawing with theme-aware system bars
- enableEdgeToEdge(
- statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- )
-
- // Ensure the navigation bar remains seamless on modern Android versions
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.isNavigationBarContrastEnforced = false
+ // Update system bar style when theme changes
+ androidx.compose.runtime.SideEffect {
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
+ navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
+ )
}
@Suppress("SpreadOperator")
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index c4f9d3fb5..8a31155eb 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -31,8 +31,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.recalculateWindowInsets
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -448,7 +446,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie
NavHost(
navController = navController,
startDestination = NodesRoutes.NodesGraph,
- modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
+ modifier = Modifier.fillMaxSize(),
) {
contactsGraph(navController, uIViewModel.scrollToTopEventFlow)
nodesGraph(navController, uIViewModel.scrollToTopEventFlow)
From b2b21e10e26b2dae4e80570ea4ae32cb83404647 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:44:19 -0600
Subject: [PATCH 004/440] feat: upcoming support for tak and trafficmanagement
configs, device hw (#4671)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/assets/device_hardware.json | 42 +++
.../mesh/navigation/SettingsNavigation.kt | 7 +
core/common/build.gradle.kts | 5 +-
.../org/meshtastic/core/database/model/TAK.kt | 96 +++++
core/model/build.gradle.kts | 23 ++
.../org/meshtastic/core/model/ChannelTest.kt | 0
.../core/model/util/ChannelSetTest.kt | 0
.../core/model/util/SharedContactTest.kt | 0
.../meshtastic/core/model/CapabilitiesTest.kt | 92 ++---
.../core/model/ChannelOptionTest.kt | 0
.../core/model/DataPacketParcelTest.kt | 38 +-
.../meshtastic/core/model/DataPacketTest.kt | 3 +-
.../core/model/DeviceVersionTest.kt | 0
.../org/meshtastic/core/model/NodeInfoTest.kt | 16 +-
.../org/meshtastic/core/model/PositionTest.kt | 13 +-
.../core/model/util/SharedContactTest.kt | 10 +-
.../core/model/util/UriUtilsTest.kt | 16 +-
.../core/model/util/ExtensionsTest.kt | 100 ------
.../core/model/util/SfppHasherTest.kt | 95 -----
.../core/model/util/TimeExtensionsTest.kt | 103 ------
.../core/model/util/UnitConversionsTest.kt | 118 ------
.../core/model/util/WireExtensionsTest.kt | 336 ------------------
.../org/meshtastic/core/model/Capabilities.kt | 56 +--
.../meshtastic/core/model/DeviceVersion.kt | 18 +-
.../org/meshtastic/core/navigation/Routes.kt | 4 +
core/resources/build.gradle.kts | 5 +-
.../composeResources/values/strings.xml | 48 +++
.../core/ui/component/DropDownPreference.kt | 36 +-
.../settings/navigation/ModuleRoute.kt | 43 ++-
.../feature/settings/radio/RadioConfig.kt | 10 +-
.../settings/radio/RadioConfigViewModel.kt | 7 +
.../radio/component/TAKConfigItemList.kt | 80 +++++
.../TrafficManagementConfigItemList.kt | 208 +++++++++++
33 files changed, 737 insertions(+), 891 deletions(-)
create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/ChannelTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt (56%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt (76%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketTest.kt (98%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/NodeInfoTest.kt (73%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/PositionTest.kt (72%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (92%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt (97%)
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 0699ff16b..71143aa72 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1349,5 +1349,47 @@
"images": [
"tbeam-1w.svg"
]
+ },
+ {
+ "hwModel": 123,
+ "hwModelSlug": "T5_S3_EPAPER_PRO",
+ "platformioTarget": "t5-s3-epaper-pro",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T5 S3 ePaper Pro",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": true,
+ "partitionScheme": "8MB"
+ },
+ {
+ "hwModel": 124,
+ "hwModelSlug": "TBEAM_BPF",
+ "platformioTarget": "tbeam-bpf",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T-Beam BPF",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": false,
+ "partitionScheme": "8MB"
+ },
+ {
+ "hwModel": 125,
+ "hwModelSlug": "MINI_EPAPER_S3",
+ "platformioTarget": "mini-epaper-s3",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T-Mini E-paper S3 Kit",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": true,
+ "partitionScheme": "8MB"
}
]
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index aa498f009..18522c531 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -61,7 +61,9 @@ import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
+import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
+import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@@ -167,6 +169,11 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
+
+ ModuleRoute.TRAFFIC_MANAGEMENT ->
+ TrafficManagementConfigScreen(viewModel, onBack = navController::popBackStack)
+
+ ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 8f55e26fc..41a0c8a3d 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
- android { androidResources.enable = false }
+ android {
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ }
sourceSets {
commonMain.dependencies {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
new file mode 100644
index 000000000..bf5cddffc
--- /dev/null
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.database.model
+
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.tak_role_forwardobserver
+import org.meshtastic.core.resources.tak_role_hq
+import org.meshtastic.core.resources.tak_role_k9
+import org.meshtastic.core.resources.tak_role_medic
+import org.meshtastic.core.resources.tak_role_rto
+import org.meshtastic.core.resources.tak_role_sniper
+import org.meshtastic.core.resources.tak_role_teamlead
+import org.meshtastic.core.resources.tak_role_teammember
+import org.meshtastic.core.resources.tak_role_unspecified
+import org.meshtastic.core.resources.tak_team_blue
+import org.meshtastic.core.resources.tak_team_brown
+import org.meshtastic.core.resources.tak_team_cyan
+import org.meshtastic.core.resources.tak_team_dark_blue
+import org.meshtastic.core.resources.tak_team_dark_green
+import org.meshtastic.core.resources.tak_team_green
+import org.meshtastic.core.resources.tak_team_magenta
+import org.meshtastic.core.resources.tak_team_maroon
+import org.meshtastic.core.resources.tak_team_orange
+import org.meshtastic.core.resources.tak_team_purple
+import org.meshtastic.core.resources.tak_team_red
+import org.meshtastic.core.resources.tak_team_teal
+import org.meshtastic.core.resources.tak_team_unspecified_color
+import org.meshtastic.core.resources.tak_team_white
+import org.meshtastic.core.resources.tak_team_yellow
+import org.meshtastic.proto.MemberRole
+import org.meshtastic.proto.Team
+
+@Suppress("CyclomaticComplexMethod")
+fun getStringResFrom(team: Team): StringResource = when (team) {
+ Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
+ Team.White -> Res.string.tak_team_white
+ Team.Yellow -> Res.string.tak_team_yellow
+ Team.Orange -> Res.string.tak_team_orange
+ Team.Magenta -> Res.string.tak_team_magenta
+ Team.Red -> Res.string.tak_team_red
+ Team.Maroon -> Res.string.tak_team_maroon
+ Team.Purple -> Res.string.tak_team_purple
+ Team.Dark_Blue -> Res.string.tak_team_dark_blue
+ Team.Blue -> Res.string.tak_team_blue
+ Team.Cyan -> Res.string.tak_team_cyan
+ Team.Teal -> Res.string.tak_team_teal
+ Team.Green -> Res.string.tak_team_green
+ Team.Dark_Green -> Res.string.tak_team_dark_green
+ Team.Brown -> Res.string.tak_team_brown
+}
+
+fun getStringResFrom(role: MemberRole): StringResource = when (role) {
+ MemberRole.Unspecifed -> Res.string.tak_role_unspecified
+ MemberRole.TeamMember -> Res.string.tak_role_teammember
+ MemberRole.TeamLead -> Res.string.tak_role_teamlead
+ MemberRole.HQ -> Res.string.tak_role_hq
+ MemberRole.Sniper -> Res.string.tak_role_sniper
+ MemberRole.Medic -> Res.string.tak_role_medic
+ MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
+ MemberRole.RTO -> Res.string.tak_role_rto
+ MemberRole.K9 -> Res.string.tak_role_k9
+}
+
+@Suppress("CyclomaticComplexMethod", "MagicNumber")
+fun getColorFrom(team: Team): Long = when (team) {
+ Team.Unspecifed_Color -> 0xFF00FFFF // Default to Cyan
+ Team.White -> 0xFFFFFFFF
+ Team.Yellow -> 0xFFFFFF00
+ Team.Orange -> 0xFFFFA500
+ Team.Magenta -> 0xFFFF00FF
+ Team.Red -> 0xFFFF0000
+ Team.Maroon -> 0xFF800000
+ Team.Purple -> 0xFF800080
+ Team.Dark_Blue -> 0xFF00008B
+ Team.Blue -> 0xFF0000FF
+ Team.Cyan -> 0xFF00FFFF
+ Team.Teal -> 0xFF008080
+ Team.Green -> 0xFF00FF00
+ Team.Dark_Green -> 0xFF006400
+ Team.Brown -> 0xFFA52A2A
+}
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 902098124..951403976 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -25,6 +25,13 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
+ @Suppress("UnstableApiUsage")
+ android {
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ withDeviceTest { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ }
+
sourceSets {
commonMain.dependencies {
api(projects.core.proto)
@@ -37,9 +44,25 @@ kotlin {
}
androidMain.dependencies {
api(libs.androidx.annotation)
+ api(libs.androidx.core.ktx)
implementation(libs.zxing.core)
}
commonTest.dependencies { implementation(kotlin("test")) }
+ val androidHostTest by getting {
+ dependencies {
+ implementation(libs.junit)
+ implementation(libs.robolectric)
+ implementation(libs.mockk)
+ implementation(libs.androidx.test.ext.junit)
+ implementation(kotlin("test"))
+ }
+ }
+ val androidDeviceTest by getting {
+ dependencies {
+ implementation(libs.androidx.test.ext.junit)
+ implementation(libs.androidx.test.runner)
+ }
+ }
}
}
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
similarity index 56%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
index e1ffb313a..40f35ece2 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -25,60 +26,74 @@ class CapabilitiesTest {
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test
- fun `canMuteNode requires v2 7 18`() {
+ fun canMuteNodeRequiresV2718() {
assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode)
- assertTrue(caps("2.8.1").canMuteNode)
}
- // FIXME: needs updating when NeighborInfo is working properly
@Test
- fun `canRequestNeighborInfo disabled`() {
+ fun canRequestNeighborInfoIsCurrentlyDisabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo)
- assertFalse(caps("2.7.15").canRequestNeighborInfo)
- assertFalse(caps("2.8.0").canRequestNeighborInfo)
+ assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
- fun `canSendVerifiedContacts requires v2 7 12`() {
+ fun canSendVerifiedContactsRequiresV2712() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts)
- assertTrue(caps("2.7.15").canSendVerifiedContacts)
}
@Test
- fun `canToggleTelemetryEnabled requires v2 7 12`() {
+ fun canToggleTelemetryEnabledRequiresV2712() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
- fun `canToggleUnmessageable requires v2 6 9`() {
+ fun canToggleUnmessageableRequiresV269() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable)
}
@Test
- fun `supportsQrCodeSharing requires v2 6 8`() {
+ fun supportsQrCodeSharingRequiresV268() {
assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing)
}
@Test
- fun `supportsSecondaryChannelLocation requires v2 6 10`() {
+ fun supportsSecondaryChannelLocationRequiresV2610() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
}
@Test
- fun `supportsStatusMessage requires v2 7 17`() {
+ fun supportsStatusMessageRequiresV2717() {
assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
- fun `null firmware returns all false`() {
+ fun supportsTrafficManagementConfigRequiresV300() {
+ assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
+ assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
+ }
+
+ @Test
+ fun supportsTakConfigRequiresV2719() {
+ assertFalse(caps("2.7.18").supportsTakConfig)
+ assertTrue(caps("2.7.19").supportsTakConfig)
+ }
+
+ @Test
+ fun supportsEsp32OtaRequiresV2718() {
+ assertFalse(caps("2.7.17").supportsEsp32Ota)
+ assertTrue(caps("2.7.18").supportsEsp32Ota)
+ }
+
+ @Test
+ fun nullFirmwareReturnsAllFalse() {
val c = caps(null)
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
@@ -88,44 +103,35 @@ class CapabilitiesTest {
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
+ assertFalse(c.supportsTrafficManagementConfig)
+ assertFalse(c.supportsTakConfig)
+ assertFalse(c.supportsEsp32Ota)
}
@Test
- fun `invalid firmware returns all false`() {
- val c = caps("invalid")
- assertFalse(c.canMuteNode)
- assertFalse(c.canRequestNeighborInfo)
- assertFalse(c.canSendVerifiedContacts)
- assertFalse(c.canToggleTelemetryEnabled)
- assertFalse(c.canToggleUnmessageable)
- assertFalse(c.supportsQrCodeSharing)
- assertFalse(c.supportsSecondaryChannelLocation)
- assertFalse(c.supportsStatusMessage)
- }
-
- @Test
- fun `forceEnableAll returns true for everything regardless of version`() {
+ fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode)
- assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
- assertTrue(c.canToggleTelemetryEnabled)
- assertTrue(c.canToggleUnmessageable)
- assertTrue(c.supportsQrCodeSharing)
- assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
+ assertTrue(c.supportsTrafficManagementConfig)
+ assertTrue(c.supportsTakConfig)
}
@Test
- fun `forceEnableAll returns true even for invalid versions`() {
- val c = Capabilities(firmwareVersion = "invalid", forceEnableAll = true)
- assertTrue(c.canMuteNode)
- assertTrue(c.canRequestNeighborInfo)
- assertTrue(c.canSendVerifiedContacts)
- assertTrue(c.canToggleTelemetryEnabled)
- assertTrue(c.canToggleUnmessageable)
- assertTrue(c.supportsQrCodeSharing)
- assertTrue(c.supportsSecondaryChannelLocation)
- assertTrue(c.supportsStatusMessage)
+ fun deviceVersionParsingIsRobust() {
+ assertEquals(20712, DeviceVersion("2.7.12").asInt)
+ assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
+ assertEquals(30000, DeviceVersion("3.0.0").asInt)
+ assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
+ assertEquals(0, DeviceVersion("invalid").asInt)
+ }
+
+ @Test
+ fun deviceVersionComparisonIsCorrect() {
+ assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
+ assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
+ assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
+ assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
similarity index 76%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
index 94bf4f5a4..0d6d15c1d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
@@ -119,24 +119,24 @@ class DataPacketParcelTest {
)
private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) {
- assertEquals("to", expected.to, actual.to)
- assertEquals("bytes", expected.bytes, actual.bytes)
- assertEquals("dataType", expected.dataType, actual.dataType)
- assertEquals("from", expected.from, actual.from)
- assertEquals("time", expected.time, actual.time)
- assertEquals("id", expected.id, actual.id)
- assertEquals("status", expected.status, actual.status)
- assertEquals("hopLimit", expected.hopLimit, actual.hopLimit)
- assertEquals("channel", expected.channel, actual.channel)
- assertEquals("wantAck", expected.wantAck, actual.wantAck)
- assertEquals("hopStart", expected.hopStart, actual.hopStart)
- assertEquals("snr", expected.snr, actual.snr, 0.001f)
- assertEquals("rssi", expected.rssi, actual.rssi)
- assertEquals("replyId", expected.replyId, actual.replyId)
- assertEquals("relayNode", expected.relayNode, actual.relayNode)
- assertEquals("relays", expected.relays, actual.relays)
- assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
- assertEquals("emoji", expected.emoji, actual.emoji)
- assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
+ assertEquals(expected.to, actual.to)
+ assertEquals(expected.bytes, actual.bytes)
+ assertEquals(expected.dataType, actual.dataType)
+ assertEquals(expected.from, actual.from)
+ assertEquals(expected.time, actual.time)
+ assertEquals(expected.id, actual.id)
+ assertEquals(expected.status, actual.status)
+ assertEquals(expected.hopLimit, actual.hopLimit)
+ assertEquals(expected.channel, actual.channel)
+ assertEquals(expected.wantAck, actual.wantAck)
+ assertEquals(expected.hopStart, actual.hopStart)
+ assertEquals(expected.snr, actual.snr, 0.001f)
+ assertEquals(expected.rssi, actual.rssi)
+ assertEquals(expected.replyId, actual.replyId)
+ assertEquals(expected.relayNode, actual.relayNode)
+ assertEquals(expected.relays, actual.relays)
+ assertEquals(expected.viaMqtt, actual.viaMqtt)
+ assertEquals(expected.emoji, actual.emoji)
+ assertEquals(expected.sfppHash, actual.sfppHash)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
similarity index 98%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
index 5dddd5858..5858585b4 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
@@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -36,7 +37,7 @@ class DataPacketTest {
assertEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
- assertEquals(null, packetNoHash.sfppHash)
+ assertNull(packetNoHash.sfppHash)
}
@Test
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
similarity index 73%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
index 0d10a6426..22942787a 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.model
import androidx.core.os.LocaleListCompat
import org.junit.After
-import org.junit.Assert
+import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.Config
@@ -50,16 +50,16 @@ class NodeInfoTest {
@Test
fun distanceGood() {
- Assert.assertEquals(node[1].distance(node[2]), 1111)
- Assert.assertEquals(node[1].distance(node[3]), 111)
- Assert.assertEquals(node[1].distance(node[4]), 1779)
+ assertEquals(1111, node[1].distance(node[2]))
+ assertEquals(111, node[1].distance(node[3]))
+ assertEquals(1779, node[1].distance(node[4]))
}
@Test
fun distanceStrGood() {
- Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km")
- Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m")
- Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi")
- Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft")
+ assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
+ assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
+ assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
+ assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
similarity index 72%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
index f07ad83dd..e6b44cd27 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
@@ -16,22 +16,23 @@
*/
package org.meshtastic.core.model
-import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Test
class PositionTest {
@Test
fun degGood() {
- Assert.assertEquals(Position.degI(89.0), 890000000)
- Assert.assertEquals(Position.degI(-89.0), -890000000)
+ assertEquals(Position.degI(89.0), 890000000)
+ assertEquals(Position.degI(-89.0), -890000000)
- Assert.assertEquals(Position.degD(Position.degI(89.0)), 89.0, 0.01)
- Assert.assertEquals(Position.degD(Position.degI(-89.0)), -89.0, 0.01)
+ assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
+ assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
}
@Test
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
val position = Position(37.1, 121.1, 35)
- Assert.assertTrue(position.time != 0)
+ assertTrue(position.time != 0)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 92%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
index c73a65853..67df45ce7 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
@@ -58,7 +58,7 @@ class SharedContactTest {
assertEquals("Suzume", contact.user?.long_name)
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
@@ -66,7 +66,7 @@ class SharedContactTest {
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidPathThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
@@ -74,21 +74,21 @@ class SharedContactTest {
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testMissingFragmentThrows() {
val urlStr = "https://meshtastic.org/v/"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidBase64Throws() {
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidProtoThrows() {
// Tag 0 is invalid in Protobuf
// 0x00 -> Tag 0, Type 0.
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
similarity index 97%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
index 2c729b1ba..606dc485d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
@@ -32,7 +32,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
- val uri = Uri.parse("https://meshtastic.org/e/somechannel")
+ val uri = Uri.parse("https://meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
@@ -41,7 +41,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles contact share uri`() {
- val uri = Uri.parse("https://meshtastic.org/v/somecontact")
+ val uri = Uri.parse("https://meshtastic.org/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
@@ -50,21 +50,21 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri ignores other hosts`() {
- val uri = Uri.parse("https://example.com/e/somechannel")
+ val uri = Uri.parse("https://example.com/e/somechannel").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
- val uri = Uri.parse("https://meshtastic.org/other/path")
+ val uri = Uri.parse("https://meshtastic.org/other/path").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
- val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
+ val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
@@ -73,7 +73,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles www host`() {
- val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
+ val uri = Uri.parse("https://www.meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
@@ -82,7 +82,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long channel path`() {
- val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
+ val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
@@ -91,7 +91,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long contact path`() {
- val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
+ val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
deleted file mode 100644
index ae4690a52..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
+++ /dev/null
@@ -1,100 +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.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.proto.EnvironmentMetrics
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.Telemetry
-
-class ExtensionsTest {
-
- @Test
- fun `isDirectSignal returns true for valid LoRa non-MQTT packets with matching hops`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertTrue(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if via MQTT`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = true,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if hops do not match`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 2,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if rx_time is zero`() {
- val packet =
- MeshPacket(
- rx_time = 0,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns true when temperature and humidity are present and valid`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = 50.0f))
- assertTrue(telemetry.hasValidEnvironmentMetrics())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns false if temperature is NaN`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = Float.NaN, relative_humidity = 50.0f))
- assertFalse(telemetry.hasValidEnvironmentMetrics())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns false if humidity is missing`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = null))
- assertFalse(telemetry.hasValidEnvironmentMetrics())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
deleted file mode 100644
index 218955a2f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
+++ /dev/null
@@ -1,95 +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.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotEquals
-import org.junit.Test
-
-class SfppHasherTest {
-
- @Test
- fun `computeMessageHash produces consistent results`() {
- val payload = "Hello World".toByteArray()
- val to = 1234
- val from = 5678
- val id = 999
-
- val hash1 = SfppHasher.computeMessageHash(payload, to, from, id)
- val hash2 = SfppHasher.computeMessageHash(payload, to, from, id)
-
- assertArrayEquals(hash1, hash2)
- assertEquals(16, hash1.size)
- }
-
- @Test
- fun `computeMessageHash produces different results for different inputs`() {
- val payload = "Hello World".toByteArray()
- val to = 1234
- val from = 5678
- val id = 999
-
- val hashBase = SfppHasher.computeMessageHash(payload, to, from, id)
-
- // Different payload
- val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id)
- assertNotEquals(hashBase.toList(), hashDiffPayload.toList())
-
- // Different to
- val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id)
- assertNotEquals(hashBase.toList(), hashDiffTo.toList())
-
- // Different from
- val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id)
- assertNotEquals(hashBase.toList(), hashDiffFrom.toList())
-
- // Different id
- val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000)
- assertNotEquals(hashBase.toList(), hashDiffId.toList())
- }
-
- @Test
- fun `computeMessageHash handles large values`() {
- val payload = byteArrayOf(1, 2, 3)
- // Testing that large unsigned-like values don't cause issues
- val to = -1 // 0xFFFFFFFF
- val from = 0x7FFFFFFF
- val id = Int.MIN_VALUE
-
- val hash = SfppHasher.computeMessageHash(payload, to, from, id)
- assertEquals(16, hash.size)
- }
-
- @Test
- fun `computeMessageHash follows little endian for integers`() {
- // This test ensures that the hash is computed consistently with the firmware
- // which uses little-endian byte order for these fields.
- val payload = byteArrayOf()
- val to = 0x01020304
- val from = 0x05060708
- val id = 0x090A0B0C
-
- val hash = SfppHasher.computeMessageHash(payload, to, from, id)
- assertNotNull(hash)
- assertEquals(16, hash.size)
- }
-
- private fun assertNotNull(any: Any?) {
- if (any == null) throw AssertionError("Should not be null")
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
deleted file mode 100644
index 68ea8032e..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
+++ /dev/null
@@ -1,103 +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 kotlinx.datetime.TimeZone
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.core.common.util.await
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.common.util.secondsToInstant
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import java.util.concurrent.CountDownLatch
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.Instant
-
-class TimeExtensionsTest {
-
- @Test
- fun testNowMillis() {
- val start = Clock.System.now().toEpochMilliseconds()
- val now = nowMillis
- val end = Clock.System.now().toEpochMilliseconds()
- assertTrue(now in start..end)
- }
-
- @Test
- fun testNowSeconds() {
- val start = Clock.System.now().epochSeconds
- val now = nowSeconds
- val end = Clock.System.now().epochSeconds
- assertTrue(now in start..end)
- }
-
- @Test
- fun testToDate() {
- val instant = Instant.fromEpochMilliseconds(1234567890L)
- val date = instant.toDate()
- assertEquals(1234567890L, date.time)
- }
-
- @Test
- fun testLongToInstant() {
- val millis = 1234567890L
- val instant = millis.toInstant()
- assertEquals(millis, instant.toEpochMilliseconds())
- }
-
- @Test
- fun testIntSecondsToInstant() {
- val seconds = 1234567890
- val instant = seconds.secondsToInstant()
- assertEquals(seconds.toLong(), instant.epochSeconds)
- }
-
- @Test
- fun testDurationInWholeSeconds() {
- assertEquals(60L, 60.seconds.inWholeSeconds)
- assertEquals(3600L, TimeConstants.ONE_HOUR.inWholeSeconds)
- }
-
- @Test
- fun testLongSecondsProperty() {
- assertEquals(60.seconds, 60L.seconds)
- }
-
- @Test
- fun testCountDownLatchAwaitWithDuration() {
- val latch = CountDownLatch(1)
- // This should timeout quickly
- val result = latch.await(10.milliseconds)
- assertEquals(false, result)
-
- val latch2 = CountDownLatch(1)
- latch2.countDown()
- val result2 = latch2.await(1.seconds)
- assertEquals(true, result2)
- }
-
- @Test
- fun testTimeZoneToPosixString() {
- val tz = TimeZone.of("UTC")
- assertEquals("UTC0", tz.toPosixString())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
deleted file mode 100644
index 07832a903..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
+++ /dev/null
@@ -1,118 +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.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.core.model.util.UnitConversions.toTempString
-
-class UnitConversionsTest {
-
- // Test data: (celsius, isFahrenheit, expected)
- private val tempTestCases =
- listOf(
- // Issue #4150: negative zero should display as "0"
- Triple(-0.1f, false, "0°C"),
- Triple(-0.2f, false, "0°C"),
- Triple(-0.4f, false, "0°C"),
- Triple(-0.49f, false, "0°C"),
- // Boundary: -0.5 rounds to -1
- Triple(-0.5f, false, "-1°C"),
- Triple(-0.9f, false, "-1°C"),
- Triple(-1.0f, false, "-1°C"),
- // Zero and small positives
- Triple(0.0f, false, "0°C"),
- Triple(0.1f, false, "0°C"),
- Triple(0.4f, false, "0°C"),
- // Typical values
- Triple(1.0f, false, "1°C"),
- Triple(20.0f, false, "20°C"),
- Triple(25.4f, false, "25°C"),
- Triple(25.5f, false, "26°C"),
- // Negative
- Triple(-5.0f, false, "-5°C"),
- Triple(-10.0f, false, "-10°C"),
- Triple(-20.4f, false, "-20°C"),
- // Fahrenheit conversions
- Triple(0.0f, true, "32°F"),
- Triple(20.0f, true, "68°F"),
- Triple(25.0f, true, "77°F"),
- Triple(100.0f, true, "212°F"),
- Triple(-40.0f, true, "-40°F"), // -40°C = -40°F
- // Issue #4150: negative zero in Fahrenheit
- Triple(-0.1f, true, "32°F"),
- Triple(-17.78f, true, "0°F"),
- )
-
- @Test
- fun `toTempString formats all temperatures correctly`() {
- tempTestCases.forEach { (celsius, isFahrenheit, expected) ->
- assertEquals(
- "Failed for $celsius°C (Fahrenheit=$isFahrenheit)",
- expected,
- celsius.toTempString(isFahrenheit),
- )
- }
- }
-
- @Test
- fun `toTempString handles extreme temperatures`() {
- assertEquals("100°C", 100.0f.toTempString(false))
- assertEquals("-40°C", (-40.0f).toTempString(false))
- assertEquals("-40°F", (-40.0f).toTempString(true))
- }
-
- @Test
- fun `toTempString handles NaN`() {
- assertEquals("--", Float.NaN.toTempString(false))
- assertEquals("--", Float.NaN.toTempString(true))
- }
-
- @Test
- fun `celsiusToFahrenheit converts correctly`() {
- mapOf(
- 0.0f to 32.0f,
- 20.0f to 68.0f,
- 100.0f to 212.0f,
- -40.0f to -40.0f,
- ).forEach { (celsius, expectedFahrenheit) ->
- assertEquals(expectedFahrenheit, UnitConversions.celsiusToFahrenheit(celsius), 0.01f)
- }
- }
-
- @Test
- fun `calculateDewPoint returns expected values`() {
- // At 100% humidity, dew point equals temperature
- assertEquals(20.0f, UnitConversions.calculateDewPoint(20.0f, 100.0f), 0.1f)
-
- // Known reference: 20°C at 60% humidity ≈ 12°C dew point
- assertEquals(12.0f, UnitConversions.calculateDewPoint(20.0f, 60.0f), 0.5f)
-
- // Higher humidity = higher dew point
- val highHumidity = UnitConversions.calculateDewPoint(25.0f, 80.0f)
- val lowHumidity = UnitConversions.calculateDewPoint(25.0f, 40.0f)
- assertTrue("Dew point should be higher at higher humidity", highHumidity > lowHumidity)
- }
-
- @Test
- fun `calculateDewPoint handles edge cases`() {
- // 0% humidity results in NaN (ln(0) = -Infinity, causing invalid calculation)
- val zeroHumidity = UnitConversions.calculateDewPoint(20.0f, 0.0f)
- assertTrue("Expected NaN for 0% humidity", zeroHumidity.isNaN())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
deleted file mode 100644
index b9ede858f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
+++ /dev/null
@@ -1,336 +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 co.touchlab.kermit.Logger
-import okio.ByteString
-import okio.ByteString.Companion.toByteString
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.proto.DeviceMetrics
-import org.meshtastic.proto.Position
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.User
-
-/**
- * Unit tests for Wire extension functions.
- *
- * Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and
- * functionality.
- */
-class WireExtensionsTest {
-
- private val testLogger = Logger
-
- @Before
- fun setUp() {
- // Setup test logger if needed
- }
-
- // ===== decodeOrNull() Tests =====
-
- @Test
- fun `decodeOrNull with valid ByteString returns decoded message`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15)
- val encoded = Position.ADAPTER.encode(position)
- val byteString = encoded.toByteString()
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(position.latitude_i, decoded!!.latitude_i)
- assertEquals(position.longitude_i, decoded.longitude_i)
- assertEquals(position.altitude, decoded.altitude)
- }
-
- @Test
- fun `decodeOrNull with null ByteString returns null`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- @Test
- fun `decodeOrNull with empty ByteString returns empty message`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
-
- // Assert
- assertNotNull(result)
- // An empty position should have null/default values
- assertNull(result!!.latitude_i)
- }
-
- @Test
- fun `decodeOrNull with valid ByteArray returns decoded message`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
- val encoded = Position.ADAPTER.encode(position)
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(position.latitude_i, decoded!!.latitude_i)
- assertEquals(position.longitude_i, decoded.longitude_i)
- }
-
- @Test
- fun `decodeOrNull with null ByteArray returns null`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- @Test
- fun `decodeOrNull with empty ByteArray returns empty message`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
-
- // Assert
- assertNotNull(result)
- assertNull(result!!.latitude_i)
- }
-
- @Test
- fun `decodeOrNull with invalid data returns null`() {
- // Arrange
- // A single byte 0xFF is an invalid field tag (field 0 is reserved and tags are varints)
- val invalidBytes = ByteString.of(0xFF.toByte())
-
- // Act - should not throw, should return null
- val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- // ===== Size Validation Tests =====
-
- @Test
- fun `isWithinSizeLimit returns true for message under limit`() {
- // Arrange
- val position = Position(latitude_i = 371234567)
- val limit = 1000
-
- // Act
- val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit)
-
- // Assert
- assertTrue(isValid)
- }
-
- @Test
- fun `isWithinSizeLimit returns false for message over limit`() {
- // Arrange
- val telemetry =
- Telemetry(
- device_metrics =
- DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f),
- )
- val limit = 1 // Artificially low limit
-
- // Act
- val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit)
-
- // Assert
- assertEquals(false, isValid)
- }
-
- @Test
- fun `sizeInBytes returns accurate encoded size`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
-
- // Act
- val size = Position.ADAPTER.sizeInBytes(position)
- val actualEncoded = Position.ADAPTER.encode(position)
-
- // Assert
- assertEquals(actualEncoded.size, size)
- assertTrue(size > 0)
- }
-
- @Test
- fun `sizeInBytes for empty message`() {
- // Arrange
- val emptyPosition = Position()
-
- // Act
- val size = Position.ADAPTER.sizeInBytes(emptyPosition)
-
- // Assert
- assertTrue(size >= 0)
- }
-
- @Test
- fun `sizeInBytes matches wire encoding size`() {
- // Arrange
- val user = User(id = "12345", long_name = "Test User", short_name = "TU")
-
- // Act
- val extensionSize = User.ADAPTER.sizeInBytes(user)
- val actualEncoded = User.ADAPTER.encode(user)
-
- // Assert
- assertEquals(extensionSize, actualEncoded.size)
- }
-
- // ===== JSON Marshalling Tests =====
-
- @Test
- fun `toReadableString returns non-empty string`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
-
- // Act
- val readable = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertNotNull(readable)
- assertTrue(readable.isNotEmpty())
- assertTrue(readable.contains("Position"))
- }
-
- @Test
- fun `toReadableString contains field values`() {
- // Arrange
- val position = Position(latitude_i = 12345, longitude_i = 67890)
-
- // Act
- val readable = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertTrue(readable.contains("12345"))
- assertTrue(readable.contains("67890"))
- }
-
- @Test
- fun `toOneLiner returns single line string`() {
- // Arrange
- val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f))
-
- // Act
- val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry)
-
- // Assert
- assertNotNull(oneLiner)
- assertEquals(false, oneLiner.contains("\n"))
- assertTrue(oneLiner.isNotEmpty())
- }
-
- @Test
- fun `toOneLiner contains essential data`() {
- // Arrange
- val user = User(long_name = "Test User")
-
- // Act
- val oneLiner = User.ADAPTER.toOneLiner(user)
-
- // Assert
- assertTrue(oneLiner.contains("Test User"))
- }
-
- // ===== Integration Tests =====
-
- @Test
- fun `decode and encode roundtrip maintains data`() {
- // Arrange
- val originalPosition =
- Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5)
- val encoded = Position.ADAPTER.encode(originalPosition)
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(originalPosition.latitude_i, decoded!!.latitude_i)
- assertEquals(originalPosition.longitude_i, decoded.longitude_i)
- assertEquals(originalPosition.altitude, decoded.altitude)
- assertEquals(originalPosition.precision_bits, decoded.precision_bits)
- }
-
- @Test
- fun `size checking prevents oversized messages`() {
- // Arrange
- val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100)
- val maxSize = 5 // Very small limit
-
- // Act
- val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize)
- val actualSize = Position.ADAPTER.sizeInBytes(position)
-
- // Assert
- assertEquals(false, isValid)
- assertTrue(actualSize > maxSize)
- }
-
- @Test
- fun `multiple messages with different sizes`() {
- // Arrange
- val smallUser = User(short_name = "A")
- val largeUser = User(long_name = "Very Long Name " + "X".repeat(100))
-
- // Act
- val smallSize = User.ADAPTER.sizeInBytes(smallUser)
- val largeSize = User.ADAPTER.sizeInBytes(largeUser)
-
- // Assert
- assertTrue(smallSize < largeSize)
- assertTrue(largeSize > smallSize)
- }
-
- @Test
- fun `readable string format consistency`() {
- // Arrange
- val position = Position(latitude_i = 123456)
-
- // Act
- val readable1 = Position.ADAPTER.toReadableString(position)
- val readable2 = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertEquals(readable1, readable2)
- }
-
- @Test
- fun `oneLiner format consistency`() {
- // Arrange
- val user = User(long_name = "Test")
-
- // Act
- val line1 = User.ADAPTER.toOneLiner(user)
- val line2 = User.ADAPTER.toOneLiner(user)
-
- // Assert
- assertEquals(line1, line2)
- assertEquals(false, line1.contains("\n"))
- }
-}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index e5c069fc9..65096604f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -23,50 +23,56 @@ import org.meshtastic.core.model.util.isDebug
*
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
* Add new features here to ensure consistency across the app.
+ *
+ * Note: Properties are calculated once during initialization for efficiency.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
- private fun isSupported(minVersion: String): Boolean =
- forceEnableAll || (version != null && version >= DeviceVersion(minVersion))
+ private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
- /**
- * Ability to mute notifications from specific nodes via admin messages.
- *
- * Note: This is currently not available in firmware but defined here for future support.
- */
- val canMuteNode: Boolean
- get() = isSupported("2.7.18")
+ /** Ability to mute notifications from specific nodes via admin messages. */
+ val canMuteNode = atLeast(V2_7_18)
/** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
- val canRequestNeighborInfo: Boolean
- get() = isSupported("9.9.9")
+ val canRequestNeighborInfo = atLeast(UNRELEASED)
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
- val canSendVerifiedContacts: Boolean
- get() = isSupported("2.7.12")
+ val canSendVerifiedContacts = atLeast(V2_7_12)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
- val canToggleTelemetryEnabled: Boolean
- get() = isSupported("2.7.12")
+ val canToggleTelemetryEnabled = atLeast(V2_7_12)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
- val canToggleUnmessageable: Boolean
- get() = isSupported("2.6.9")
+ val canToggleUnmessageable = atLeast(V2_6_9)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
- val supportsQrCodeSharing: Boolean
- get() = isSupported("2.6.8")
+ val supportsQrCodeSharing = atLeast(V2_6_8)
/** Support for Status Message module. Supported since firmware v2.7.17. */
- val supportsStatusMessage: Boolean
- get() = isSupported("2.7.17")
+ val supportsStatusMessage = atLeast(V2_7_17)
+
+ /** Support for Traffic Management module. Supported since firmware v3.0.0. */
+ val supportsTrafficManagementConfig = atLeast(V3_0_0)
+
+ /** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
+ val supportsTakConfig = atLeast(V2_7_19)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
- val supportsSecondaryChannelLocation: Boolean
- get() = isSupported("2.6.10")
+ val supportsSecondaryChannelLocation = atLeast(V2_6_10)
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
- val supportsEsp32Ota: Boolean
- get() = isSupported("2.7.18")
+ val supportsEsp32Ota = atLeast(V2_7_18)
+
+ companion object {
+ private val V2_6_8 = DeviceVersion("2.6.8")
+ private val V2_6_9 = DeviceVersion("2.6.9")
+ private val V2_6_10 = DeviceVersion("2.6.10")
+ private val V2_7_12 = DeviceVersion("2.7.12")
+ private val V2_7_17 = DeviceVersion("2.7.17")
+ private val V2_7_18 = DeviceVersion("2.7.18")
+ private val V2_7_19 = DeviceVersion("2.7.19")
+ private val V3_0_0 = DeviceVersion("3.0.0")
+ private val UNRELEASED = DeviceVersion("9.9.9")
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
index 64d210f5d..d72d7775f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
@@ -21,15 +21,15 @@ import co.touchlab.kermit.Logger
/** Provide structured access to parse and compare device version strings */
data class DeviceVersion(val asString: String) : Comparable {
+ /** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
@Suppress("TooGenericExceptionCaught", "SwallowedException")
- val asInt
- get() =
- try {
- verStringToInt(asString)
- } catch (e: Exception) {
- Logger.w { "Exception while parsing version '$asString', assuming version 0" }
- 0
- }
+ val asInt: Int =
+ try {
+ verStringToInt(asString)
+ } catch (e: Exception) {
+ Logger.w { "Exception while parsing version '$asString', assuming version 0" }
+ 0
+ }
/**
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
@@ -51,5 +51,5 @@ data class DeviceVersion(val asString: String) : Comparable {
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
}
- override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
+ override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
}
diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index d3a43e392..7aba5f310 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -145,6 +145,10 @@ object SettingsRoutes {
@Serializable data object StatusMessage : Route
+ @Serializable data object TrafficManagement : Route
+
+ @Serializable data object TAK : Route
+
// endregion
// region advanced config routes
diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts
index 347c9d69a..b2e255c4a 100644
--- a/core/resources/build.gradle.kts
+++ b/core/resources/build.gradle.kts
@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
- android { androidResources.enable = true }
+ android {
+ androidResources.enable = true
+ withHostTest { isIncludeAndroidResources = true }
+ }
sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 7376bd0a0..b77231ac7 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1223,4 +1223,52 @@
Invalid name, URL template, or local URI for custom tile provider.
A custom tile provider with this name already exists.
Failed to copy MBTiles file to internal storage.
+
+ TAK (ATAK)
+ TAK Configuration
+ Team Color
+ Member Role
+
+ Unspecified
+ White
+ Yellow
+ Orange
+ Magenta
+ Red
+ Maroon
+ Purple
+ Dark Blue
+ Blue
+ Cyan
+ Teal
+ Green
+ Dark Green
+ Brown
+
+ Unspecified
+ Team Member
+ Team Lead
+ Headquarters
+ Sniper
+ Medic
+ Forward Observer
+ Radio Telephone Operator
+ Doggo (K9)
+
+ Traffic Management
+ Traffic Management Configuration
+ Module Enabled
+ Position Deduplication
+ Position Precision (bits)
+ Min Position Interval (secs)
+ NodeInfo Direct Response
+ Max Hops for Direct Response
+ Rate Limiting
+ Rate Limit Window (secs)
+ Max Packets in Window
+ Drop Unknown Packets
+ Unknown Packet Threshold
+ Local-only Telemetry (Relays)
+ Local-only Position (Relays)
+ Preserve Router Hops
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index f6b5e6e64..33a454635 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -38,6 +38,8 @@ import androidx.compose.runtime.remember
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.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,6 +53,7 @@ fun > DropDownPreference(
modifier: Modifier = Modifier,
summary: String? = null,
itemIcon: @Composable ((T) -> ImageVector)? = null,
+ itemColor: @Composable ((T) -> Color)? = null,
itemLabel: @Composable ((T) -> String)? = null,
) {
val enumConstants =
@@ -63,7 +66,8 @@ fun > DropDownPreference(
enumConstants.map {
val label = itemLabel?.invoke(it) ?: it.name
val icon = itemIcon?.invoke(it)
- DropDownItem(it, label, icon)
+ val color = itemColor?.invoke(it)
+ DropDownItem(it, label, icon, color)
}
DropDownPreference(
@@ -77,7 +81,7 @@ fun > DropDownPreference(
)
}
-data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null)
+data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null, val color: Color? = null)
@JvmName("DropDownPreferencePairs")
@Composable
@@ -141,7 +145,17 @@ fun DropDownPreference(
modifier = Modifier.size(24.dp),
)
}
- },
+ }
+ ?: currentItem?.color?.let {
+ {
+ Icon(
+ painter = ColorPainter(it),
+ contentDescription = currentItem.label,
+ modifier = Modifier.size(24.dp),
+ tint = Color.Unspecified,
+ )
+ }
+ },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(),
enabled = enabled,
@@ -157,8 +171,20 @@ fun DropDownPreference(
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
- selectionOption.icon?.let {
- Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp))
+ if (selectionOption.icon != null) {
+ Icon(
+ imageVector = selectionOption.icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ } else if (selectionOption.color != null) {
+ Icon(
+ painter = ColorPainter(selectionOption.color),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = Color.Unspecified,
+ )
Spacer(modifier = Modifier.width(12.dp))
}
Text(selectionOption.label)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
index cb96d573b..fd7eae24c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
@@ -49,8 +49,11 @@ import org.meshtastic.core.resources.remote_hardware
import org.meshtastic.core.resources.serial
import org.meshtastic.core.resources.status_message
import org.meshtastic.core.resources.store_forward
+import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.telemetry
+import org.meshtastic.core.resources.traffic_management
import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
enum class ModuleRoute(
@@ -59,6 +62,7 @@ enum class ModuleRoute(
val icon: ImageVector?,
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
+ val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
) {
MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -140,18 +144,51 @@ enum class ModuleRoute(
AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value,
isSupported = { it.supportsStatusMessage },
),
+ TRAFFIC_MANAGEMENT(
+ Res.string.traffic_management,
+ SettingsRoutes.TrafficManagement,
+ Icons.Rounded.Speed,
+ AdminMessage.ModuleConfigType.TRAFFICMANAGEMENT_CONFIG.value,
+ isSupported = { it.supportsTrafficManagementConfig },
+ ),
+ TAK(
+ Res.string.tak,
+ SettingsRoutes.TAK,
+ Icons.Rounded.People,
+ AdminMessage.ModuleConfigType.TAK_CONFIG.value,
+ isSupported = { it.supportsTakConfig },
+ isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
+ ),
;
val bitfield: Int
- get() = 1 shl ordinal
+ get() =
+ when (this) {
+ MQTT -> 0x0001
+ SERIAL -> 0x0002
+ EXT_NOTIFICATION -> 0x0004
+ STORE_FORWARD -> 0x0008
+ RANGE_TEST -> 0x0010
+ TELEMETRY -> 0x0020
+ CANNED_MESSAGE -> 0x0040
+ AUDIO -> 0x0080
+ REMOTE_HARDWARE -> 0x0100
+ NEIGHBOR_INFO -> 0x0200
+ AMBIENT_LIGHTING -> 0x0400
+ DETECTION_SENSOR -> 0x0800
+ PAXCOUNTER -> 0x1000
+ STATUS_MESSAGE -> 0x0000 // Not excludable yet
+ TRAFFIC_MANAGEMENT -> 0x0000 // Not excludable yet
+ TAK -> 0x0000 // Not excludable yet
+ }
companion object {
- fun filterExcludedFrom(metadata: DeviceMetadata?): List {
+ fun filterExcludedFrom(metadata: DeviceMetadata?, role: Config.DeviceConfig.Role?): List {
val capabilities = Capabilities(metadata?.firmware_version)
return entries.filter {
val excludedModules = metadata?.excluded_modules ?: 0
val isExcluded = (excludedModules and it.bitfield) != 0
- !isExcluded && it.isSupported(capabilities)
+ !isExcluded && it.isSupported(capabilities) && it.isApplicable(role)
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index e220b5c82..d84cad310 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
@@ -97,13 +95,15 @@ fun RadioConfigItemList(
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
- var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
+ var modules by remember {
+ mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
+ }
- LaunchedEffect(excludedModulesUnlocked) {
+ LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
if (excludedModulesUnlocked) {
modules = ModuleRoute.entries
} else {
- modules = ModuleRoute.filterExcludedFrom(state.metadata)
+ modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 3f9adf6ee..ec9d29c5c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -355,6 +355,8 @@ constructor(
detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management = config.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = config.tak ?: state.moduleConfig.tak,
),
)
}
@@ -591,6 +593,8 @@ constructor(
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
+ lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
+ lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
meshService?.commitEditSettings(destNum)
}
@@ -823,6 +827,9 @@ constructor(
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management =
+ response.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = response.tak ?: state.moduleConfig.tak,
),
)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
new file mode 100644
index 000000000..94b17c645
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.getColorFrom
+import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.tak
+import org.meshtastic.core.resources.tak_config
+import org.meshtastic.core.resources.tak_role
+import org.meshtastic.core.resources.tak_team
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.proto.ModuleConfig
+
+@Composable
+fun TAKConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
+ val formState = rememberConfigState(initialValue = takConfig)
+
+ LaunchedEffect(takConfig) { formState.value = takConfig }
+
+ RadioConfigScreenList(
+ title = stringResource(Res.string.tak),
+ onBack = onBack,
+ configState = formState,
+ enabled = state.connected,
+ responseState = state.responseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ val config = ModuleConfig(tak = it)
+ viewModel.setModuleConfig(config)
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.tak_config)) {
+ DropDownPreference(
+ title = stringResource(Res.string.tak_team),
+ enabled = state.connected,
+ selectedItem = formState.value.team,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ itemColor = { Color(getColorFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(team = it) },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.tak_role),
+ enabled = state.connected,
+ selectedItem = formState.value.role,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(role = it) },
+ )
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
new file mode 100644
index 000000000..c05ff42d1
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
@@ -0,0 +1,208 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.traffic_management
+import org.meshtastic.core.resources.traffic_management_config
+import org.meshtastic.core.resources.traffic_management_drop_unknown_enabled
+import org.meshtastic.core.resources.traffic_management_enabled
+import org.meshtastic.core.resources.traffic_management_exhaust_hop_position
+import org.meshtastic.core.resources.traffic_management_exhaust_hop_telemetry
+import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response
+import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response_max_hops
+import org.meshtastic.core.resources.traffic_management_position_dedup
+import org.meshtastic.core.resources.traffic_management_position_min_interval
+import org.meshtastic.core.resources.traffic_management_position_precision
+import org.meshtastic.core.resources.traffic_management_rate_limit_enabled
+import org.meshtastic.core.resources.traffic_management_rate_limit_max_packets
+import org.meshtastic.core.resources.traffic_management_rate_limit_window
+import org.meshtastic.core.resources.traffic_management_router_preserve_hops
+import org.meshtastic.core.resources.traffic_management_unknown_packet_threshold
+import org.meshtastic.core.ui.component.EditTextPreference
+import org.meshtastic.core.ui.component.SwitchPreference
+import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.proto.ModuleConfig
+
+@Suppress("LongMethod")
+@Composable
+fun TrafficManagementConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val tmConfig = state.moduleConfig.traffic_management ?: ModuleConfig.TrafficManagementConfig()
+ val formState = rememberConfigState(initialValue = tmConfig)
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(tmConfig) { formState.value = tmConfig }
+
+ RadioConfigScreenList(
+ title = stringResource(Res.string.traffic_management),
+ onBack = onBack,
+ configState = formState,
+ enabled = state.connected,
+ responseState = state.responseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ val config = ModuleConfig(traffic_management = it)
+ viewModel.setModuleConfig(config)
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.traffic_management_config)) {
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_enabled),
+ checked = formState.value.enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_position_dedup),
+ checked = formState.value.position_dedup_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(position_dedup_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_position_precision),
+ value = formState.value.position_precision_bits,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(position_precision_bits = it) },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_position_min_interval),
+ value = formState.value.position_min_interval_secs,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(position_min_interval_secs = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_nodeinfo_direct_response),
+ checked = formState.value.nodeinfo_direct_response,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(nodeinfo_direct_response = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_nodeinfo_direct_response_max_hops),
+ value = formState.value.nodeinfo_direct_response_max_hops,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(nodeinfo_direct_response_max_hops = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_enabled),
+ checked = formState.value.rate_limit_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(rate_limit_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_window),
+ value = formState.value.rate_limit_window_secs,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(rate_limit_window_secs = it) },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_max_packets),
+ value = formState.value.rate_limit_max_packets,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(rate_limit_max_packets = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_drop_unknown_enabled),
+ checked = formState.value.drop_unknown_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(drop_unknown_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_unknown_packet_threshold),
+ value = formState.value.unknown_packet_threshold,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(unknown_packet_threshold = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_exhaust_hop_telemetry),
+ checked = formState.value.exhaust_hop_telemetry,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_telemetry = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_exhaust_hop_position),
+ checked = formState.value.exhaust_hop_position,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_position = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_router_preserve_hops),
+ checked = formState.value.router_preserve_hops,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(router_preserve_hops = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ }
+ }
+ }
+}
From fdd07f893f9cec47edc9aabcdfe8273e333c3457 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:51:05 -0600
Subject: [PATCH 005/440] feat: settings rework (#4678)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../mesh/navigation/SettingsNavigation.kt | 41 ++-
.../repository/radio/RadioInterfaceService.kt | 8 +-
.../mesh/service/MeshCommandSender.kt | 4 +
.../geeksville/mesh/service/MeshService.kt | 8 +-
.../org/meshtastic/core/ble/BleConnection.kt | 6 +-
.../core/common/util/SequentialJob.kt | 25 +-
.../SwitchingNodeInfoReadDataSource.kt | 8 +-
.../SwitchingNodeInfoWriteDataSource.kt | 30 ++-
.../core/database/DatabaseManager.kt | 11 +-
.../org/meshtastic/core/navigation/Routes.kt | 6 +
.../composeResources/values/strings.xml | 1 +
.../core/service/ServiceRepository.kt | 2 +-
.../feature/node/metrics/NeighborInfoLog.kt | 3 +-
.../feature/node/metrics/TracerouteLog.kt | 3 +-
.../feature/settings/AdministrationScreen.kt | 191 ++++++++++++++
.../settings/DeviceConfigurationScreen.kt | 88 +++++++
.../settings/ModuleConfigurationScreen.kt | 99 +++++++
.../feature/settings/SettingsScreen.kt | 50 ++--
.../settings/filter/FilterSettingsScreen.kt | 6 +-
.../settings/navigation/ConfigRoute.kt | 2 +-
.../settings/radio/CleanNodeDatabaseScreen.kt | 8 +-
.../feature/settings/radio/RadioConfig.kt | 241 ++++++++----------
.../settings/radio/RadioConfigViewModel.kt | 25 +-
.../radio/channel/ChannelConfigScreen.kt | 32 ++-
.../radio/component/LoadingOverlay.kt | 97 +++++++
.../component/PacketResponseStateDialog.kt | 158 +++++++++---
.../radio/component/RadioConfigScreenList.kt | 94 +++----
27 files changed, 941 insertions(+), 306 deletions(-)
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index 18522c531..eacec7cb3 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -19,8 +19,11 @@
package com.geeksville.mesh.navigation
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
@@ -32,7 +35,11 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
+import org.meshtastic.feature.settings.AdministrationScreen
+import org.meshtastic.feature.settings.DeviceConfigurationScreen
+import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
+import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -76,6 +83,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
+ settingsViewModel = hiltViewModel(parentEntry),
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
@@ -84,10 +92,39 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}
},
) {
- navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
+ navController.navigate(it)
}
}
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ DeviceConfigurationScreen(
+ viewModel = hiltViewModel(parentEntry),
+ onBack = navController::popBackStack,
+ onNavigate = { route -> navController.navigate(route) },
+ )
+ }
+
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ val settingsViewModel: SettingsViewModel = hiltViewModel(parentEntry)
+ val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
+ ModuleConfigurationScreen(
+ viewModel = hiltViewModel(parentEntry),
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ onBack = navController::popBackStack,
+ onNavigate = { route -> navController.navigate(route) },
+ )
+ }
+
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ AdministrationScreen(viewModel = hiltViewModel(parentEntry), onBack = navController::popBackStack)
+ }
+
composable(
deepLinks =
listOf(
@@ -104,6 +141,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
+ LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
@@ -133,6 +171,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
+ LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index 7d1ebfbd5..0e7215d5c 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
@@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow = _connectionState.asStateFlow()
- private val _receivedData = simpleSharedFlow()
+ private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64)
val receivedData: SharedFlow = _receivedData
- private val _connectionError = simpleSharedFlow()
+ private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
val connectionError: SharedFlow = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
@@ -371,7 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}
- private val _meshActivity = simpleSharedFlow()
+ private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64)
val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 48497a762..3b36c9e19 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -94,6 +94,10 @@ constructor(
radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
}
+ fun getCachedLocalConfig(): LocalConfig = localConfig.value
+
+ fun getCachedChannelSet(): ChannelSet = channelSet.value
+
@VisibleForTesting internal constructor() : this(null, null, null, null)
fun getCurrentPacketId(): Long = currentPacketId.get()
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index db1a6066f..2f01f3368 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -31,10 +31,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
@@ -249,9 +247,7 @@ class MeshService : Service() {
override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
- override fun getConfig(): ByteArray = toRemoteExceptions {
- runBlocking { radioConfigRepository.localConfigFlow.first().encode() }
- }
+ override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() }
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetConfig(payload, myNodeNum)
@@ -310,7 +306,7 @@ class MeshService : Service() {
}
override fun getChannelSet(): ByteArray = toRemoteExceptions {
- runBlocking { radioConfigRepository.channelSetFlow.first().encode() }
+ commandSender.getCachedChannelSet().encode()
}
override fun getNodes(): List = nodeManager.getNodes()
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
index 0e3982421..1ec635cc6 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
@@ -72,7 +74,7 @@ class BleConnection(
*
* @param p The peripheral to connect to.
*/
- suspend fun connect(p: Peripheral) {
+ suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p
@@ -156,7 +158,7 @@ class BleConnection(
}
/** Disconnects from the current peripheral. */
- suspend fun disconnect() {
+ suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
index 730252c62..564c66515 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
@@ -16,8 +16,11 @@
*/
package org.meshtastic.core.common.util
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@@ -26,15 +29,31 @@ import javax.inject.Inject
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
- private val job = AtomicReference(null)
+ private val job = AtomicReference()
/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
+ *
+ * @param timeoutMs Optional timeout in milliseconds. If > 0, the [block] is wrapped in [withTimeout] so that
+ * indefinitely-suspended coroutines (e.g. blocked DataStore reads) throw [TimeoutCancellationException] instead
+ * of hanging silently.
*/
- fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
+ fun launch(scope: CoroutineScope, timeoutMs: Long = 0, block: suspend CoroutineScope.() -> Unit) {
cancel()
- val newJob = scope.handledLaunch(block = block)
+ val newJob =
+ scope.handledLaunch {
+ if (timeoutMs > 0) {
+ try {
+ withTimeout(timeoutMs, block)
+ } catch (e: TimeoutCancellationException) {
+ Logger.w { "SequentialJob timed out after ${timeoutMs}ms" }
+ throw e
+ }
+ } else {
+ block()
+ }
+ }
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
index 622da459a..35d9c0848 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.flow.Flow
@@ -54,7 +53,8 @@ class SwitchingNodeInfoReadDataSource @Inject constructor(private val dbManager:
}
override suspend fun getNodesOlderThan(lastHeard: Int): List =
- dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) }
+ dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList()
- override suspend fun getUnknownNodes(): List = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() }
+ override suspend fun getUnknownNodes(): List =
+ dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList()
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
index c201cab03..6b5501910 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
@@ -33,33 +33,43 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {
- override suspend fun upsert(node: NodeEntity) =
+ override suspend fun upsert(node: NodeEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
+ }
- override suspend fun installConfig(mi: MyNodeEntity, nodes: List) =
+ override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
+ }
- override suspend fun clearNodeDB(preserveFavorites: Boolean) =
+ override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
+ }
- override suspend fun clearMyNodeInfo() =
+ override suspend fun clearMyNodeInfo() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
+ }
- override suspend fun deleteNode(num: Int) =
+ override suspend fun deleteNode(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
+ }
- override suspend fun deleteNodes(nodeNums: List) =
+ override suspend fun deleteNodes(nodeNums: List) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
+ }
- override suspend fun deleteMetadata(num: Int) =
+ override suspend fun deleteMetadata(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
+ }
- override suspend fun upsert(metadata: MetadataEntity) =
+ override suspend fun upsert(metadata: MetadataEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
+ }
- override suspend fun setNodeNotes(num: Int, notes: String) =
+ override suspend fun setNodeNotes(num: Int, notes: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
+ }
- override suspend fun backfillDenormalizedNames() =
+ override suspend fun backfillDenormalizedNames() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
+ }
}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 7754211bb..3ae7d49f7 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -23,6 +23,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -44,6 +45,7 @@ import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@Suppress("TooManyFunctions")
+@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@@ -114,8 +116,15 @@ class DatabaseManager @Inject constructor(private val app: Application, private
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
+ private val limitedIo = dispatchers.io.limitedParallelism(4)
+
/** Execute [block] with the current DB instance. */
- inline fun withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
+ suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
+ val active = _currentDb.value?.openHelper?.databaseName ?: return@withContext null
+ markLastUsed(active)
+ val db = _currentDb.value ?: return@withContext null // Use the cached current DB
+ block(db)
+ }
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {
diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index 7aba5f310..660a20e4e 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -91,6 +91,12 @@ object SettingsRoutes {
@Serializable data class Settings(val destNum: Int? = null) : Route
+ @Serializable data object DeviceConfiguration : Route
+
+ @Serializable data object ModuleConfiguration : Route
+
+ @Serializable data object Administration : Route
+
// region radio Config Routes
@Serializable data object User : Route
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b77231ac7..11695a4c3 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -338,6 +338,7 @@
Direct Message
NodeDB reset
Delivery confirmed
+ Your device may disconnect and reboot while settings are applied.
Error
Ignore
Remove from ignored
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 2137061f3..77f2b49c0 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -98,7 +98,7 @@ class ServiceRepository @Inject constructor() {
}
}
- private val _meshPacketFlow = MutableSharedFlow()
+ private val _meshPacketFlow = MutableSharedFlow(extraBufferCapacity = 64)
val meshPacketFlow: SharedFlow
get() = _meshPacketFlow
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
index d626be2d4..006e02fcf 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -90,7 +89,7 @@ fun NeighborInfoLogScreen(
Scaffold(
topBar = {
- val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
+ val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.neighbor_info),
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
index dcadc596d..1fdd5cf5b 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -111,7 +110,7 @@ fun TracerouteLogScreen(
Scaffold(
topBar = {
- val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
+ val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.traceroute_log),
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
new file mode 100644
index 000000000..1d5c16f4e
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
@@ -0,0 +1,191 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.administration
+import org.meshtastic.core.resources.preserve_favorites
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.radio.AdminRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigState
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.ResponseState
+import org.meshtastic.feature.settings.radio.component.LoadingOverlay
+import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
+import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
+import org.meshtastic.feature.settings.radio.component.WarningDialog
+
+@Composable
+fun AdministrationScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+ val enabled = state.connected && !state.responseState.isWaiting()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.administration),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(
+ title = stringResource(Res.string.administration),
+ titleColor = MaterialTheme.colorScheme.error,
+ ) {
+ AdminRouteItems(viewModel = viewModel, enabled = enabled, state = state, destNode = destNode)
+ }
+ }
+ }
+
+ LoadingOverlay(state = state.responseState)
+
+ if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
+ PacketResponseStateDialog(
+ state = state.responseState,
+ onDismiss = { viewModel.clearPacketResponse() },
+ onComplete = {
+ viewModel.clearPacketResponse()
+ onBack()
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun AdminRouteItems(
+ viewModel: RadioConfigViewModel,
+ enabled: Boolean,
+ state: RadioConfigState,
+ destNode: Node?,
+) {
+ AdminRoute.entries.forEach { route ->
+ var showDialog by remember { mutableStateOf(false) }
+ if (showDialog) {
+ AdminActionDialog(
+ route = route,
+ destNode = destNode,
+ enabled = enabled,
+ state = state,
+ onDismiss = { showDialog = false },
+ onConfirm = { viewModel.setResponseStateLoading(route) },
+ onPreserveFavoritesChange = { viewModel.setPreserveFavorites(it) },
+ )
+ }
+
+ ListItem(
+ enabled = enabled,
+ text = stringResource(route.title),
+ leadingIcon = route.icon,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIcon = null,
+ ) {
+ showDialog = true
+ }
+ }
+}
+
+@Composable
+private fun AdminActionDialog(
+ route: AdminRoute,
+ destNode: Node?,
+ enabled: Boolean,
+ state: RadioConfigState,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+ onPreserveFavoritesChange: (Boolean) -> Unit,
+) {
+ if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
+ ShutdownConfirmationDialog(
+ title = "${stringResource(route.title)}?",
+ node = destNode,
+ onDismiss = onDismiss,
+ isShutdown = route == AdminRoute.SHUTDOWN,
+ onConfirm = onConfirm,
+ )
+ } else {
+ WarningDialog(
+ title = "${stringResource(route.title)}?",
+ text = {
+ if (route == AdminRoute.NODEDB_RESET) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = stringResource(Res.string.preserve_favorites))
+ Switch(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ enabled = enabled,
+ checked = state.nodeDbResetPreserveFavorites,
+ onCheckedChange = onPreserveFavoritesChange,
+ )
+ }
+ }
+ },
+ onDismiss = onDismiss,
+ onConfirm = onConfirm,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
new file mode 100644
index 000000000..77dc42419
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.navigation.Route
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device_configuration
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.navigation.ConfigRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+
+@Composable
+fun DeviceConfigurationScreen(
+ viewModel: RadioConfigViewModel = hiltViewModel(),
+ onBack: () -> Unit,
+ onNavigate: (Route) -> Unit,
+) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.device_configuration),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
+ ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
+ ListItem(
+ text = stringResource(it.title),
+ leadingIcon = it.icon,
+ enabled = state.connected && !state.responseState.isWaiting(),
+ ) {
+ onNavigate(it.route)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
new file mode 100644
index 000000000..630d19c0b
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.navigation.Route
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.module_settings
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.navigation.ModuleRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+
+@Composable
+fun ModuleConfigurationScreen(
+ viewModel: RadioConfigViewModel = hiltViewModel(),
+ excludedModulesUnlocked: Boolean = false,
+ onBack: () -> Unit,
+ onNavigate: (Route) -> Unit,
+) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+
+ val modules =
+ remember(state.metadata, excludedModulesUnlocked) {
+ if (excludedModulesUnlocked) {
+ ModuleRoute.entries
+ } else {
+ ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.module_settings),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
+ modules.forEach {
+ ListItem(
+ text = stringResource(it.title),
+ leadingIcon = it.icon,
+ enabled = state.connected && !state.responseState.isWaiting(),
+ ) {
+ onNavigate(it.route)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 1887edbb3..bd5ebc655 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -59,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -106,14 +106,14 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
-import org.meshtastic.feature.settings.navigation.getNavRouteFrom
+import org.meshtastic.feature.settings.navigation.ConfigRoute
+import org.meshtastic.feature.settings.navigation.ModuleRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
-import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
@@ -125,8 +125,8 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
- settingsViewModel: SettingsViewModel = hiltViewModel(),
- viewModel: RadioConfigViewModel = hiltViewModel(),
+ settingsViewModel: SettingsViewModel,
+ viewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
@@ -137,23 +137,6 @@ fun SettingsScreen(
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
- var isWaiting by remember { mutableStateOf(false) }
- if (isWaiting) {
- PacketResponseStateDialog(
- state = state.responseState,
- onDismiss = {
- isWaiting = false
- viewModel.clearPacketResponse()
- },
- onComplete = {
- getNavRouteFrom(state.route)?.let { route ->
- isWaiting = false
- viewModel.clearPacketResponse()
- onNavigate(route)
- }
- },
- )
- }
var deviceProfile by remember { mutableStateOf(null) }
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
@@ -241,17 +224,22 @@ fun SettingsScreen(
)
},
) { paddingValues ->
- Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
- node = destNode,
- excludedModulesUnlocked = excludedModulesUnlocked,
isOtaCapable = isOtaCapable,
- onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
onRouteClick = { route ->
- isWaiting = true
- viewModel.setResponseStateLoading(route)
+ val navRoute =
+ when (route) {
+ is ConfigRoute -> route.route
+ is ModuleRoute -> route.route
+ else -> null
+ }
+ navRoute?.let { onNavigate(it) }
},
onImport = {
viewModel.clearPacketResponse()
@@ -273,7 +261,7 @@ fun SettingsScreen(
val context = LocalContext.current
- TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
@@ -434,7 +422,7 @@ fun SettingsScreen(
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
- trailingIcon = null,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.About)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
index 9a2c1f0ee..0c8737e52 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
@@ -37,7 +37,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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
@@ -47,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
@@ -64,8 +64,8 @@ import org.meshtastic.core.ui.component.MainAppBar
@Composable
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
- val filterEnabled by viewModel.filterEnabled.collectAsState()
- val filterWords by viewModel.filterWords.collectAsState()
+ val filterEnabled by viewModel.filterEnabled.collectAsStateWithLifecycle()
+ val filterWords by viewModel.filterWords.collectAsStateWithLifecycle()
var newWord by remember { mutableStateOf("") }
Scaffold(
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
index 1821fd6c3..9c6bb2cc8 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
@@ -93,7 +93,7 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im
}
}
- val radioConfigRoutes = listOf(LORA, CHANNELS, SECURITY)
+ val radioConfigRoutes = listOf(USER, LORA, CHANNELS, SECURITY)
fun deviceConfigRoutes(metadata: DeviceMetadata?): List =
filterExcludedFrom(metadata) - radioConfigRoutes
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
index 06c5a853b..51ca46704 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
@@ -33,12 +33,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.resources.Res
@@ -56,9 +56,9 @@ import org.meshtastic.core.ui.component.NodeChip
*/
@Composable
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
- val olderThanDays by viewModel.olderThanDays.collectAsState()
- val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
- val nodesToDelete by viewModel.nodesToDelete.collectAsState()
+ val olderThanDays by viewModel.olderThanDays.collectAsStateWithLifecycle()
+ val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsStateWithLifecycle()
+ val nodesToDelete by viewModel.nodesToDelete.collectAsStateWithLifecycle()
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index d84cad310..b87987539 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -18,37 +18,37 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.AdminPanelSettings
+import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Restore
+import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-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.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@@ -66,18 +66,13 @@ import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.message_device_managed
import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.nodedb_reset
-import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.radio_configuration
import org.meshtastic.core.resources.reboot
import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.settings.navigation.ConfigRoute
-import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
-import org.meshtastic.feature.settings.radio.component.WarningDialog
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -85,30 +80,16 @@ import org.meshtastic.feature.settings.radio.component.WarningDialog
fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
- node: Node? = null,
- excludedModulesUnlocked: Boolean = false,
isOtaCapable: Boolean = false,
- onPreserveFavoritesToggle: (Boolean) -> Unit = {},
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
- var modules by remember {
- mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
- }
- LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
- if (excludedModulesUnlocked) {
- modules = ModuleRoute.entries
- } else {
- modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
- }
- }
-
- Column {
- TitledCard(title = stringResource(Res.string.radio_configuration)) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
@@ -117,126 +98,122 @@ fun RadioConfigItemList(
}
}
- TitledCard(title = stringResource(Res.string.device_configuration), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
if (isManaged) {
ManagedMessage()
}
- ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
-
- TitledCard(title = stringResource(Res.string.module_settings), modifier = Modifier.padding(top = 16.dp)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- modules.forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
- }
-
- if (state.isLocal) {
- TitledCard(title = stringResource(Res.string.backup_restore), modifier = Modifier.padding(top = 16.dp)) {
- if (isManaged) {
- ManagedMessage()
- }
-
ListItem(
- text = stringResource(Res.string.import_configuration),
- leadingIcon = Icons.Rounded.Download,
+ text = stringResource(Res.string.device_configuration),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
- onClick = onImport,
- )
- ListItem(
- text = stringResource(Res.string.export_configuration),
- leadingIcon = Icons.Rounded.Upload,
- enabled = enabled,
- onClick = onExport,
- )
- }
- }
-
- TitledCard(title = stringResource(Res.string.administration), modifier = Modifier.padding(top = 16.dp)) {
- AdminRoute.entries.forEach { route ->
- var showDialog by remember { mutableStateOf(false) }
- if (showDialog) {
- // Use enhanced confirmation for SHUTDOWN and REBOOT
- if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
- ShutdownConfirmationDialog(
- title = "${stringResource(route.title)}?",
- node = node,
- onDismiss = { showDialog = false },
- isShutdown = route == AdminRoute.SHUTDOWN,
- onConfirm = { onRouteClick(route) },
- )
- } else {
- WarningDialog(
- title = "${stringResource(route.title)}?",
- text = {
- if (route == AdminRoute.NODEDB_RESET) {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(text = stringResource(Res.string.preserve_favorites))
- Switch(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- enabled = enabled,
- checked = state.nodeDbResetPreserveFavorites,
- onCheckedChange = onPreserveFavoritesToggle,
- )
- }
- }
- },
- onDismiss = { showDialog = false },
- onConfirm = { onRouteClick(route) },
- )
- }
- }
-
- ListItem(
- enabled = enabled,
- text = stringResource(route.title),
- leadingIcon = route.icon,
- trailingIcon = null,
) {
- showDialog = true
+ onNavigate(SettingsRoutes.DeviceConfiguration)
}
}
- }
- if (state.isLocal) {
- TitledCard(title = stringResource(Res.string.advanced_title), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
if (isManaged) {
ManagedMessage()
}
+ ListItem(
+ text = stringResource(Res.string.module_settings),
+ leadingIcon = Icons.Rounded.Settings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.ModuleConfiguration)
+ }
+ }
+
+ if (state.isLocal) {
+ ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
- if (isOtaCapable) {
ListItem(
- text = stringResource(Res.string.firmware_update_title),
- leadingIcon = Icons.Rounded.SystemUpdate,
+ text = stringResource(Res.string.import_configuration),
+ leadingIcon = Icons.Rounded.Download,
enabled = enabled,
- onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ onClick = onImport,
+ )
+ ListItem(
+ text = stringResource(Res.string.export_configuration),
+ leadingIcon = Icons.Rounded.Upload,
+ enabled = enabled,
+ onClick = onExport,
)
}
-
- ListItem(
- text = stringResource(Res.string.clean_node_database_title),
- leadingIcon = Icons.Rounded.CleaningServices,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
- )
-
- ListItem(
- text = stringResource(Res.string.debug_panel),
- leadingIcon = Icons.Rounded.BugReport,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.DebugPanel) },
- )
}
+
+ ExpressiveSection(title = stringResource(Res.string.administration)) {
+ ListItem(
+ text = stringResource(Res.string.administration),
+ leadingIcon = Icons.Rounded.AdminPanelSettings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIconTint = MaterialTheme.colorScheme.error,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.Administration)
+ }
+ }
+
+ if (state.isLocal) {
+ ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ if (isOtaCapable) {
+ ListItem(
+ text = stringResource(Res.string.firmware_update_title),
+ leadingIcon = Icons.Rounded.SystemUpdate,
+ enabled = enabled,
+ onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ )
+ }
+
+ ListItem(
+ text = stringResource(Res.string.clean_node_database_title),
+ leadingIcon = Icons.Rounded.CleaningServices,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
+ )
+
+ ListItem(
+ text = stringResource(Res.string.debug_panel),
+ leadingIcon = Icons.Rounded.BugReport,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.DebugPanel) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ExpressiveSection(
+ title: String,
+ modifier: Modifier = Modifier,
+ titleColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = titleColor,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ content = content,
+ )
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index ec9d29c5c..2cb947c8f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -181,6 +181,12 @@ constructor(
.onEach { lc -> if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(radioConfig = lc) } }
.launchIn(viewModelScope)
+ radioConfigRepository.channelSetFlow
+ .onEach { cs ->
+ if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(channelList = cs.settings) }
+ }
+ .launchIn(viewModelScope)
+
radioConfigRepository.moduleConfigFlow
.onEach { lmc ->
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(moduleConfig = lmc) }
@@ -608,16 +614,7 @@ constructor(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
- _radioConfigState.update {
- RadioConfigState(
- isLocal = it.isLocal,
- connected = it.connected,
- route = route.name,
- metadata = it.metadata,
- nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
- responseState = ResponseState.Loading(),
- )
- }
+ _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {
ConfigRoute.USER -> getOwner(destNum)
@@ -862,6 +859,14 @@ constructor(
sendAdminRequest(destNum)
}
requestIds.update { it.apply { remove(data.request_id) } }
+
+ if (requestIds.value.isEmpty()) {
+ if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
+ clearPacketResponse()
+ } else if (route.isEmpty()) {
+ setResponseStateSuccess()
+ }
+ }
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
index 5915c54aa..30c5c8214 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
@@ -67,11 +67,13 @@ import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
+import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@@ -80,20 +82,24 @@ import org.meshtastic.proto.Config
fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
- if (state.responseState.isWaiting()) {
- PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
- }
+ Box(modifier = Modifier.fillMaxSize()) {
+ ChannelConfigScreen(
+ title = stringResource(Res.string.channels),
+ onBack = onBack,
+ settingsList = state.channelList,
+ loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
+ maxChannels = viewModel.maxChannels,
+ firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
+ enabled = state.connected,
+ onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
+ )
- ChannelConfigScreen(
- title = stringResource(Res.string.channels),
- onBack = onBack,
- settingsList = state.channelList,
- loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
- maxChannels = viewModel.maxChannels,
- firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
- enabled = state.connected,
- onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
- )
+ LoadingOverlay(state = state.responseState)
+
+ if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
+ PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
+ }
+ }
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
new file mode 100644
index 000000000..18ade8df5
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.meshtastic.feature.settings.radio.ResponseState
+
+private const val LOADING_OVERLAY_ALPHA = 0.8f
+private const val PERCENTAGE_FACTOR = 100
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
+ AnimatedVisibility(visible = state is ResponseState.Loading, enter = fadeIn(), exit = fadeOut()) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = LOADING_OVERLAY_ALPHA))
+ .clickable(enabled = false) {},
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp).fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ if (state is ResponseState.Loading) {
+ val progress by
+ animateFloatAsState(
+ targetValue = state.completed.toFloat() / state.total.toFloat(),
+ label = "loading_progress",
+ )
+
+ Box(contentAlignment = Alignment.Center) {
+ CircularWavyProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(80.dp),
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ Text(
+ text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ state.status?.let { status ->
+ Text(
+ text = status,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
index 366f8669c..1f7e42681 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
@@ -18,10 +18,17 @@ package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -29,19 +36,23 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delivery_confirmed
+import org.meshtastic.core.resources.delivery_confirmed_reboot_warning
import org.meshtastic.core.resources.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
@@ -49,54 +60,139 @@ fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit
if (state is ResponseState.Success) {
delay(AUTO_DISMISS_DELAY_MS)
onDismiss()
+ backDispatcher?.onBackPressed()
}
}
MeshtasticDialog(
- onDismiss = onDismiss,
- title = "", // Title is handled in the text block for more control
+ onDismiss = if (state is ResponseState.Loading) onDismiss else null,
+ title = null,
+ icon = null,
text = {
- Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- if (state is ResponseState.Loading) {
- val progress by
- animateFloatAsState(
- targetValue = state.completed.toFloat() / state.total.toFloat(),
- label = "progress",
- )
- Text("%.0f%%".format(progress * 100))
- LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
- state.status?.let {
- Text(
- text = it,
- modifier = Modifier.padding(top = 8.dp),
- style = MaterialTheme.typography.bodySmall,
- )
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ when (state) {
+ is ResponseState.Loading -> {
+ LoadingContent(state = state, onComplete = onComplete)
}
- if (state.completed >= state.total) onComplete()
- }
- if (state is ResponseState.Success) {
- Text(text = stringResource(Res.string.delivery_confirmed))
- }
- if (state is ResponseState.Error) {
- Text(text = stringResource(Res.string.error), minLines = 2)
- Text(text = state.error.asString())
+ is ResponseState.Success -> {
+ SuccessContent()
+ }
+ is ResponseState.Error -> {
+ ErrorContent(state = state)
+ }
+ ResponseState.Empty -> {}
}
}
},
dismissable = false,
- onConfirm = {
- onDismiss()
- if (state is ResponseState.Success || state is ResponseState.Error) {
+ onConfirm =
+ if (state !is ResponseState.Loading) {
+ {
+ onDismiss()
backDispatcher?.onBackPressed()
}
+ } else {
+ null
},
confirmText = stringResource(Res.string.close),
- dismissText = null, // Hide dismiss button, only show "Close" confirm button
+ dismissText = if (state is ResponseState.Loading) stringResource(Res.string.cancel) else null,
)
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) {
+ val progress by
+ animateFloatAsState(targetValue = state.completed.toFloat() / state.total.toFloat(), label = "progress")
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "%.0f%%".format(progress * 100),
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ LinearWavyProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ state.status?.let {
+ Text(
+ text = it,
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ if (state.completed >= state.total) onComplete()
+}
+
+@Composable
+private fun SuccessContent() {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(84.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(Res.string.delivery_confirmed),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = stringResource(Res.string.delivery_confirmed_reboot_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ErrorContent(state: ResponseState.Error) {
+ Icon(
+ imageVector = Icons.Filled.Error,
+ contentDescription = null,
+ modifier = Modifier.size(84.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(Res.string.error),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ text = "${state.error.asString()}.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
@Preview(showBackground = true)
@Composable
-private fun PacketResponseStateDialogPreview() {
+private fun PacketResponseStateDialogLoadingPreview() {
PacketResponseStateDialog(state = ResponseState.Loading(total = 17, completed = 5))
}
+
+@Preview(showBackground = true)
+@Composable
+private fun PacketResponseStateDialogSuccessPreview() {
+ PacketResponseStateDialog(state = ResponseState.Success(Unit))
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PacketResponseStateDialogErrorPreview() {
+ PacketResponseStateDialog(
+ state = ResponseState.Error(org.meshtastic.core.resources.UiText.DynamicString("Failed to send packet")),
+ )
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
index 71b5ffb41..15396a60b 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
@@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -58,55 +59,58 @@ fun > RadioConfigScreenList(
) {
val focusManager = LocalFocusManager.current
- if (responseState.isWaiting()) {
- PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
- }
+ Box(modifier = modifier) {
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = title,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ ourNode = null,
+ showNodeChip = false,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { innerPadding ->
+ val showFooterButtons = configState.isDirty || additionalDirtyCheck()
- Scaffold(
- modifier = modifier,
- topBar = {
- MainAppBar(
- title = title,
- canNavigateUp = true,
- onNavigateUp = onBack,
- ourNode = null,
- showNodeChip = false,
- actions = {},
- onClickChip = {},
- )
- },
- ) { innerPadding ->
- val showFooterButtons = configState.isDirty || additionalDirtyCheck()
+ LazyColumn(
+ modifier = Modifier.padding(innerPadding).fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ content()
- LazyColumn(
- modifier = Modifier.padding(innerPadding).fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- content()
-
- item {
- AnimatedVisibility(
- visible = showFooterButtons,
- enter = fadeIn() + expandIn(),
- exit = fadeOut() + shrinkOut(),
- ) {
- PreferenceFooter(
- enabled = enabled && showFooterButtons,
- negativeText = stringResource(Res.string.discard_changes),
- onNegativeClicked = {
- focusManager.clearFocus()
- configState.reset()
- onDiscard()
- },
- positiveText = stringResource(Res.string.save_changes),
- onPositiveClicked = {
- focusManager.clearFocus()
- onSave(configState.value)
- },
- )
+ item {
+ AnimatedVisibility(
+ visible = showFooterButtons,
+ enter = fadeIn() + expandIn(),
+ exit = fadeOut() + shrinkOut(),
+ ) {
+ PreferenceFooter(
+ enabled = enabled && showFooterButtons,
+ negativeText = stringResource(Res.string.discard_changes),
+ onNegativeClicked = {
+ focusManager.clearFocus()
+ configState.reset()
+ onDiscard()
+ },
+ positiveText = stringResource(Res.string.save_changes),
+ onPositiveClicked = {
+ focusManager.clearFocus()
+ onSave(configState.value)
+ },
+ )
+ }
}
}
}
+
+ LoadingOverlay(state = responseState)
+
+ if (responseState is ResponseState.Success || responseState is ResponseState.Error) {
+ PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
+ }
}
}
From 362ab6357c0f94811a17c8d3e6a6b2c7e1cb51ae Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:52:29 -0600
Subject: [PATCH 006/440] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4672)
---
app/src/main/assets/device_hardware.json | 42 -----
app/src/main/assets/firmware_releases.json | 6 +
.../composeResources/values-be/strings.xml | 3 +
.../composeResources/values-bg/strings.xml | 4 +
.../composeResources/values-cs/strings.xml | 3 +
.../composeResources/values-de/strings.xml | 6 +
.../composeResources/values-el/strings.xml | 3 +
.../composeResources/values-es/strings.xml | 3 +
.../composeResources/values-et/strings.xml | 57 +++++++
.../composeResources/values-fi/strings.xml | 57 +++++++
.../composeResources/values-fr/strings.xml | 4 +
.../composeResources/values-hr/strings.xml | 1 +
.../composeResources/values-hu/strings.xml | 3 +
.../composeResources/values-it/strings.xml | 4 +
.../composeResources/values-ja/strings.xml | 160 ++++++++++++++++++
.../composeResources/values-ko/strings.xml | 3 +
.../composeResources/values-lt/strings.xml | 1 +
.../composeResources/values-nl/strings.xml | 3 +
.../composeResources/values-pl/strings.xml | 4 +
.../values-pt-rBR/strings.xml | 3 +
.../composeResources/values-pt/strings.xml | 3 +
.../composeResources/values-ro/strings.xml | 3 +
.../composeResources/values-ru/strings.xml | 50 ++++++
.../composeResources/values-sk/strings.xml | 3 +
.../composeResources/values-sv/strings.xml | 4 +
.../composeResources/values-tr/strings.xml | 3 +
.../composeResources/values-uk/strings.xml | 3 +
.../values-zh-rCN/strings.xml | 4 +
.../values-zh-rTW/strings.xml | 46 ++++-
29 files changed, 446 insertions(+), 43 deletions(-)
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 71143aa72..0699ff16b 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1349,47 +1349,5 @@
"images": [
"tbeam-1w.svg"
]
- },
- {
- "hwModel": 123,
- "hwModelSlug": "T5_S3_EPAPER_PRO",
- "platformioTarget": "t5-s3-epaper-pro",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T5 S3 ePaper Pro",
- "tags": [
- "LilyGo"
- ],
- "hasMui": true,
- "partitionScheme": "8MB"
- },
- {
- "hwModel": 124,
- "hwModelSlug": "TBEAM_BPF",
- "platformioTarget": "tbeam-bpf",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T-Beam BPF",
- "tags": [
- "LilyGo"
- ],
- "hasMui": false,
- "partitionScheme": "8MB"
- },
- {
- "hwModel": 125,
- "hwModelSlug": "MINI_EPAPER_S3",
- "platformioTarget": "mini-epaper-s3",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T-Mini E-paper S3 Kit",
- "tags": [
- "LilyGo"
- ],
- "hasMui": true,
- "partitionScheme": "8MB"
}
]
\ No newline at end of file
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 01aeacbf8..dcb81f56d 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -199,6 +199,12 @@
"title": "Add VL53L0 distance sensor.",
"page_url": "https://github.com/meshtastic/firmware/pull/9706",
"zip_url": "https://discord.com/invite/meshtastic"
+ },
+ {
+ "id": "9675",
+ "title": "add FromRadioSync BLE characteristic",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9675",
+ "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml
index 9690b226f..7cfe00f42 100644
--- a/core/resources/src/commonMain/composeResources/values-be/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml
@@ -245,4 +245,7 @@
Усе
Bluetooth
+ Чырвоны
+ Сіні
+ Зялёны
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 1444d7c83..7954340a3 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -865,4 +865,8 @@
Bluetooth
Конфигурация
+ Червен
+ Син
+ Зелен
+ Модулът е активиран
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index cebaeb12c..1d170a23b 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -962,4 +962,7 @@
Vše
Bluetooth
+ Červená
+ Modrá
+ Zelená
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index e7c820b54..a3f76112b 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -1168,4 +1168,10 @@
Ungültiger Name, URL oder lokale URI für benutzerdefinierten Kachelanbieter.
Ein benutzerdefinierter Kachelanbieter mit diesem Namen existiert bereits.
Fehler beim Kopieren der MB Kacheldatei in den internen Speicher.
+ Unspecified
+ Rot
+ Blau
+ Grün
+ Unspecified
+ Modul aktiviert
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index 6b59874e3..25abf61a7 100644
--- a/core/resources/src/commonMain/composeResources/values-el/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml
@@ -207,4 +207,7 @@
Bluetooth
+ Κόκκινο
+ Μπλε
+ Πράσινο
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index 56e33a948..8ddff0aaf 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -911,4 +911,7 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Bluetooth
Configuración
+ Rojo
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index ef364a6b2..e07369ea9 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -933,7 +933,9 @@
Maastik
Hübriid
Halda kaardikihte
+ Kaardikihid toetavad .kml, .kmz või GeoJSON vorminguid.
Kaardikihid
+ Kaardikihte pole laetud.
Lisa kiht
Peida kiht
Näita kiht
@@ -942,6 +944,10 @@
Sõlmed siin asukohas
Vali kaardi tüüp
Halda kohandatud kardikihti
+ Lisa võrgupaani allikas
+ Kohandatud paanide allikaid ei leitud.
+ Muuda võrgupaani allikat
+ Kustuta võrgupaani allikas
Nimi ei tohi olla tühi.
Teenusepakkuja nimi on olemas.
URL ei tohi olla tühi.
@@ -1155,4 +1161,55 @@
Värskenda
Uuendatud
+ Lisa kaardikiht
+ Värskenda kihti
+ Kohalik MB-paani fail
+ Lisa kohalik MB-paani fail
+ Kohandatud paanipakkuja nimi, URL-i mall või kohalik URL on sobimatu.
+ Selle nimega kohandatud paanipakkuja on juba olemas.
+ MB-paanifaili kopeerimine sisemällu ebaõnnestus.
+ TAK (ATAK)
+ TAK-i sätted
+ Meeskonna värv
+ Liikme roll
+ Määramata
+ Valge
+ Kollane
+ Oranž
+ Fukspunane
+ Punane
+ Kastanpruun
+ Lilla
+ Tume sinine
+ Sinine
+ Tsüaan
+ Sinakasroheline
+ Roheline
+ Tume roheline
+ Pruun
+ Määramata
+ Meeskonnaliige
+ Meeskonna ülem
+ Peakorter
+ Snaiper
+ Meedik
+ Luure
+ Sidemees
+ Koer (K9)
+ Liikluskorraldus
+ Läbilaskepunkt
+ Moodul lubatud
+ Positsioonide dubleerimine
+ Positsiooni täpsus (bittides)
+ Minimaalne positsiooniintervall (sekundites)
+ Sõlmeinfo otsevastus
+ Otsevastuse hüpete maksimaalne arv
+ Saatekiiruse piiramine
+ Kiiruse piirangu aken (sekundites)
+ Max pakettide arv aknas
+ Tundmatute paketide hülgamine
+ Tundmatu pakettide lävi
+ Ainult kohalik telemeetria (vahendajad)
+ Ainult kohalik asukoht (vahendajad)
+ Säilita ruuteri hüpped
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 52c37cba5..1883a6d50 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -933,7 +933,9 @@
Maasto
Hybridi
Hallitse Karttatasoja
+ Karttatasot tukevat .kml-, .kmz- tai GeoJSON-tiedostomuotoja.
Karttatasot
+ Karttatasoja ei ole ladattu.
Lisää taso
Piilota taso
Näytä taso
@@ -942,6 +944,10 @@
Laitteet tässä sijainnissa
Valittu karttatyyppi
Hallitse mukautettuja karttatasoja
+ Lisää karttatiilien verkkolähde
+ Mukautettuja karttalähteitä ei löytynyt.
+ Muokkaa karttatiilien verkkolähteen asetuksia
+ Poista verkkokarttalähde
Nimi ei voi olla tyhjä.
Palveluntarjoajan nimi on olemassa.
URL-osoite ei voi olla tyhjä.
@@ -1156,4 +1162,55 @@
Päivitä
Päivitetty
+ Lisää verkkokarttataso
+ Päivitä karttataso
+ Paikallinen MBTiles-karttatiedosto
+ Lisää paikallinen MBTiles-karttatiedosto
+ Virheellinen nimi, URL-malli tai paikallinen URI mukautetulle karttalähteelle.
+ Mukautettu karttalähde tällä nimellä on jo olemassa.
+ MBTiles-tiedoston kopiointi sisäiseen tallennustilaan epäonnistui.
+ TAK (ATAK)
+ TAK-asetukset
+ Tiimin väri
+ Jäsenen rooli
+ Määrittelemätön
+ Valkoinen
+ Keltainen
+ Oranssi
+ Purppura
+ Punainen
+ Viininpunainen
+ Liila
+ Tummansininen
+ Sininen
+ Turkoosi
+ Sinivihreä
+ Vihreä
+ Tummanvihreä
+ Ruskea
+ Määrittelemätön
+ Tiimin jäsen
+ Joukkueen johtaja
+ Päämaja
+ Tarkka-ampuja
+ Lääkäri
+ Havaitsija etulinjassa
+ Radiopuhelinoperaattori
+ Koiraseuranta (K9)
+ Liikenteenhallinta
+ Liikenteen hallinnan asetukset
+ Moduuli käytössä
+ Sijaintiduplikaattien poisto (liikenteenhallinta)
+ Sijainnin tarkkuus (bitteinä)
+ Sijainnin vähimmäislähetysväli (sekunteina)
+ Laitetietojen suora vastaus
+ Suoran vastauksen enimmäishyppyjen määrä
+ Lähetysnopeuden rajoitus
+ Lähetysrajoituksen aikajakso (sekunteina)
+ Pakettien enimmäismäärä aikajaksossa
+ Tuntemattomien pakettien hylkääminen
+ Tuntemattomien pakettien kynnysarvo
+ Telemetria vain paikallisesti (välittäjät)
+ Sijainti vain paikallisesti (välittäjät)
+ Säilytä välittäjien hypyt
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 3a252b924..3aa4b7e71 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -1152,4 +1152,8 @@
Actualiser
Mis à jour
+ Rouge
+ Bleu
+ Vert
+ Module activé
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index 11fad4638..e9093c157 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -173,4 +173,5 @@
+ Crveno
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index 9b6088405..5f68ad29f 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -928,4 +928,7 @@
Összes
Bluetooth
+ Piros
+ Kék
+ Zöld
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 6a734a54a..591177ca8 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -942,4 +942,8 @@
Bluetooth
Configurazione
+ Rosso
+ Blu
+ Verde
+ Modulo abilitato
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 109f5e579..39e633de7 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -26,6 +26,7 @@
インフラを除外
オフラインノードを非表示
ダイレクトノードのみ表示
+ 無視されたノードを表示しています。\nノード一覧に戻るにはここを押してください。
詳細を表示
並べ替え
ノードの並べ替えオプション
@@ -40,9 +41,11 @@
API 経由
内部
お気に入り経由
+ 無視されたノードのみ表示
不明
相手の受信確認待ち
送信待ち
+ SF++ チェーンで確認済み
相手の受信を確認しました
ルートがありません
相手が正常に受信できませんでした
@@ -59,43 +62,91 @@
不明な公開キー
セッションキーが不正です
許可されていない公開キー
+ PKIの送信に失敗しました、公開鍵はありません
+ クライアント
アプリに接続されているか、スタンドアロンのメッセージングデバイスです。
+ クライアント・ミュート
このデバイスは他のデバイスからのパケットを転送しません。
+ クライアント・ベース
+ ルーター
メッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストに表示されます。
+ ルータークライアント
ROUTERとCLIENTの組み合わせ。モバイルデバイス向けではありません。
+ リピーター
最小限のオーバーヘッドでメッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストには表示されません。
+ トラッカー
GPSの位置情報パケットを優先してブロードキャストします。
+ センサー
テレメトリーパケットを優先してブロードキャストします。
+ TAK
ATAKシステムとの通信に最適化し、定期的なブロードキャストを削減します。
+ クライアント・非表示
ステルスまたは電力節約のため、必要に応じてのみブロードキャストするデバイス。
+ 紛失モード
デバイスを見つけやすくするために、デバイス自身の位置情報をメッセージ形式で定期的にデフォルトのチャンネルにブロードキャストします。
+ TAK Tracker
TAK PLIの自動ブロードキャストを有効にし、ルーチンブロードキャストを削減します。
+ ルーター・レイト
周辺クラスターの通信範囲を拡大させるインフラストラクチャノード。他のすべてのノードが通信し終わった後で、必ずパケットを1回だけ再ブロードキャストする。ノードリストに表示される。
すべて
受信メッセージが、参加しているプライベートチャンネル上のもの、または同じLoRaパラメータを持つ別のメッシュからのものであれば再ブロードキャストします。
+ すべてをスキップ
ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。 リピーターロールでのみ使用できます。他のロールに設定すると、ALLの動作になります。
+ ローカルのみ
開いている外部メッシュや復号できないメッシュからのメッセージを無視。 ノードのローカルプライマリー/セカンダリーチャンネルでのみメッセージを再ブロードキャスト。
+ 既知のみ
LOCAL ONLYのような外部メッシュからのメッセージを無視します。 さらに一歩進んで既知のノードリストにないノードからのメッセージを無視します。
なし
SENSOR、TRACKER、およびTAK_TRACKERロールでのみ許可。CLIENT_MUTEロールとは異なり、すべての再ブロードキャストを禁止。
+ コアポート番号のみ
TAK、RangeTest、PaxCounterなどの非標準ポート番号からのパケットを無視。NodeInfo、Text、Position、Telemetry、Routingなどの標準ポート番号を持つパケットのみを再ブロードキャスト。
加速度センサー搭載デバイスで本体をダブルタップすると、ボタンのプッシュと同じ動作として扱います。
+ ユーザーボタンがトリプルクリックされている場合、プライマリチャンネル上の位置を送信します。
デバイスの点滅するLEDを制御します。ほとんどのデバイスでは、最大4つあるLEDのうちの1つを制御します。充電用LEDとGPS用LEDは制御できません。
+ デバイスの画面とログ上の日付のタイムゾーン。
+ 端末のタイムゾーンを使用
近隣ノード情報(NeighborInfo)をMQTTやPhoneAPIへ送信することに加えて、LoRa無線経由でも送信すべきかどうかを設定します。デフォルトの名前とキーが設定されたチャンネルでは利用できません。
+ ユーザーボタンが押された後、またはメッセージが受信された後、画面がオンになっている期間。
+ 指定した間隔に基づき、画面上でカルーセルのように自動的に次のページに切り替わります。
+ 円外側の画面上のコンパス方位は、常に北を指します。
+ 画面を上下に反転させる。
+ デバイスの画面に表示されている単位。
+ OLED 画面の自動検出を上書きします。
+ デフォルト画面レイアウトを上書きします。
+ 画面の見出しテキストを太字にします。
+ お使いの端末に加速度センサーがあることが必要です。
+ 無線端末を使用される地域を指定してくだい。
+ 使用可能なモデムプリセット、デフォルトはロングファーストです。
+ 最大ホップ数を設定し、初期値は3ホップです。ホップ数を増やすと輻輳も増加するため、控えめに運用しましょう。0ホップのブロードキャストメッセージはACKを受信しなくなります。
UDP 経由でローカルネットワーク上のパケットのブロードキャスト通信を有効にする。
+ ノードが位置情報をブロードキャストせずに経過し得る最大間隔。
+ スマート間隔
+ スマート距離
+ デバイスの GPS
+ 固定位置
+ 標高
+ GPS ポーリング間隔
+ 高度なデバイス GPS
+ GPS RX GPIO
+ GPS TX GPIO
+ GPS EN GPIO
GPIO
デバッグ
+ チャネ
チャンネル名
QRコード
ユーザー名不明
送信
このスマートフォンはMeshtasticデバイスとペアリングされていません。デバイスとペアリングしてユーザー名を設定してください。\n\nこのオープンソースアプリケーションはアルファテスト中です。問題を発見した場合はBBSに書き込んでください。 https://github.com/orgs/meshtastic/discussions\n\n詳しくはWEBページをご覧ください。 www.meshtastic.org
あなた
+ 分析とクラッシュレポートを許可する。
同意
キャンセル
+ 破棄
保存
新しいチャンネルURLを受信しました
+ Meshtasticは、新規デバイスをBluetooth経由で検出するために位置情報の許可を有効にする必要があります。非使用時は無効にすることができます。
バグを報告
バグを報告
不具合報告として診断情報を送信しますか?送信した場合は https://github.com/orgs/meshtastic/discussions に検証できる報告を書き込んでください。
@@ -104,6 +155,7 @@
ペアに設定できませんでした。もう一度選択してください。
位置情報が無効なため、メッシュネットワークに位置情報を提供できません。
シェア
+ 新しいノードを見ました:%1$s
切断
デバイスはスリープ状態です
接続済み: %1$s オンライン
@@ -112,15 +164,47 @@
接続済
Meshtasticデバイスに接続しました
(%1$s)
+ 現在の接続:
+ Wi-Fi IP:
+ イーサネット IP:
接続中
接続されていません
+ デバイスが選択されていません
接続しましたが、Meshtasticデバイスはスリープ状態です。
アプリを更新して下さい。
アプリが古く、デバイスと通信ができません。アプリストアまたはGithubでアプリを更新してください。詳細はこちら に記載されています。
なし (切断)
通知サービス
+ 謝辞
このチャンネルURLは無効なため使用できません。
+ この連絡先は無効なので追加できません
デバッグ
+ デコードされたペイロード:
+ ログのエクスポート
+ エクスポートがキャンセルされました
+ %1$d ログをエクスポートしました
+ ログファイルの書き込みに失敗しました:%1$s
+
+ - %1$d 時間
+
+
+ - %1$d 日
+
+ フィルタ
+ 適用中のフィルタ
+ ログ内で検索…
+ 次の一致
+ 前の一致
+ 検索をクリア
+ フィルタ追加
+ フィルタを含む
+ すべてのフィルタをクリア
+ カスタムフィルタを追加
+ プリセットフィルタ
+ 無視したノードのみを表示
+ メッシュログを保存
+ 無効にすると、メッシュログをファイルに保存することがスキップされます
+ ログをクリア
削除
メッセージ配信状況
アラート通知
@@ -557,7 +641,83 @@
削除
+ フィルタを無効にする
+ チャンネル URL
+ NFCをスキャンする
+ 共有連絡先の NFC をスキャン
+ 共有連絡先のQRコードをスキャン
+ 共有連絡先のURLを入力
+ チャンネルの NFC をスキャンする
+ チャンネルのQRコードをスキャンする
+ チャンネルURLを入力
+ チャンネルのQRコードを共有
+ NFCタグに端末を近づけてスキャンしてください。
+ QRコード生成
+ NFC が無効になっています。システム設定で有効にしてください。
すべて
Bluetooth
+ Configure Bluetooth Permissions
+ Meshtasticデバイスに接続しました
+ Meshtastic メッシュ無線デバイスをスキャンして接続します。
+ ディスカバリー
+ あなたの近くにあるMeshtasticデバイスを見つけて識別します。
+ 設定
+ デバイスの設定とチャンネルをワイヤレスで管理します。
+ 許可が与えられました
+ 許可が拒否されました
+ マップスタイルの選択
+ バッテリー:%1$d%%
+ 稼働時間: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ トラフィック: TX %1$d / RX %2$d (D: %3$d)
+ リレー: %1$d (キャンセル済み: %2$d)
+ 診断: %1$s
+ ノイズ %1$d dBm
+ ドロップされた %1$d
+ ヒープ
+ %1$d / %2$d
+ %1$s
+ 給電
+ Meshtastic 統計
+ 更新
+ 更新済み
+ ネットレイヤーを追加
+ レイヤーを更新
+ ローカル MBTiles ファイル
+ ローカル MBTiles ファイルを追加する
+ カスタムタイルプロバイダーのファイル名、URLテンプレート、またはローカルURIが無効です。
+ この名前のカスタムタイルプロバイダーが既に存在します。
+ MBTilesファイルを内部ストレージにコピーできませんでした。
+ TAK (ATAK)
+ TAK 設定
+ チームカラー
+ メンバーロール
+ 未指定
+ 白色
+ 黄色
+ 柿色
+ 紅紫色
+ 赤
+ 栗色
+ 紫色
+ 紺色
+ 青
+ 浅葱色
+ 鴨の羽色
+ 緑
+ 柚葉色
+ 茶色
+ 未指定
+ チームメンバー
+ チームリーダー
+ 本部
+ スナイパー
+ 衛生兵
+ 前線観測員 (FO)
+ 無線通信手
+ イッヌ (K9)
+ トラフィック管理
+ トラフィック管理設定
+ モジュール有効
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index ebb0190d4..d9e077601 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -569,4 +569,7 @@
블루투스
설정
+ 빨강
+ 파랑
+ 초록
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 0ed59d06a..17dc9457b 100644
--- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
@@ -248,4 +248,5 @@
+ Raudona
diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
index 9f9cd310c..7b46c7887 100644
--- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
@@ -437,4 +437,7 @@
Alles
Bluetooth
+ Rood
+ Blauw
+ Groen
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 1c617170d..0bfa412e4 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -812,4 +812,8 @@
Bluetooth
Konfiguracja
+ Czerwony
+ Niebieski
+ Zielony
+ Moduł Włączony
diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
index 8efbac0df..ad24867db 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -706,4 +706,7 @@
Bluetooth
+ Vermelho
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 5bfc89042..c5e56e3c4 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -555,4 +555,7 @@
Bluetooth
Configuração
+ Vermelho
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 79dafabb3..91140efa5 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -640,4 +640,7 @@
Toate
Bluetooth
+ Roșu
+ Albastru
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index af5c367d7..348f196ca 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -722,6 +722,7 @@
Полное имя
Короткое имя
Модель оборудования
+ Лицензия радиолюбителя (HAM)
Включение данной опции отключает шифрование и несовместимо с основной сетью Meshtastic.
Точка росы
Давление
@@ -940,7 +941,9 @@
Ландшафт
Смешанный
Управление Слоями Карты
+ Слои карты поддерживают форматы .kml, .kmz или GeoJSON.
Слои карты
+ Слои карты не загружены.
Добавить слой
Скрыть слой
Показать слой
@@ -949,6 +952,10 @@
Ноды в этом месте
Выбранный тип карты
Управление собственными источниками плиток
+ Добавить источник сетевых плиток
+ Источники пользовательских плиток не найдены.
+ Редактировать источник сетевых плиток
+ Удалить источник сетевых плиток
Имя не может быть пустым.
Имя провайдера уже существует.
URL не может быть пустым.
@@ -1170,4 +1177,47 @@
Обновить
Обновлено
+ Добавить сетевой уровень
+ Обновить уровень
+ Недопустимое имя, шаблон URL или локальный URI для провайдера плиток пользователя.
+ Провайдер плиток с этим именем уже существует.
+ Не удалось скопировать файл MBTiles во внутреннее хранилище.
+ TAK (ATAK)
+ Настройка TAK
+ Цвет команды
+ Роль участника
+ Не указан
+ Белый
+ Жёлтый
+ Оранжевый
+ Пурпурный
+ Красный
+ Бордовый
+ Фиолетовый
+ Тёмно-синий
+ Синий
+ Голубой
+ Бирюзовый
+ Зеленый
+ Тёмно-зеленый
+ Коричневый
+ Не определена
+ Участник команды
+ Руководитель команды
+ Штаб-квартира
+ Снайпер
+ Санитар
+ Наблюдатель
+ Оператор радиотелефона
+ Собака (К9)
+ Управление движением
+ Настройка управления движением
+ Телеметрия окружающей среды
+ Удаление дубликатов позиций
+ Точность позиции (бит)
+ Мин. интервал позиционирования (сек)
+ Ограничение скорости
+ Окно ограничения скорости (сек.)
+ Макс количество пакетов в окне
+ Сохраняить хопы маршрутизатора
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index 2e7ec0cda..b15f7c609 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -460,4 +460,7 @@
Všetky
Bluetooth
+ Červená
+ Modrá
+ Zelená
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 54bb8ec09..b29c6f373 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -1031,4 +1031,8 @@
Bluetooth
Konfiguration
+ Rött
+ Blått
+ Grönt
+ Modul aktiverad
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index 9bad5ac60..b6e256741 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -576,4 +576,7 @@
Bluetooth
Yapılandırma
+ Kırmızı
+ Mavi
+ Yeşil
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 486ed99ab..4f7ddbeb6 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -795,4 +795,7 @@
Bluetooth
Налаштування
+ Червоний
+ Синій
+ Зелений
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 d008c928f..5ffc2d6d5 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -1154,4 +1154,8 @@
刷新
更新
+ 红
+ 蓝
+ 绿
+ 开启模块
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 17a1812e8..a87e8a84e 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -941,7 +941,7 @@
已選擇的地圖類型
管理自定義圖磚來源
加入自定義圖磚來源
- 沒有自定義圖專來源
+ 沒有自定義圖專來源。
編輯自定義圖磚來源
刪除自定義圖磚來源
名稱不得空白。
@@ -1161,4 +1161,48 @@
自訂圖磚來源的名稱、URL 範本或本機 URI 無效。
已存在相同名稱的自訂圖磚來源。
無法將 MBTiles 檔案複製至內部儲存空間。
+ TAK (ATAK)
+ TAK 設定
+ 隊伍顏色
+ 隊員角色
+ 未指定
+ 白色
+ 黃色
+ 橙色
+ 洋紅色
+ Red - 紅色
+ 栗紅色
+ 紫色
+ 深藍色
+ Blue - 藍色
+ 天青色
+ 羽青色
+ Green - 綠色
+ 墨綠色
+ 咖啡色
+ 未指定
+ 隊伍成員
+ 隊長
+ 司令部 (HQ)
+ 狙擊手
+ 醫療兵
+ 前進觀測員 (FO)
+ 無線電兵
+ 汪星人 (K9)
+ 流量管理
+ 流量管理設定
+ 模組已啟用
+ 定位去重複化處理
+ 定位精度(位元)
+ 定位最小間隔時間(秒)
+ 節點資訊直接直接應答
+ 直接應答最大跳數
+ 速率限制
+ 速率限制開放窗口期(秒)
+ 開放窗口期封包上限
+ 捨棄不明封包
+ 不明封包閾值
+ 僅本地遙測資訊(中繼)
+ 僅本地定位資訊(中繼)
+ 保留路由跳數
From 9ba4d50e601062a0543514f3215415b41ac4c5dd Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:52:46 -0600
Subject: [PATCH 007/440] chore(deps): update vico to v3.0.2 (#4675)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 124528478..c7f3a6ff1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -55,7 +55,7 @@ okio = "3.16.4"
osmdroid-android = "6.1.20"
spotless = "8.2.1"
wire = "6.0.0-alpha02"
-vico = "3.0.1"
+vico = "3.0.2"
dependency-guard = "0.5.0"
nordic-ble = "2.0.0-alpha15"
nordic-common = "2.9.1"
From 5f31df96d8242db080b667f81f11b842be3f66cc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 12:15:23 -0600
Subject: [PATCH 008/440] chore(deps): update
androidx.compose:compose-bom-alpha to v2026.02.01 (#4673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c7f3a6ff1..d51b3cce8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -110,7 +110,7 @@ androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", versi
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
# AndroidX Compose
-androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.00" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.01" }
androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
From 8c6bd8ab7aae6f0e01a6277046941edf7b74e8a4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 12:15:33 -0600
Subject: [PATCH 009/440] feat: settings rework part 2, domain and usecase
abstraction, tests (#4680)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 19 +-
.../repository/radio/NordicBleInterface.kt | 4 +
.../repository/radio/RadioInterfaceService.kt | 5 +-
.../mesh/service/ConnectionStateHandler.kt | 2 +-
.../mesh/service/MeshCommandSender.kt | 43 +-
.../mesh/service/MeshConfigFlowManager.kt | 2 +-
.../mesh/service/MeshConnectionManager.kt | 30 +-
.../mesh/service/MeshMessageProcessor.kt | 2 +-
.../mesh/service/MeshNodeManager.kt | 2 +-
.../mesh/service/MeshServiceBroadcasts.kt | 2 +-
.../service/MeshServiceNotificationsImpl.kt | 2 +-
.../geeksville/mesh/service/PacketHandler.kt | 2 +-
.../main/java/com/geeksville/mesh/ui/Main.kt | 2 +-
.../mesh/ui/connections/ConnectionsScreen.kt | 2 +-
.../ui/connections/components/BLEDevices.kt | 2 +-
.../components/ConnectionsNavIcon.kt | 5 +-
.../connections/components/DeviceListItem.kt | 2 +-
.../components/DeviceListSection.kt | 2 +-
.../connections/components/NetworkDevices.kt | 2 +-
.../ui/connections/components/UsbDevices.kt | 2 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 2 +-
.../mesh/widget/LocalStatsWidget.kt | 2 +-
.../mesh/widget/LocalStatsWidgetState.kt | 2 +-
.../service/MeshCommandSenderHopLimitTest.kt | 2 +-
.../service/MeshCommandSenderQueueTest.kt | 122 ----
.../mesh/service/MeshConnectionManagerTest.kt | 31 +-
.../mesh/service/MeshServiceBroadcastsTest.kt | 2 +-
.../mesh/service/PacketHandlerTest.kt | 2 +-
.../HomoglyphCharacterStringTransformer.kt | 6 +-
.../core/common/util/SequentialJob.kt | 5 +-
.../core/data/repository/NodeRepository.kt | 8 +-
.../data/repository/RadioConfigRepository.kt | 4 +-
.../core/database/DatabaseManager.kt | 9 +-
core/di/build.gradle.kts | 4 +-
.../org/meshtastic/core/di/AppModule.kt | 11 +-
core/domain/build.gradle.kts | 44 ++
.../meshtastic/core/domain/MessageQueue.kt | 25 +
.../domain/usecase/SendMessageUseCase.kt | 62 +-
.../usecase/settings/AdminActionsUseCase.kt | 92 +++
.../settings/CleanNodeDatabaseUseCase.kt | 63 ++
.../usecase/settings/ExportDataUseCase.kt | 122 ++++
.../usecase/settings/ExportProfileUseCase.kt | 35 +
.../settings/ExportSecurityConfigUseCase.kt | 58 ++
.../usecase/settings/ImportProfileUseCase.kt | 35 +
.../usecase/settings/InstallProfileUseCase.kt | 153 ++++
.../usecase/settings/IsOtaCapableUseCase.kt | 65 ++
.../usecase/settings/MeshLocationUseCase.kt | 33 +
.../settings/ProcessRadioResponseUseCase.kt | 127 ++++
.../usecase/settings/RadioConfigUseCase.kt | 187 +++++
.../settings/SetAppIntroCompletedUseCase.kt | 27 +
.../settings/SetDatabaseCacheLimitUseCase.kt | 29 +
.../settings/SetMeshLogSettingsUseCase.kt | 54 ++
.../settings/SetProvideLocationUseCase.kt | 27 +
.../usecase/settings/SetThemeUseCase.kt | 27 +
.../settings/ToggleAnalyticsUseCase.kt | 27 +
.../ToggleHomoglyphEncodingUseCase.kt | 27 +
.../core/domain/FakeRadioController.kt | 109 +++
.../domain/usecase/SendMessageUseCaseTest.kt | 64 +-
.../settings/AdminActionsUseCaseTest.kt | 72 ++
.../settings/CleanNodeDatabaseUseCaseTest.kt | 73 ++
.../usecase/settings/ExportDataUseCaseTest.kt | 99 +++
.../settings/ExportProfileUseCaseTest.kt | 48 ++
.../ExportSecurityConfigUseCaseTest.kt | 61 ++
.../settings/ImportProfileUseCaseTest.kt | 60 ++
.../settings/InstallProfileUseCaseTest.kt | 98 +++
.../settings/IsOtaCapableUseCaseTest.kt | 124 ++++
.../settings/MeshLocationUseCaseTest.kt | 47 ++
.../ProcessRadioResponseUseCaseTest.kt | 106 +++
.../settings/RadioConfigUseCaseTest.kt | 160 +++++
.../SetAppIntroCompletedUseCaseTest.kt | 44 ++
.../SetDatabaseCacheLimitUseCaseTest.kt | 49 ++
.../settings/SetMeshLogSettingsUseCaseTest.kt | 74 ++
.../settings/SetProvideLocationUseCaseTest.kt | 44 ++
.../usecase/settings/SetThemeUseCaseTest.kt | 44 ++
.../settings/ToggleAnalyticsUseCaseTest.kt | 60 ++
.../ToggleHomoglyphEncodingUseCaseTest.kt | 60 ++
.../meshtastic/core/model}/ConnectionState.kt | 5 +-
.../meshtastic/core/model/RadioController.kt | 90 +++
core/service/build.gradle.kts | 7 +-
.../service/AndroidRadioControllerImpl.kt | 161 +++++
.../core/service/ServiceRepository.kt | 5 +-
.../core/service/di/ServiceModule.kt | 31 +
.../firmware/FirmwareUpdateViewModel.kt | 2 +-
.../feature/intro/AppIntroductionScreen.kt | 2 +-
.../feature/map/MapViewModelTest.kt | 2 +-
feature/messaging/build.gradle.kts | 9 +
.../meshtastic/feature/messaging/Message.kt | 1 +
.../feature/messaging/MessageViewModel.kt | 2 +-
.../feature/messaging/di/MessagingModule.kt | 31 +
.../domain/worker/SendMessageWorker.kt | 70 ++
.../domain/worker/WorkManagerMessageQueue.kt | 43 ++
.../HomoglyphCharacterTransformTest.kt | 1 +
.../domain/worker/SendMessageWorkerTest.kt | 159 +++++
.../feature/node/component/NodeItem.kt | 2 +-
.../feature/node/component/NodeStatusIcons.kt | 2 +-
.../feature/node/list/NodeListScreen.kt | 2 +-
feature/settings/build.gradle.kts | 4 +
.../feature/settings/AdministrationScreen.kt | 2 +-
.../settings/DeviceConfigurationScreen.kt | 2 +-
.../settings/ModuleConfigurationScreen.kt | 2 +-
.../feature/settings/SettingsScreen.kt | 316 +--------
.../feature/settings/SettingsViewModel.kt | 213 +-----
.../settings/component/AppInfoSection.kt | 159 +++++
.../settings/component/AppearanceSection.kt | 84 +++
.../settings/component/ExpressiveSection.kt | 56 ++
.../settings/component/HomoglyphSetting.kt | 41 ++
.../settings/component/PersistenceSection.kt | 116 +++
.../settings/component/PrivacySection.kt | 113 +++
.../settings/radio/CleanNodeDatabaseScreen.kt | 8 +-
.../radio/CleanNodeDatabaseViewModel.kt | 59 +-
.../feature/settings/radio/RadioConfig.kt | 239 ++++---
.../settings/radio/RadioConfigViewModel.kt | 658 +++++++-----------
.../feature/settings/HomoglyphSettingTest.kt | 1 +
.../feature/settings/SettingsViewModelTest.kt | 128 ++++
.../settings/debugging/DebugViewModelTest.kt | 115 +++
.../filter/FilterSettingsViewModelTest.kt | 67 ++
.../radio/CleanNodeDatabaseViewModelTest.kt | 82 +++
.../radio/RadioConfigViewModelTest.kt | 241 +++++++
gradle.properties | 79 +--
gradle/libs.versions.toml | 3 +
settings.gradle.kts | 1 +
121 files changed, 5245 insertions(+), 1332 deletions(-)
delete mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
rename {feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/HomoglyphCharacterStringTransformer.kt (97%)
create mode 100644 core/domain/build.gradle.kts
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
rename {feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging => core/domain/src/main/kotlin/org/meshtastic/core}/domain/usecase/SendMessageUseCase.kt (60%)
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
rename {feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging => core/domain/src/test/kotlin/org/meshtastic/core}/domain/usecase/SendMessageUseCaseTest.kt (69%)
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service => model/src/commonMain/kotlin/org/meshtastic/core/model}/ConnectionState.kt (94%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
create mode 100644 feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3c0e623aa..383ee77f1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -198,7 +198,7 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
index 3ab5b5300..19e047139 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
@@ -208,6 +208,10 @@ constructor(
onDisconnected(state)
}
}
+ .catch { e ->
+ Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
+ service.onDisconnect(BleError.from(e))
+ }
.launchIn(connectionScope)
val p = retryBleOperation(tag = address) { findPeripheral() }
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index 0e7215d5c..f7cf8fbd5 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -47,9 +48,9 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -127,6 +128,7 @@ constructor(
stopInterface()
}
}
+ .catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
@@ -137,6 +139,7 @@ constructor(
stopInterface()
}
}
+ .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
index 4db868f22..a9f1cf014 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
@@ -18,7 +18,7 @@ package com.geeksville.mesh.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import javax.inject.Inject
import javax.inject.Singleton
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 3b36c9e19..6e98b253e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -30,12 +30,12 @@ import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -47,7 +47,6 @@ import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@@ -68,7 +67,6 @@ constructor(
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
- private val offlineSentPackets = CopyOnWriteArrayList()
val tracerouteStartTimes = ConcurrentHashMap()
val neighborInfoStartTimes = ConcurrentHashMap()
@@ -77,17 +75,6 @@ constructor(
@Volatile var lastNeighborInfo: NeighborInfo? = null
- private val rememberDataType =
- setOf(
- PortNum.TEXT_MESSAGE_APP.value,
- PortNum.ALERT_APP.value,
- PortNum.WAYPOINT_APP.value,
- PortNum.ATAK_PLUGIN.value,
- PortNum.ATAK_FORWARDER.value,
- PortNum.DETECTION_SENSOR_APP.value,
- PortNum.PRIVATE_APP.value,
- )
-
fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
@@ -154,14 +141,9 @@ constructor(
}
if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
- try {
- sendNow(p)
- } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
- Logger.e(ex) { "Error sending message, so enqueueing" }
- enqueueForSending(p)
- }
+ sendNow(p)
} else {
- enqueueForSending(p)
+ error("Radio is not connected")
}
}
@@ -185,25 +167,6 @@ constructor(
packetHandler?.sendToRadio(meshPacket)
}
- private fun enqueueForSending(p: DataPacket) {
- if (p.dataType in rememberDataType) {
- offlineSentPackets.add(p)
- }
- }
-
- fun processQueuedPackets() {
- val sentPackets = mutableListOf()
- offlineSentPackets.forEach { p ->
- try {
- sendNow(p)
- sentPackets.add(p)
- } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
- Logger.e(ex) { "Error sending queued message:" }
- }
- }
- offlineSentPackets.removeAll(sentPackets)
- }
-
fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
index ad3f64d34..1d666ca2d 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -27,7 +27,7 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
index bd777c538..eeb4882dc 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -19,6 +19,10 @@ package com.geeksville.mesh.service
import android.app.Notification
import android.content.Context
import androidx.glance.appwidget.updateAll
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.widget.LocalStatsWidget
@@ -40,7 +44,9 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.resources.Res
@@ -50,8 +56,8 @@ import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@@ -82,6 +88,8 @@ constructor(
private val commandSender: MeshCommandSender,
private val nodeManager: MeshNodeManager,
private val analytics: PlatformAnalytics,
+ private val packetRepository: PacketRepository,
+ private val workManager: WorkManager,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
@@ -255,7 +263,25 @@ constructor(
}
fun onRadioConfigLoaded() {
- commandSender.processQueuedPackets()
+ scope.handledLaunch {
+ val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
+ queuedPackets.forEach { packet ->
+ try {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to enqueue queued packet worker" }
+ }
+ }
+ }
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
index f1da54dd7..7ed7980c3 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
@@ -60,7 +60,7 @@ constructor(
private val logInsertJobByPacketId = ConcurrentHashMap()
private val earlyReceivedPackets = ArrayDeque()
- private val maxEarlyPacketBuffer = 128
+ private val maxEarlyPacketBuffer = 10240
fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
index ce6d4431c..1f284c7a7 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -78,7 +78,7 @@ constructor(
fun loadCachedNodeDB() {
scope.handledLaunch {
- val nodes = nodeRepository?.getNodeDBbyNum()?.first() ?: emptyMap()
+ val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
index e0215bc15..34ce09dec 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
@@ -21,11 +21,11 @@ import android.content.Intent
import android.os.Parcelable
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.util.Locale
import javax.inject.Inject
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index 6128caaf6..babdc5565 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -309,7 +309,7 @@ constructor(
if (myNodeNum != null) {
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
// and we only do this once if the cache is empty.
- val nodes = runBlocking { repo.getNodeDBbyNum().first() }
+ val nodes = runBlocking { repo.getNodeEntityDBbyNumFlow().first() }
nodes[myNodeNum]?.let { entity ->
if (cachedDeviceMetrics == null) {
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
index 2de292491..d85edd7ad 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
@@ -32,11 +32,11 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 8a31155eb..f28f98114 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -96,6 +96,7 @@ import no.nordicsemi.android.common.permissions.notification.RequestNotification
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
@@ -123,7 +124,6 @@ import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
index a7b34c125..27cb87e24 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
@@ -59,6 +59,7 @@ import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
@@ -71,7 +72,6 @@ import org.meshtastic.core.resources.must_set_region
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.not_connected
import org.meshtastic.core.resources.set_your_region
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
index ed0a540bb..2ea0bda92 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
@@ -37,9 +37,9 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_available_devices
-import org.meshtastic.core.service.ConnectionState
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
index a99053754..03be8458b 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.ui.connections.components
import androidx.compose.animation.Crossfade
@@ -39,7 +38,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ui.connections.DeviceType
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
index 78e64088c..0ab39bbe7 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
@@ -56,12 +56,12 @@ import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
index 1915cfff3..2381d4f97 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
@@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
@Composable
fun List.DeviceListSection(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
index cc0f8af7a..8cda4687c 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
@@ -53,6 +53,7 @@ import com.geeksville.mesh.ui.connections.ScannerViewModel
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
@@ -63,7 +64,6 @@ import org.meshtastic.core.resources.forget_connection
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices
import org.meshtastic.core.resources.recent_network_devices
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
index fe7aa4d70..9669e83c8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
@@ -27,9 +27,9 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_usb_devices
-import org.meshtastic.core.service.ConnectionState
@Composable
fun UsbDevices(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index 693dbf61f..24bcff02f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -71,6 +71,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.Channel
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
import org.meshtastic.core.navigation.Route
@@ -88,7 +89,6 @@ import org.meshtastic.core.resources.replace
import org.meshtastic.core.resources.reset
import org.meshtastic.core.resources.reset_to_defaults
import org.meshtastic.core.resources.share_channels_qr
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
index 7de8359eb..1e7f58323 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -69,6 +69,7 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
@@ -92,7 +93,6 @@ import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
-import org.meshtastic.core.service.ConnectionState
class LocalStatsWidget : GlanceAppWidget() {
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index 75dc02cd1..eafbe38a2 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
index 9fcb5ab91..c7f2e2e87 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
@@ -30,8 +30,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
deleted file mode 100644
index e1c0cca2f..000000000
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
+++ /dev/null
@@ -1,122 +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 com.geeksville.mesh.service
-
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import okio.ByteString
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.proto.PortNum
-
-class MeshCommandSenderQueueTest {
-
- private val packetHandler = mockk(relaxed = true)
- private val connectionStateHandler = mockk(relaxed = true)
- private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
-
- private lateinit var commandSender: MeshCommandSender
-
- @Before
- fun setUp() {
- every { connectionStateHandler.connectionState } returns connectionStateFlow.asStateFlow()
- commandSender = MeshCommandSender(packetHandler, null, connectionStateHandler, null)
- }
-
- @Test
- fun `sendData queues TEXT_MESSAGE_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.TEXT_MESSAGE_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues ATAK_PLUGIN when disconnected`() {
- val packet = DataPacket(dataType = PortNum.ATAK_PLUGIN.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues ATAK_FORWARDER when disconnected`() {
- val packet = DataPacket(dataType = PortNum.ATAK_FORWARDER.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues DETECTION_SENSOR_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.DETECTION_SENSOR_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues PRIVATE_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.PRIVATE_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData does NOT queue IP_TUNNEL_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.IP_TUNNEL_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
- }
-}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
index c7e002ec0..cefdb7b61 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -19,6 +19,9 @@ package com.geeksville.mesh.service
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
@@ -37,12 +40,15 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@@ -67,6 +73,8 @@ class MeshConnectionManagerTest {
private val commandSender: MeshCommandSender = mockk(relaxed = true)
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val workManager: WorkManager = mockk(relaxed = true)
private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
@@ -107,6 +115,8 @@ class MeshConnectionManagerTest {
commandSender,
nodeManager,
analytics,
+ packetRepository,
+ workManager,
)
}
@@ -194,10 +204,23 @@ class MeshConnectionManagerTest {
}
@Test
- fun `onRadioConfigLoaded processes queued packets and sets time`() = runTest(testDispatcher) {
- manager.onRadioConfigLoaded()
+ fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
+ manager.start(backgroundScope)
+ val packetId = 456
+ val dataPacket = mockk(relaxed = true)
+ every { dataPacket.id } returns packetId
+ coEvery { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
- verify { commandSender.processQueuedPackets() }
+ manager.onRadioConfigLoaded()
+ advanceUntilIdle()
+
+ verify {
+ workManager.enqueueUniqueWork(
+ match { it.startsWith(SendMessageWorker.WORK_NAME_PREFIX) },
+ any(),
+ any(),
+ )
+ }
verify { commandSender.sendAdmin(any(), initFn = any()) }
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
index 1e9db9ba9..88cee4a4b 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
@@ -24,7 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
index 0ad9629f2..bd3ddc0b9 100644
--- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
@@ -30,7 +30,7 @@ import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
similarity index 97%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index 9aebea8a0..d91c02b7e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.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
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging
+package org.meshtastic.core.common.util
/**
* This util class allows you to optimize the binary size of the transmitted text message strings. It replaces certain
@@ -24,7 +24,7 @@ package org.meshtastic.feature.messaging
* reduces the binary size of the transmitted message. The average transmitted message volume can then fit around
* ~140-145 characters instead of ~115-120
*/
-internal object HomoglyphCharacterStringTransformer {
+object HomoglyphCharacterStringTransformer {
/**
* Unicode characters from the basic cyrillic block (U+0400-U+04FF), each of which occupies 2 bytes
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
index 564c66515..6046c68b6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
@@ -25,8 +25,9 @@ import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
- * A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
- * for ensuring that only one operation of a certain type is running at a time.
+ * A helper class that manages a single [Job]. When a new job is launched, any previous job is cancelled. This is useful
+ * for ensuring that only the latest operation of a certain type is running at a time (e.g. for search or settings
+ * updates).
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference()
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
index 8ea4e70be..53729ce48 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
@@ -56,7 +56,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
-class NodeRepository
+open class NodeRepository
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@@ -66,7 +66,7 @@ constructor(
private val localStatsDataSource: LocalStatsDataSource,
) {
/** Hardware info about our local device (can be null if not connected). */
- val myNodeInfo: StateFlow =
+ open val myNodeInfo: StateFlow =
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
@@ -75,7 +75,7 @@ constructor(
private val _ourNodeInfo = MutableStateFlow(null)
/** Information about the locally connected node, as seen from the mesh. */
- val ourNodeInfo: StateFlow
+ open val ourNodeInfo: StateFlow
get() = _ourNodeInfo
private val _myId = MutableStateFlow(null)
@@ -131,7 +131,7 @@ constructor(
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
- fun getNodeDBbyNum() =
+ fun getNodeEntityDBbyNumFlow() =
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
index a22b001e4..1e4067f80 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
@@ -36,7 +36,7 @@ import javax.inject.Inject
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
-class RadioConfigRepository
+open class RadioConfigRepository
@Inject
constructor(
private val nodeDB: NodeRepository,
@@ -68,7 +68,7 @@ constructor(
suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
- val localConfigFlow: Flow = localConfigDataSource.localConfigFlow
+ open val localConfigFlow: Flow = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 3ae7d49f7..fe90c72e3 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -46,7 +46,12 @@ import javax.inject.Singleton
@Singleton
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
-class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
+open class DatabaseManager
+@Inject
+constructor(
+ private val app: Application,
+ private val dispatchers: CoroutineDispatchers,
+) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@@ -54,7 +59,7 @@ class DatabaseManager @Inject constructor(private val app: Application, private
// Expose the DB cache limit as a reactive stream so UI can observe changes.
private val _cacheLimit = MutableStateFlow(getCacheLimit())
- val cacheLimit: StateFlow = _cacheLimit
+ open val cacheLimit: StateFlow = _cacheLimit
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
private val prefsListener =
diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts
index ef82c29a4..d968dda63 100644
--- a/core/di/build.gradle.kts
+++ b/core/di/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -40,4 +40,4 @@ plugins {
configure { namespace = "org.meshtastic.core.di" }
-dependencies {}
+dependencies { implementation(libs.androidx.work.runtime.ktx) }
diff --git a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
index 4c834d897..0dfe5764a 100644
--- a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
+++ b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,14 +14,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.di
+import android.content.Context
+import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -30,4 +33,8 @@ object AppModule {
@Provides
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
+
+ @Provides
+ @Singleton
+ fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
}
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 000000000..60226b661
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -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 .
+ */
+
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.flavors)
+ alias(libs.plugins.meshtastic.hilt)
+}
+
+android { namespace = "org.meshtastic.core.domain" }
+
+dependencies {
+ implementation(projects.core.model)
+ implementation(projects.core.proto)
+ implementation(projects.core.common)
+ implementation(projects.core.database)
+ implementation(projects.core.prefs)
+ implementation(projects.core.data)
+ implementation(projects.core.datastore)
+ implementation(projects.core.resources)
+
+ implementation(libs.kermit)
+ implementation(libs.compose.multiplatform.resources)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
new file mode 100644
index 000000000..5142c89f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.domain
+
+/**
+ * Interface for enqueuing background work for transmitting messages. This allows the domain layer to trigger durable
+ * transmission without depending on Android-specific WorkManager.
+ */
+interface MessageQueue {
+ suspend fun enqueue(packetId: Int)
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
similarity index 60%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
rename to core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
index 1c9863015..ca2cf3f77 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
@@ -14,28 +14,39 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging.domain.usecase
+package org.meshtastic.core.domain.usecase
import co.touchlab.kermit.Logger
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
import org.meshtastic.proto.Config
-import org.meshtastic.proto.SharedContact
import javax.inject.Inject
+import kotlin.math.abs
+import kotlin.random.Random
+/**
+ * Use case for sending a message. This component handles message transformation, persistence, and enqueuing for durable
+ * delivery.
+ */
@Suppress("TooGenericExceptionCaught")
class SendMessageUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val packetRepository: PacketRepository,
+ private val radioController: RadioController,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
+ private val messageQueue: MessageQueue,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
@@ -74,18 +85,45 @@ constructor(
text
}
- val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
+ val packetId = abs(Random.nextInt())
+
+ val packet =
+ DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply {
+ from = fromId
+ id = packetId
+ status = MessageStatus.QUEUED
+ }
+
+ val packetToSave =
+ Packet(
+ uuid = 0L,
+ myNodeNum = ourNode?.num ?: 0,
+ packetId = packetId,
+ port_num = packet.dataType,
+ contact_key = contactKey,
+ received_time = nowMillis,
+ read = true,
+ data = packet,
+ snr = packet.snr,
+ rssi = packet.rssi,
+ hopsAway = packet.hopsAway,
+ filtered = false,
+ )
try {
- serviceRepository.meshService?.send(packet)
+ // Write to the DB to immediately reflect the queued state on the UI
+ packetRepository.insert(packetToSave)
+
+ // Enqueue for durable transmission via the platform-specific queue
+ messageQueue.enqueue(packetId)
} catch (ex: Exception) {
- Logger.e(ex) { "Failed to send data packet" }
+ Logger.e(ex) { "Failed to enqueue message packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
- serviceRepository.onServiceAction(ServiceAction.Favorite(node))
+ radioController.favoriteNode(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
@@ -93,9 +131,7 @@ constructor(
private suspend fun sendSharedContact(node: Node) {
try {
- val contact =
- SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
- serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
+ radioController.sendSharedContact(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
new file mode 100644
index 000000000..728a209e4
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.RadioController
+import javax.inject.Inject
+
+/** Use case for performing administrative actions on the radio. */
+open class AdminActionsUseCase
+@Inject
+constructor(
+ private val radioController: RadioController,
+ private val nodeRepository: NodeRepository,
+) {
+ /**
+ * Reboots the radio.
+ *
+ * @param destNum The node number to reboot.
+ * @return The packet ID of the request.
+ */
+ suspend fun reboot(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.reboot(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Shuts down the radio.
+ *
+ * @param destNum The node number to shut down.
+ * @return The packet ID of the request.
+ */
+ suspend fun shutdown(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.shutdown(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Factory resets the radio.
+ *
+ * @param destNum The node number to reset.
+ * @param isLocal Whether the reset is being performed on the locally connected node.
+ * @return The packet ID of the request.
+ */
+ suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int {
+ val packetId = radioController.getPacketId()
+ radioController.factoryReset(destNum, packetId)
+
+ if (isLocal) {
+ // If it's the local node, we should also clear the phone's node database as it will be out of sync.
+ nodeRepository.clearNodeDB()
+ }
+
+ return packetId
+ }
+
+ /**
+ * Resets the NodeDB on the radio.
+ *
+ * @param destNum The node number to reset.
+ * @param preserveFavorites Whether to keep favorite nodes in the database.
+ * @param isLocal Whether the reset is being performed on the locally connected node.
+ * @return The packet ID of the request.
+ */
+ suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int {
+ val packetId = radioController.getPacketId()
+ radioController.nodedbReset(destNum, packetId, preserveFavorites)
+
+ if (isLocal) {
+ // If it's the local node, we should also clear the phone's node database.
+ nodeRepository.clearNodeDB(preserveFavorites)
+ }
+
+ return packetId
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
new file mode 100644
index 000000000..6a32f1131
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.RadioController
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.days
+
+/** Use case for cleaning up nodes from the database. */
+class CleanNodeDatabaseUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val radioController: RadioController,
+) {
+ /** Identifies nodes that match the cleanup criteria. */
+ suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List {
+ val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
+ val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
+
+ val nodesToConsider =
+ if (onlyUnknownNodes) {
+ val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
+ val unknownNodes = nodeRepository.getUnknownNodes()
+ olderNodes.filter { itNode -> unknownNodes.any { it.num == itNode.num } }
+ } else {
+ nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
+ }
+
+ return nodesToConsider
+ .filterNot { node ->
+ (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
+ }
+ .map { it.toModel() }
+ }
+
+ /** Performs the cleanup of specified nodes. */
+ suspend fun cleanNodes(nodeNums: List) {
+ if (nodeNums.isEmpty()) return
+
+ nodeRepository.deleteNodes(nodeNums)
+ val packetId = radioController.getPacketId()
+ for (nodeNum in nodeNums) {
+ radioController.removeByNodenum(packetId, nodeNum)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
new file mode 100644
index 000000000..c8bcdf699
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
@@ -0,0 +1,122 @@
+/*
+ * 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 android.icu.text.SimpleDateFormat
+import kotlinx.coroutines.flow.first
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.proto.PortNum
+import java.io.BufferedWriter
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.math.roundToInt
+import org.meshtastic.proto.Position as ProtoPosition
+
+/** Use case for exporting persisted packet data to a CSV format. */
+class ExportDataUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val meshLogRepository: MeshLogRepository,
+) {
+ /**
+ * Writes all persisted packet data to the provided [BufferedWriter].
+ *
+ * @param writer The writer to output the CSV data to.
+ * @param myNodeNum The node number of the current device.
+ * @param filterPortnum If provided, only packets with this port number will be exported.
+ */
+ @Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod", "detekt:NestedBlockDepth")
+ suspend operator fun invoke(writer: BufferedWriter, myNodeNum: Int, filterPortnum: Int? = null) {
+ val nodes = nodeRepository.nodeDBbyNum.value
+ val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
+ meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
+ }
+
+ val nodePositions = mutableMapOf()
+
+ @Suppress("MaxLineLength")
+ writer.appendLine(
+ "\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
+ )
+
+ val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
+ meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
+ packet.nodeInfo?.let { nodeInfo ->
+ positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
+ }
+
+ packet.meshPacket?.let { proto ->
+ packet.position?.let { position ->
+ positionToPos.invoke(position)?.let {
+ nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
+ }
+ }
+
+ if (
+ (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
+ proto.rx_snr != 0.0f
+ ) {
+ val rxDateTime = dateFormat.format(packet.received_date)
+ val rxFrom = proto.from.toUInt()
+ val senderName = nodes[proto.from]?.user?.long_name ?: ""
+
+ val senderPosition = nodePositions[proto.from]
+ val senderPos = positionToPos.invoke(senderPosition)
+ val senderLat = senderPos?.latitude ?: ""
+ val senderLong = senderPos?.longitude ?: ""
+
+ val rxPosition = nodePositions[myNodeNum]
+ val rxPos = positionToPos.invoke(rxPosition)
+ val rxLat = rxPos?.latitude ?: ""
+ val rxLong = rxPos?.longitude ?: ""
+ val rxAlt = rxPos?.altitude ?: ""
+ val rxSnr = proto.rx_snr
+
+ val dist =
+ if (senderPos == null || rxPos == null) {
+ ""
+ } else {
+ positionToMeter(Position(rxPosition!!), Position(senderPosition!!)).roundToInt().toString()
+ }
+
+ val hopLimit = proto.hop_limit
+ val decoded = proto.decoded
+ val encrypted = proto.encrypted
+ val payload =
+ when {
+ (decoded?.portnum?.value ?: 0) !in
+ setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
+ "<${decoded?.portnum}>"
+
+ decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
+ encrypted != null -> "${encrypted.size} encrypted bytes"
+ else -> ""
+ }
+
+ @Suppress("MaxLineLength")
+ writer.appendLine(
+ "$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
new file mode 100644
index 000000000..8a9905975
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.meshtastic.proto.DeviceProfile
+import java.io.OutputStream
+import javax.inject.Inject
+
+/** Use case for exporting a device profile to an output stream. */
+class ExportProfileUseCase @Inject constructor() {
+ /**
+ * Exports the provided [DeviceProfile] to the given [OutputStream].
+ *
+ * @param outputStream The stream to write the profile to.
+ * @param profile The device profile to export.
+ * @return A [Result] indicating success or failure.
+ */
+ operator fun invoke(outputStream: OutputStream, profile: DeviceProfile): Result = runCatching {
+ outputStream.write(profile.encode())
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
new file mode 100644
index 000000000..2e32ed868
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.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 .
+ */
+package org.meshtastic.core.domain.usecase.settings
+
+import android.util.Base64
+import org.json.JSONObject
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.proto.Config
+import java.io.OutputStream
+import javax.inject.Inject
+
+/** Use case for exporting security configuration to a JSON format. */
+class ExportSecurityConfigUseCase @Inject constructor() {
+ /**
+ * Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
+ *
+ * @param outputStream The stream to write the JSON to.
+ * @param securityConfig The security configuration to export.
+ * @return A [Result] indicating success or failure.
+ */
+ operator fun invoke(outputStream: OutputStream, securityConfig: Config.SecurityConfig): Result = runCatching {
+ val publicKeyBytes = securityConfig.public_key.toByteArray()
+ val privateKeyBytes = securityConfig.private_key.toByteArray()
+
+ // Convert byte arrays to Base64 strings
+ val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
+ val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
+
+ // Create a JSON object
+ val jsonObject =
+ JSONObject().apply {
+ put("timestamp", nowMillis)
+ put("public_key", publicKeyBase64)
+ put("private_key", privateKeyBase64)
+ }
+
+ val jsonString = jsonObject.toString(JSON_INDENT_SPACES)
+ outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
+ }
+
+ private companion object {
+ private const val JSON_INDENT_SPACES = 4
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
new file mode 100644
index 000000000..7dc1a9745
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.meshtastic.proto.DeviceProfile
+import java.io.InputStream
+import javax.inject.Inject
+
+/** Use case for importing a device profile from an input stream. */
+class ImportProfileUseCase @Inject constructor() {
+ /**
+ * Imports a [DeviceProfile] from the provided [InputStream].
+ *
+ * @param inputStream The stream to read the profile from.
+ * @return A [Result] containing the imported [DeviceProfile] or an error.
+ */
+ operator fun invoke(inputStream: InputStream): Result = runCatching {
+ val bytes = inputStream.readBytes()
+ DeviceProfile.ADAPTER.decode(bytes)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
new file mode 100644
index 000000000..20b59f452
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Use case for installing a device profile onto a radio. */
+class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
+ /**
+ * Installs the provided [DeviceProfile] onto the radio at [destNum].
+ *
+ * @param destNum The destination node number.
+ * @param profile The device profile to install.
+ * @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
+ */
+ suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
+ radioController.beginEditSettings(destNum)
+
+ installOwner(destNum, profile, currentUser)
+ installConfig(destNum, profile.config)
+ installFixedPosition(destNum, profile.fixed_position)
+ installModuleConfig(destNum, profile.module_config)
+
+ radioController.commitEditSettings(destNum)
+ }
+
+ private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) {
+ if (profile.long_name != null || profile.short_name != null) {
+ currentUser?.let {
+ val user =
+ it.copy(
+ long_name = profile.long_name ?: it.long_name,
+ short_name = profile.short_name ?: it.short_name,
+ )
+ radioController.setOwner(destNum, user, radioController.getPacketId())
+ }
+ }
+ }
+
+ private suspend fun installConfig(destNum: Int, config: LocalConfig?) {
+ config?.let { lc ->
+ lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) }
+ lc.position?.let {
+ radioController.setConfig(destNum, Config(position = it), radioController.getPacketId())
+ }
+ lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) }
+ lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) }
+ lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) }
+ lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) }
+ lc.bluetooth?.let {
+ radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId())
+ }
+ lc.security?.let {
+ radioController.setConfig(destNum, Config(security = it), radioController.getPacketId())
+ }
+ }
+ }
+
+ private suspend fun installFixedPosition(destNum: Int, fixedPosition: org.meshtastic.proto.Position?) {
+ if (fixedPosition != null) {
+ radioController.setFixedPosition(destNum, Position(fixedPosition))
+ }
+ }
+
+ private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) {
+ moduleConfig?.let { lmc ->
+ installModuleConfigPart1(destNum, lmc)
+ installModuleConfigPart2(destNum, lmc)
+ }
+ }
+
+ private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) {
+ lmc.mqtt?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId())
+ }
+ lmc.serial?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId())
+ }
+ lmc.external_notification?.let {
+ radioController.setModuleConfig(
+ destNum,
+ ModuleConfig(external_notification = it),
+ radioController.getPacketId(),
+ )
+ }
+ lmc.store_forward?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId())
+ }
+ lmc.range_test?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId())
+ }
+ lmc.telemetry?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId())
+ }
+ lmc.canned_message?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId())
+ }
+ lmc.audio?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId())
+ }
+ }
+
+ private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) {
+ lmc.remote_hardware?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId())
+ }
+ lmc.neighbor_info?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId())
+ }
+ lmc.ambient_lighting?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId())
+ }
+ lmc.detection_sensor?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId())
+ }
+ lmc.paxcounter?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId())
+ }
+ lmc.statusmessage?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId())
+ }
+ lmc.traffic_management?.let {
+ radioController.setModuleConfig(
+ destNum,
+ ModuleConfig(traffic_management = it),
+ radioController.getPacketId(),
+ )
+ }
+ lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
new file mode 100644
index 000000000..0e18a33a7
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
@@ -0,0 +1,65 @@
+/*
+ * 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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.radio.RadioPrefs
+import org.meshtastic.core.prefs.radio.isBle
+import org.meshtastic.core.prefs.radio.isSerial
+import org.meshtastic.core.prefs.radio.isTcp
+import javax.inject.Inject
+
+/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
+class IsOtaCapableUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val radioController: RadioController,
+ private val radioPrefs: RadioPrefs,
+ private val deviceHardwareRepository: DeviceHardwareRepository,
+) {
+ operator fun invoke(): Flow = combine(nodeRepository.ourNodeInfo, radioController.connectionState) {
+ node: Node?,
+ connectionState: ConnectionState,
+ ->
+ node to connectionState
+ }
+ .flatMapLatest { (node, connectionState) ->
+ if (node == null || connectionState != ConnectionState.Connected) {
+ flowOf(false)
+ } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
+ val hwModel = node.user.hw_model.value
+ val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
+
+ // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
+ // TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
+ val isEsp32OtaSupported = false
+
+ flowOf(hw?.requiresDfu == true || isEsp32OtaSupported)
+ } else {
+ flowOf(false)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
new file mode 100644
index 000000000..f03f89e23
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.meshtastic.core.model.RadioController
+import javax.inject.Inject
+
+/** Use case for controlling location sharing with the mesh. */
+class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
+ /** Starts providing the phone's location to the mesh. */
+ fun startProvidingLocation() {
+ radioController.startProvideLocation()
+ }
+
+ /** Stops providing the phone's location to the mesh. */
+ fun stopProvidingLocation() {
+ radioController.stopProvideLocation()
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
new file mode 100644
index 000000000..e208a5435
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
@@ -0,0 +1,127 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.DeviceConnectionStatus
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Routing
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Sealed class representing the result of processing a radio response packet. */
+sealed class RadioResponseResult {
+ data class Metadata(val metadata: DeviceMetadata) : RadioResponseResult()
+
+ data class ChannelResponse(val channel: Channel) : RadioResponseResult()
+
+ data class Owner(val user: User) : RadioResponseResult()
+
+ data class ConfigResponse(val config: org.meshtastic.proto.Config) : RadioResponseResult()
+
+ data class ModuleConfigResponse(val config: org.meshtastic.proto.ModuleConfig) : RadioResponseResult()
+
+ data class CannedMessages(val messages: String) : RadioResponseResult()
+
+ data class Ringtone(val ringtone: String) : RadioResponseResult()
+
+ data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult()
+
+ data class Error(val message: UiText) : RadioResponseResult()
+
+ data object Success : RadioResponseResult()
+}
+
+/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
+class ProcessRadioResponseUseCase @Inject constructor() {
+ /**
+ * Decodes and processes the provided [packet].
+ *
+ * @param packet The mesh packet received from the radio.
+ * @param destNum The node number that the response is expected from.
+ * @param requestIds The set of active request IDs.
+ * @return A [RadioResponseResult] if the packet matches a request, or null otherwise.
+ */
+ @Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
+ operator fun invoke(packet: MeshPacket, destNum: Int, requestIds: Set): RadioResponseResult? {
+ val data = packet.decoded
+ if (data == null || data.request_id !in requestIds) {
+ return null
+ }
+
+ return when (data.portnum) {
+ PortNum.ROUTING_APP -> processRoutingResponse(packet, data, destNum)
+ PortNum.ADMIN_APP -> processAdminResponse(packet, data, destNum)
+ else -> null
+ }
+ }
+
+ private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? {
+ val parsed = Routing.ADAPTER.decode(data.payload)
+ return when {
+ parsed.error_reason != Routing.Error.NONE ->
+ RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0)))
+ packet.from == destNum -> RadioResponseResult.Success
+ else -> null
+ }
+ }
+
+ private fun processAdminResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult {
+ if (destNum != packet.from) {
+ return RadioResponseResult.Error(
+ UiText.DynamicString("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}."),
+ )
+ }
+
+ val parsed = AdminMessage.ADAPTER.decode(data.payload)
+ return processAdminMessage(parsed)
+ }
+
+ private fun processAdminMessage(parsed: AdminMessage): RadioResponseResult = when {
+ parsed.get_device_metadata_response != null ->
+ RadioResponseResult.Metadata(parsed.get_device_metadata_response!!)
+
+ parsed.get_channel_response != null -> RadioResponseResult.ChannelResponse(parsed.get_channel_response!!)
+
+ parsed.get_owner_response != null -> RadioResponseResult.Owner(parsed.get_owner_response!!)
+
+ parsed.get_config_response != null -> RadioResponseResult.ConfigResponse(parsed.get_config_response!!)
+
+ parsed.get_module_config_response != null ->
+ RadioResponseResult.ModuleConfigResponse(parsed.get_module_config_response!!)
+
+ parsed.get_canned_message_module_messages_response != null ->
+ RadioResponseResult.CannedMessages(parsed.get_canned_message_module_messages_response!!)
+
+ parsed.get_ringtone_response != null -> RadioResponseResult.Ringtone(parsed.get_ringtone_response!!)
+
+ parsed.get_device_connection_status_response != null ->
+ RadioResponseResult.ConnectionStatus(parsed.get_device_connection_status_response!!)
+
+ else -> {
+ Logger.d { "No custom processing needed for $parsed" }
+ RadioResponseResult.Success
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
new file mode 100644
index 000000000..a65b75209
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Use case for interacting with radio configuration components. */
+@Suppress("TooManyFunctions")
+open class RadioConfigUseCase @Inject constructor(private val radioController: RadioController) {
+ /**
+ * Updates the owner information on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param user The new user configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setOwner(destNum: Int, user: User): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setOwner(destNum, user, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests the owner information from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getOwner(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getOwner(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a configuration section on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param config The new configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setConfig(destNum: Int, config: Config): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setConfig(destNum, config, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a configuration section from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param configType The type of configuration to request (from [org.meshtastic.proto.AdminMessage.ConfigType]).
+ * @return The packet ID of the request.
+ */
+ suspend fun getConfig(destNum: Int, configType: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getConfig(destNum, configType, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a module configuration section on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param config The new module configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setModuleConfig(destNum, config, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a module configuration section from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param moduleConfigType The type of module configuration to request.
+ * @return The packet ID of the request.
+ */
+ suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getModuleConfig(destNum, moduleConfigType, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a channel from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param index The index of the channel to request.
+ * @return The packet ID of the request.
+ */
+ suspend fun getChannel(destNum: Int, index: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getChannel(destNum, index, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a channel on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param channel The new channel configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setRemoteChannel(destNum, channel, packetId)
+ return packetId
+ }
+
+ /** Updates the fixed position on the radio. */
+ suspend fun setFixedPosition(destNum: Int, position: Position) {
+ radioController.setFixedPosition(destNum, position)
+ }
+
+ /** Removes the fixed position on the radio. */
+ suspend fun removeFixedPosition(destNum: Int) {
+ radioController.setFixedPosition(destNum, Position(0.0, 0.0, 0))
+ }
+
+ /** Sets the ringtone on the radio. */
+ suspend fun setRingtone(destNum: Int, ringtone: String) {
+ radioController.setRingtone(destNum, ringtone)
+ }
+
+ /**
+ * Requests the ringtone from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getRingtone(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getRingtone(destNum, packetId)
+ return packetId
+ }
+
+ /** Sets the canned messages on the radio. */
+ suspend fun setCannedMessages(destNum: Int, messages: String) {
+ radioController.setCannedMessages(destNum, messages)
+ }
+
+ /**
+ * Requests the canned messages from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getCannedMessages(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getCannedMessages(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests the device connection status from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getDeviceConnectionStatus(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getDeviceConnectionStatus(destNum, packetId)
+ return packetId
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
new file mode 100644
index 000000000..04462c0f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.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.meshtastic.core.datastore.UiPreferencesDataSource
+import javax.inject.Inject
+
+/** Use case for setting whether the application intro has been completed. */
+class SetAppIntroCompletedUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
+ operator fun invoke(completed: Boolean) {
+ uiPreferencesDataSource.setAppIntroCompleted(completed)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
new file mode 100644
index 000000000..4153ad934
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.database.DatabaseManager
+import javax.inject.Inject
+
+/** Use case for setting the database cache limit. */
+class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
+ operator fun invoke(limit: Int) {
+ val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
+ databaseManager.setCacheLimit(clamped)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
new file mode 100644
index 000000000..360c72bcd
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import javax.inject.Inject
+
+/** Use case for managing mesh log settings. */
+class SetMeshLogSettingsUseCase
+@Inject
+constructor(
+ private val meshLogRepository: MeshLogRepository,
+ private val meshLogPrefs: MeshLogPrefs,
+) {
+ /**
+ * Sets the retention period for mesh logs.
+ *
+ * @param days The number of days to retain logs.
+ */
+ suspend fun setRetentionDays(days: Int) {
+ val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
+ meshLogPrefs.retentionDays = clamped
+ meshLogRepository.deleteLogsOlderThan(clamped)
+ }
+
+ /**
+ * Enables or disables mesh logging.
+ *
+ * @param enabled True to enable logging, false to disable.
+ */
+ suspend fun setLoggingEnabled(enabled: Boolean) {
+ meshLogPrefs.loggingEnabled = enabled
+ if (!enabled) {
+ meshLogRepository.deleteAll()
+ } else {
+ meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
new file mode 100644
index 000000000..fa8daee9e
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.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.meshtastic.core.prefs.ui.UiPrefs
+import javax.inject.Inject
+
+/** Use case for setting whether to provide the node location to the mesh. */
+class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
+ operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
+ uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
new file mode 100644
index 000000000..437e39604
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.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.meshtastic.core.datastore.UiPreferencesDataSource
+import javax.inject.Inject
+
+/** Use case for setting the application theme. */
+class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
+ operator fun invoke(themeMode: Int) {
+ uiPreferencesDataSource.setTheme(themeMode)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
new file mode 100644
index 000000000..0682c4da2
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.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.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import javax.inject.Inject
+
+/** Use case for toggling the analytics preference. */
+class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
+ operator fun invoke() {
+ analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
new file mode 100644
index 000000000..1c83d6886
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.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.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import javax.inject.Inject
+
+/** Use case for toggling the homoglyph encoding preference. */
+class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
+ operator fun invoke() {
+ homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
new file mode 100644
index 000000000..69ec2022a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
@@ -0,0 +1,109 @@
+/*
+ * 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.domain
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.ClientNotification
+
+class FakeRadioController : RadioController {
+
+ // Mutable state flows so we can manipulate them in our tests
+ private val _connectionState = MutableStateFlow(ConnectionState.Connected)
+ override val connectionState: StateFlow = _connectionState
+
+ private val _clientNotification = MutableStateFlow(null)
+ override val clientNotification: StateFlow = _clientNotification
+
+ // Track sent packets to assert in tests
+ val sentPackets = mutableListOf()
+ val favoritedNodes = mutableListOf()
+ val sentSharedContacts = mutableListOf()
+
+ override suspend fun sendMessage(packet: DataPacket) {
+ sentPackets.add(packet)
+ }
+
+ override fun clearClientNotification() {
+ _clientNotification.value = null
+ }
+
+ override suspend fun favoriteNode(nodeNum: Int) {
+ favoritedNodes.add(nodeNum)
+ }
+
+ override suspend fun sendSharedContact(nodeNum: Int) {
+ sentSharedContacts.add(nodeNum)
+ }
+
+ override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
+
+ override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
+
+ override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {}
+
+ override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {}
+
+ override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {}
+
+ override suspend fun setRingtone(destNum: Int, ringtone: String) {}
+
+ override suspend fun setCannedMessages(destNum: Int, messages: String) {}
+
+ override suspend fun getOwner(destNum: Int, packetId: Int) {}
+
+ override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {}
+
+ override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {}
+
+ override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {}
+
+ override suspend fun getRingtone(destNum: Int, packetId: Int) {}
+
+ override suspend fun getCannedMessages(destNum: Int, packetId: Int) {}
+
+ override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {}
+
+ override suspend fun reboot(destNum: Int, packetId: Int) {}
+
+ override suspend fun shutdown(destNum: Int, packetId: Int) {}
+
+ override suspend fun factoryReset(destNum: Int, packetId: Int) {}
+
+ override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
+
+ override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
+
+ override suspend fun beginEditSettings(destNum: Int) {}
+
+ override suspend fun commitEditSettings(destNum: Int) {}
+
+ override fun getPacketId(): Int = 1
+
+ override fun startProvideLocation() {}
+
+ override fun stopProvideLocation() {}
+
+ // --- Helper methods for testing ---
+
+ fun setConnectionState(state: ConnectionState) {
+ _connectionState.value = state
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
similarity index 69%
rename from feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
rename to core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
index 42adf05a8..6c0d0fe6e 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
@@ -14,49 +14,67 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging.domain.usecase
+package org.meshtastic.core.domain.usecase
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
+import io.mockk.slot
+import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.FakeRadioController
+import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
- private lateinit var serviceRepository: ServiceRepository
+ private lateinit var packetRepository: PacketRepository
+ private lateinit var radioController: FakeRadioController
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
+ private lateinit var messageQueue: MessageQueue
private lateinit var useCase: SendMessageUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
- serviceRepository = mockk(relaxed = true)
+ packetRepository = mockk(relaxed = true)
+ radioController = FakeRadioController()
homoglyphEncodingPrefs = mockk(relaxed = true)
+ messageQueue = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
- serviceRepository = serviceRepository,
+ packetRepository = packetRepository,
+ radioController = radioController,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ messageQueue = messageQueue,
)
mockkConstructor(Capabilities::class)
}
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
@Test
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
@@ -69,8 +87,11 @@ class SendMessageUseCaseTest {
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
- coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(0, radioController.favoritedNodes.size)
+ assertEquals(0, radioController.sentSharedContacts.size)
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -86,18 +107,21 @@ class SendMessageUseCaseTest {
val destNode = mockk(relaxed = true)
every { destNode.isFavorite } returns false
+ every { destNode.num } returns 12345
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
-
every { anyConstructed().canSendVerifiedContacts } returns false
// Act
useCase("Direct message", "!dest", null)
// Assert
- coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(1, radioController.favoritedNodes.size)
+ assertEquals(12345, radioController.favoritedNodes[0])
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -112,18 +136,21 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk(relaxed = true)
+ every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
-
every { anyConstructed().canSendVerifiedContacts } returns true
// Act
useCase("Direct message", "!dest", null)
// Assert
- coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(1, radioController.sentSharedContacts.size)
+ assertEquals(67890, radioController.sentSharedContacts[0])
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -133,14 +160,15 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
- // Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
- val originalText = "\u0410pple"
+ val originalText = "\u0410pple" // Cyrillic A
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
- // We verify that send was called with the transformed text (Latin 'A'pple)
- coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
+ val packetSlot = slot()
+ coVerify { packetRepository.insert(capture(packetSlot)) }
+ assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true)
+ coVerify { messageQueue.enqueue(any()) }
}
}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
new file mode 100644
index 000000000..e423ca882
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
@@ -0,0 +1,72 @@
+/*
+ * 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 io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.RadioController
+
+class AdminActionsUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var useCase: AdminActionsUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ nodeRepository = mockk(relaxed = true)
+ useCase = AdminActionsUseCase(radioController, nodeRepository)
+ every { radioController.getPacketId() } returns 42
+ }
+
+ @Test
+ fun `reboot calls radioController and returns packetId`() = runTest {
+ val result = useCase.reboot(123)
+ coVerify { radioController.reboot(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `shutdown calls radioController and returns packetId`() = runTest {
+ val result = useCase.shutdown(123)
+ coVerify { radioController.shutdown(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `factoryReset calls radioController and clears DB if local`() = runTest {
+ val result = useCase.factoryReset(123, isLocal = true)
+ coVerify { radioController.factoryReset(123, 42) }
+ coVerify { nodeRepository.clearNodeDB() }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `nodedbReset calls radioController and clears DB if local`() = runTest {
+ val result = useCase.nodedbReset(123, preserveFavorites = true, isLocal = true)
+ coVerify { radioController.nodedbReset(123, 42, true) }
+ coVerify { nodeRepository.clearNodeDB(true) }
+ assertEquals(42, result)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
new file mode 100644
index 000000000..001c0a5fe
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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 io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.domain.FakeRadioController
+import kotlin.time.Duration.Companion.days
+
+class CleanNodeDatabaseUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var radioController: FakeRadioController
+ private lateinit var useCase: CleanNodeDatabaseUseCase
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk(relaxed = true)
+ radioController = FakeRadioController()
+ useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController)
+ }
+
+ @Test
+ fun `getNodesToClean filters nodes correctly`() = runTest {
+ // Arrange
+ val currentTime = 1000000L
+ val olderThanTimestamp = currentTime - 30.days.inWholeSeconds
+
+ val oldNode = NodeEntity(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
+ val newNode = NodeEntity(num = 2, lastHeard = (currentTime - 1).toInt())
+ val ignoredNode = NodeEntity(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
+
+ coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode)
+
+ // Act
+ val result = useCase.getNodesToClean(30f, false, currentTime)
+
+ // Assert
+ assertEquals(1, result.size)
+ assertEquals(1, result[0].num)
+ }
+
+ @Test
+ fun `cleanNodes calls repository and controller`() = runTest {
+ // Act
+ useCase.cleanNodes(listOf(1, 2))
+
+ // Assert
+ coVerify { nodeRepository.deleteNodes(listOf(1, 2)) }
+ // Note: we can't easily verify removeByNodenum on FakeRadioController without adding tracking
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
new file mode 100644
index 000000000..32dcff37f
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.encodeUtf8
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+import org.robolectric.RobolectricTestRunner
+import java.io.BufferedWriter
+import java.io.StringWriter
+
+@RunWith(RobolectricTestRunner::class)
+class ExportDataUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var meshLogRepository: MeshLogRepository
+ private lateinit var useCase: ExportDataUseCase
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk(relaxed = true)
+ meshLogRepository = mockk(relaxed = true)
+ useCase = ExportDataUseCase(nodeRepository, meshLogRepository)
+ }
+
+ @Test
+ fun `invoke writes header and log data`() = runTest {
+ // Arrange
+ val myNodeNum = 123
+ val senderNodeNum = 456
+ val senderNode = Node(num = senderNodeNum, user = User(long_name = "Sender Name"))
+
+ val nodes = mapOf(senderNodeNum to senderNode)
+ val stateFlow = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns stateFlow
+ every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap())
+
+ val meshPacket =
+ MeshPacket(
+ from = senderNodeNum,
+ rx_snr = 5.5f,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "Hello".encodeUtf8()),
+ )
+ val meshLog =
+ MeshLog(
+ uuid = "uuid-1",
+ message_type = "Packet",
+ received_date = 1700000000000L,
+ raw_message = "",
+ fromNum = senderNodeNum,
+ portNum = PortNum.TEXT_MESSAGE_APP.value,
+ fromRadio = FromRadio(packet = meshPacket),
+ )
+ every { meshLogRepository.getAllLogsInReceiveOrder(any()) } returns flowOf(listOf(meshLog))
+
+ val stringWriter = StringWriter()
+ val bufferedWriter = BufferedWriter(stringWriter)
+
+ // Act
+ useCase(bufferedWriter, myNodeNum)
+ bufferedWriter.flush()
+
+ // Assert
+ val output = stringWriter.toString()
+ assertTrue("Header should be present", output.contains("\"date\",\"time\",\"from\",\"sender name\""))
+ assertTrue("Sender name should be present", output.contains("Sender Name"))
+ assertTrue("Payload should be present", output.contains("Hello"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
new file mode 100644
index 000000000..e2e26f4f2
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.DeviceProfile
+import java.io.ByteArrayOutputStream
+
+class ExportProfileUseCaseTest {
+
+ private lateinit var useCase: ExportProfileUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ExportProfileUseCase()
+ }
+
+ @Test
+ fun `invoke writes encoded profile to output stream`() {
+ // Arrange
+ val profile = DeviceProfile(long_name = "Export Node")
+ val outputStream = ByteArrayOutputStream()
+
+ // Act
+ val result = useCase(outputStream, profile)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ assertArrayEquals(profile.encode(), outputStream.toByteArray())
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
new file mode 100644
index 000000000..b86569cd0
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
@@ -0,0 +1,61 @@
+/*
+ * 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 okio.ByteString.Companion.toByteString
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.proto.Config
+import org.robolectric.RobolectricTestRunner
+import java.io.ByteArrayOutputStream
+
+@RunWith(RobolectricTestRunner::class)
+class ExportSecurityConfigUseCaseTest {
+
+ private lateinit var useCase: ExportSecurityConfigUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ExportSecurityConfigUseCase()
+ }
+
+ @Test
+ fun `invoke writes valid JSON to output stream`() {
+ // Arrange
+ val publicKey = byteArrayOf(1, 2, 3).toByteString()
+ val privateKey = byteArrayOf(4, 5, 6).toByteString()
+ val config = Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
+ val outputStream = ByteArrayOutputStream()
+
+ // Act
+ val result = useCase(outputStream, config)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ val json = JSONObject(outputStream.toString())
+ assertTrue(json.has("timestamp"))
+ assertTrue(json.has("public_key"))
+ assertTrue(json.has("private_key"))
+ // Check base64 values
+ assertEquals("AQID", json.getString("public_key"))
+ assertEquals("BAUG", json.getString("private_key"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
new file mode 100644
index 000000000..7b41a67f8
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.DeviceProfile
+import java.io.ByteArrayInputStream
+
+class ImportProfileUseCaseTest {
+
+ private lateinit var useCase: ImportProfileUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ImportProfileUseCase()
+ }
+
+ @Test
+ fun `invoke with valid data returns profile`() {
+ // Arrange
+ val profile = DeviceProfile(long_name = "Test Node")
+ val inputStream = ByteArrayInputStream(profile.encode())
+
+ // Act
+ val result = useCase(inputStream)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ assertEquals("Test Node", result.getOrNull()?.long_name)
+ }
+
+ @Test
+ fun `invoke with invalid data returns failure`() {
+ // Arrange
+ val inputStream = ByteArrayInputStream(byteArrayOf(1, 2, 3))
+
+ // Act
+ val result = useCase(inputStream)
+
+ // Assert
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
new file mode 100644
index 000000000..411d47a92
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+
+class InstallProfileUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: InstallProfileUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = InstallProfileUseCase(radioController)
+ every { radioController.getPacketId() } returns 1
+ }
+
+ @Test
+ fun `invoke with names updates owner`() = runTest {
+ // Arrange
+ val profile = DeviceProfile(long_name = "New Long", short_name = "NL")
+ val currentUser = User(long_name = "Old Long", short_name = "OL")
+
+ // Act
+ useCase(123, profile, currentUser)
+
+ // Assert
+ coVerify { radioController.beginEditSettings(123) }
+ coVerify { radioController.setOwner(123, match { it.long_name == "New Long" && it.short_name == "NL" }, 1) }
+ coVerify { radioController.commitEditSettings(123) }
+ }
+
+ @Test
+ fun `invoke with config sets config`() = runTest {
+ // Arrange
+ val loraConfig = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US)
+ val profile = DeviceProfile(config = LocalConfig(lora = loraConfig))
+
+ // Act
+ useCase(456, profile, null)
+
+ // Assert
+ coVerify { radioController.setConfig(456, match { it.lora == loraConfig }, 1) }
+ }
+
+ @Test
+ fun `invoke with module_config sets module config`() = runTest {
+ // Arrange
+ val mqttConfig = ModuleConfig.MQTTConfig(enabled = true, address = "broker.local")
+ val profile = DeviceProfile(module_config = LocalModuleConfig(mqtt = mqttConfig))
+
+ // Act
+ useCase(789, profile, null)
+
+ // Assert
+ coVerify { radioController.setModuleConfig(789, match { it.mqtt == mqttConfig }, 1) }
+ }
+
+ @Test
+ fun `invoke with module_config part 2 sets module config`() = runTest {
+ // Arrange
+ val neighborInfoConfig = ModuleConfig.NeighborInfoConfig(enabled = true)
+ val profile = DeviceProfile(module_config = LocalModuleConfig(neighbor_info = neighborInfoConfig))
+
+ // Act
+ useCase(789, profile, null)
+
+ // Assert
+ coVerify { radioController.setModuleConfig(789, match { it.neighbor_info == neighborInfoConfig }, 1) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
new file mode 100644
index 000000000..41db758c7
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
@@ -0,0 +1,124 @@
+/*
+ * 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 app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.radio.RadioPrefs
+
+class IsOtaCapableUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var radioController: RadioController
+ private lateinit var radioPrefs: RadioPrefs
+ private lateinit var deviceHardwareRepository: DeviceHardwareRepository
+ private lateinit var useCase: IsOtaCapableUseCase
+
+ private val ourNodeInfoFlow = MutableStateFlow(null)
+ private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk { every { ourNodeInfo } returns ourNodeInfoFlow }
+ radioController = mockk { every { connectionState } returns connectionStateFlow }
+ radioPrefs = mockk(relaxed = true)
+ deviceHardwareRepository = mockk(relaxed = true)
+
+ useCase = IsOtaCapableUseCase(nodeRepository, radioController, radioPrefs, deviceHardwareRepository)
+ }
+
+ @Test
+ fun `returns false when node is null`() = runTest {
+ ourNodeInfoFlow.value = null
+ connectionStateFlow.value = ConnectionState.Connected
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when not connected`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Disconnected
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when radio is not BLE, Serial, or TCP`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "m123" // Mock
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns true when hw requires Dfu`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { every { requiresDfu } returns true }
+ coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
+
+ useCase().test {
+ assertTrue(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when hw does not require Dfu and isEsp32OtaSupported is false`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { every { requiresDfu } returns false }
+ coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
new file mode 100644
index 000000000..95910cc78
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
@@ -0,0 +1,47 @@
+/*
+ * 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 io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.RadioController
+
+class MeshLocationUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: MeshLocationUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = MeshLocationUseCase(radioController)
+ }
+
+ @Test
+ fun `startProvidingLocation calls radioController`() {
+ useCase.startProvidingLocation()
+ verify { radioController.startProvideLocation() }
+ }
+
+ @Test
+ fun `stopProvidingLocation calls radioController`() {
+ useCase.stopProvidingLocation()
+ verify { radioController.stopProvideLocation() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
new file mode 100644
index 000000000..9489a804e
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Routing
+
+class ProcessRadioResponseUseCaseTest {
+
+ private lateinit var useCase: ProcessRadioResponseUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ProcessRadioResponseUseCase()
+ }
+
+ @Test
+ fun `invoke with routing error returns error result`() {
+ // Arrange
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded =
+ Data(
+ portnum = PortNum.ROUTING_APP,
+ request_id = 42,
+ payload = Routing(error_reason = Routing.Error.NO_ROUTE).encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.Error)
+ }
+
+ @Test
+ fun `invoke with metadata response returns metadata result`() {
+ // Arrange
+ val metadata = DeviceMetadata(firmware_version = "2.5.0")
+ val adminMsg = AdminMessage(get_device_metadata_response = metadata)
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded = Data(
+ portnum = PortNum.ADMIN_APP,
+ request_id = 42,
+ payload = adminMsg.encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.Metadata)
+ assertEquals("2.5.0", (result as RadioResponseResult.Metadata).metadata.firmware_version)
+ }
+
+ @Test
+ fun `invoke with canned messages response returns canned messages result`() {
+ // Arrange
+ val adminMsg = AdminMessage(get_canned_message_module_messages_response = "Hello World")
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded = Data(
+ portnum = PortNum.ADMIN_APP,
+ request_id = 42,
+ payload = adminMsg.encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.CannedMessages)
+ assertEquals("Hello World", (result as RadioResponseResult.CannedMessages).messages)
+ }
+
+ private fun ByteArray.toByteString() = okio.ByteString.of(*this)
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
new file mode 100644
index 000000000..29e26406c
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
@@ -0,0 +1,160 @@
+/*
+ * 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 io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+
+class RadioConfigUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: RadioConfigUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = RadioConfigUseCase(radioController)
+ every { radioController.getPacketId() } returns 42
+ }
+
+ @Test
+ fun `setOwner calls radioController and returns packetId`() = runTest {
+ val user = User(long_name = "New Name")
+ val result = useCase.setOwner(123, user)
+
+ coVerify { radioController.setOwner(123, user, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getOwner calls radioController and returns packetId`() = runTest {
+ val result = useCase.getOwner(123)
+
+ coVerify { radioController.getOwner(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setConfig calls radioController and returns packetId`() = runTest {
+ val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
+ val result = useCase.setConfig(123, config)
+
+ coVerify { radioController.setConfig(123, config, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getConfig calls radioController and returns packetId`() = runTest {
+ val result = useCase.getConfig(123, 1)
+
+ coVerify { radioController.getConfig(123, 1, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setModuleConfig calls radioController and returns packetId`() = runTest {
+ val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
+ val result = useCase.setModuleConfig(123, config)
+
+ coVerify { radioController.setModuleConfig(123, config, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getModuleConfig calls radioController and returns packetId`() = runTest {
+ val result = useCase.getModuleConfig(123, 2)
+
+ coVerify { radioController.getModuleConfig(123, 2, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getChannel calls radioController and returns packetId`() = runTest {
+ val result = useCase.getChannel(123, 0)
+
+ coVerify { radioController.getChannel(123, 0, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setRemoteChannel calls radioController and returns packetId`() = runTest {
+ val channel = Channel(index = 0)
+ val result = useCase.setRemoteChannel(123, channel)
+
+ coVerify { radioController.setRemoteChannel(123, channel, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setFixedPosition calls radioController`() = runTest {
+ val pos = Position(1.0, 2.0, 3)
+ useCase.setFixedPosition(123, pos)
+
+ coVerify { radioController.setFixedPosition(123, pos) }
+ }
+
+ @Test
+ fun `removeFixedPosition calls radioController with zero position`() = runTest {
+ useCase.removeFixedPosition(123)
+
+ coVerify { radioController.setFixedPosition(123, any()) }
+ }
+
+ @Test
+ fun `setRingtone calls radioController`() = runTest {
+ useCase.setRingtone(123, "ring")
+ coVerify { radioController.setRingtone(123, "ring") }
+ }
+
+ @Test
+ fun `getRingtone calls radioController and returns packetId`() = runTest {
+ val result = useCase.getRingtone(123)
+ coVerify { radioController.getRingtone(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setCannedMessages calls radioController`() = runTest {
+ useCase.setCannedMessages(123, "msg")
+ coVerify { radioController.setCannedMessages(123, "msg") }
+ }
+
+ @Test
+ fun `getCannedMessages calls radioController and returns packetId`() = runTest {
+ val result = useCase.getCannedMessages(123)
+ coVerify { radioController.getCannedMessages(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getDeviceConnectionStatus calls radioController and returns packetId`() = runTest {
+ val result = useCase.getDeviceConnectionStatus(123)
+ coVerify { radioController.getDeviceConnectionStatus(123, 42) }
+ assertEquals(42, result)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
new file mode 100644
index 000000000..08e485c9a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+
+class SetAppIntroCompletedUseCaseTest {
+
+ private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
+ private lateinit var useCase: SetAppIntroCompletedUseCase
+
+ @Before
+ fun setUp() {
+ uiPreferencesDataSource = mockk(relaxed = true)
+ useCase = SetAppIntroCompletedUseCase(uiPreferencesDataSource)
+ }
+
+ @Test
+ fun `invoke calls setAppIntroCompleted on data source`() {
+ // Act
+ useCase(true)
+
+ // Assert
+ verify { uiPreferencesDataSource.setAppIntroCompleted(true) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
new file mode 100644
index 000000000..1551ab32d
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.database.DatabaseManager
+
+class SetDatabaseCacheLimitUseCaseTest {
+
+ private lateinit var databaseManager: DatabaseManager
+ private lateinit var useCase: SetDatabaseCacheLimitUseCase
+
+ @Before
+ fun setUp() {
+ databaseManager = mockk(relaxed = true)
+ useCase = SetDatabaseCacheLimitUseCase(databaseManager)
+ }
+
+ @Test
+ fun `invoke calls setCacheLimit with clamped value`() {
+ // Act & Assert
+ useCase(0)
+ verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) }
+
+ useCase(100)
+ verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) }
+
+ useCase(5)
+ verify { databaseManager.setCacheLimit(5) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
new file mode 100644
index 000000000..748587b6a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
@@ -0,0 +1,74 @@
+/*
+ * 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 io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+
+class SetMeshLogSettingsUseCaseTest {
+
+ private lateinit var meshLogRepository: MeshLogRepository
+ private lateinit var meshLogPrefs: MeshLogPrefs
+ private lateinit var useCase: SetMeshLogSettingsUseCase
+
+ @Before
+ fun setUp() {
+ meshLogRepository = mockk(relaxed = true)
+ meshLogPrefs = mockk(relaxed = true)
+ useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
+ }
+
+ @Test
+ fun `setRetentionDays clamps and updates prefs and repository`() = runTest {
+ // Act
+ useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
+
+ // Assert
+ verify { meshLogPrefs.retentionDays = MeshLogPrefs.MIN_RETENTION_DAYS }
+ coVerify { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
+ }
+
+ @Test
+ fun `setLoggingEnabled true triggers cleanup`() = runTest {
+ // Arrange
+ every { meshLogPrefs.retentionDays } returns 30
+
+ // Act
+ useCase.setLoggingEnabled(true)
+
+ // Assert
+ verify { meshLogPrefs.loggingEnabled = true }
+ coVerify { meshLogRepository.deleteLogsOlderThan(30) }
+ }
+
+ @Test
+ fun `setLoggingEnabled false triggers deletion`() = runTest {
+ // Act
+ useCase.setLoggingEnabled(false)
+
+ // Assert
+ verify { meshLogPrefs.loggingEnabled = false }
+ coVerify { meshLogRepository.deleteAll() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
new file mode 100644
index 000000000..240b07876
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.ui.UiPrefs
+
+class SetProvideLocationUseCaseTest {
+
+ private lateinit var uiPrefs: UiPrefs
+ private lateinit var useCase: SetProvideLocationUseCase
+
+ @Before
+ fun setUp() {
+ uiPrefs = mockk(relaxed = true)
+ useCase = SetProvideLocationUseCase(uiPrefs)
+ }
+
+ @Test
+ fun `invoke calls setShouldProvideNodeLocation on uiPrefs`() {
+ // Act
+ useCase(1234, true)
+
+ // Assert
+ verify { uiPrefs.setShouldProvideNodeLocation(1234, true) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
new file mode 100644
index 000000000..7d04ce7bc
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+
+class SetThemeUseCaseTest {
+
+ private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
+ private lateinit var useCase: SetThemeUseCase
+
+ @Before
+ fun setUp() {
+ uiPreferencesDataSource = mockk(relaxed = true)
+ useCase = SetThemeUseCase(uiPreferencesDataSource)
+ }
+
+ @Test
+ fun `invoke calls setTheme on data source`() {
+ // Act
+ useCase(1)
+
+ // Assert
+ verify { uiPreferencesDataSource.setTheme(1) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
new file mode 100644
index 000000000..63fbf2b2a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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 io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+
+class ToggleAnalyticsUseCaseTest {
+
+ private lateinit var analyticsPrefs: AnalyticsPrefs
+ private lateinit var useCase: ToggleAnalyticsUseCase
+
+ @Before
+ fun setUp() {
+ analyticsPrefs = mockk(relaxed = true)
+ useCase = ToggleAnalyticsUseCase(analyticsPrefs)
+ }
+
+ @Test
+ fun `invoke toggles analytics from false to true`() {
+ // Arrange
+ every { analyticsPrefs.analyticsAllowed } returns false
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { analyticsPrefs.analyticsAllowed = true }
+ }
+
+ @Test
+ fun `invoke toggles analytics from true to false`() {
+ // Arrange
+ every { analyticsPrefs.analyticsAllowed } returns true
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { analyticsPrefs.analyticsAllowed = false }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
new file mode 100644
index 000000000..f8cf978af
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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 io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+
+class ToggleHomoglyphEncodingUseCaseTest {
+
+ private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
+ private lateinit var useCase: ToggleHomoglyphEncodingUseCase
+
+ @Before
+ fun setUp() {
+ homoglyphEncodingPrefs = mockk(relaxed = true)
+ useCase = ToggleHomoglyphEncodingUseCase(homoglyphEncodingPrefs)
+ }
+
+ @Test
+ fun `invoke toggles homoglyph encoding from false to true`() {
+ // Arrange
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = true }
+ }
+
+ @Test
+ fun `invoke toggles homoglyph encoding from true to false`() {
+ // Arrange
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = false }
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
similarity index 94%
rename from core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index 0e8beedae..0af5a0efd 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.core.service
+package org.meshtastic.core.model
sealed class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
new file mode 100644
index 000000000..286f32ddb
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -0,0 +1,90 @@
+/*
+ * 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
+
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.proto.ClientNotification
+
+@Suppress("TooManyFunctions")
+interface RadioController {
+ val connectionState: StateFlow
+ val clientNotification: StateFlow
+
+ suspend fun sendMessage(packet: DataPacket)
+
+ fun clearClientNotification()
+
+ // Abstracted ServiceActions
+ suspend fun favoriteNode(nodeNum: Int)
+
+ suspend fun sendSharedContact(nodeNum: Int)
+
+ // Radio configuration
+ suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
+
+ suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
+
+ suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
+
+ suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
+
+ suspend fun setFixedPosition(destNum: Int, position: Position)
+
+ suspend fun setRingtone(destNum: Int, ringtone: String)
+
+ suspend fun setCannedMessages(destNum: Int, messages: String)
+
+ // Admin get operations
+ suspend fun getOwner(destNum: Int, packetId: Int)
+
+ suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
+
+ suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
+
+ suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
+
+ suspend fun getRingtone(destNum: Int, packetId: Int)
+
+ suspend fun getCannedMessages(destNum: Int, packetId: Int)
+
+ suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
+
+ // Admin operations
+ suspend fun reboot(destNum: Int, packetId: Int)
+
+ suspend fun shutdown(destNum: Int, packetId: Int)
+
+ suspend fun factoryReset(destNum: Int, packetId: Int)
+
+ suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
+
+ suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
+
+ // Batch editing
+ suspend fun beginEditSettings(destNum: Int)
+
+ suspend fun commitEditSettings(destNum: Int)
+
+ // Helpers
+ fun getPacketId(): Int
+
+ /** Starts providing the phone's location to the mesh. */
+ fun startProvideLocation()
+
+ /** Stops providing the phone's location to the mesh. */
+ fun stopProvideLocation()
+}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index a71e0ec3a..8245b887e 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -16,7 +16,10 @@
*/
import com.android.build.api.dsl.LibraryExtension
-plugins { alias(libs.plugins.meshtastic.android.library) }
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.hilt)
+}
configure {
buildFeatures { aidl = true }
@@ -28,6 +31,7 @@ configure {
dependencies {
api(projects.core.api)
implementation(projects.core.common)
+ implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
@@ -39,4 +43,5 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
+ testImplementation(libs.turbine)
}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
new file mode 100644
index 000000000..ae582faa3
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.service
+
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.ClientNotification
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@Suppress("TooManyFunctions")
+class AndroidRadioControllerImpl
+@Inject
+constructor(
+ private val serviceRepository: ServiceRepository,
+ private val nodeRepository: NodeRepository,
+) : RadioController {
+
+ override val connectionState: StateFlow
+ get() = serviceRepository.connectionState
+
+ override val clientNotification: StateFlow
+ get() = serviceRepository.clientNotification
+
+ override suspend fun sendMessage(packet: DataPacket) {
+ // Bridging to the existing flow via IMeshService
+ serviceRepository.meshService?.send(packet)
+ }
+
+ override fun clearClientNotification() {
+ serviceRepository.clearClientNotification()
+ }
+
+ override suspend fun favoriteNode(nodeNum: Int) {
+ val nodeDef = nodeRepository.getNode(nodeNum.toString())
+ serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
+ }
+
+ override suspend fun sendSharedContact(nodeNum: Int) {
+ val nodeDef = nodeRepository.getNode(nodeNum.toString())
+ val contact =
+ org.meshtastic.proto.SharedContact(
+ node_num = nodeDef.num,
+ user = nodeDef.user,
+ manually_verified = nodeDef.manuallyVerified,
+ )
+ serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
+ }
+
+ override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
+ serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
+ }
+
+ override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {
+ serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode())
+ }
+
+ override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {
+ serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode())
+ }
+
+ override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {
+ serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode())
+ }
+
+ override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {
+ serviceRepository.meshService?.setFixedPosition(destNum, position)
+ }
+
+ override suspend fun setRingtone(destNum: Int, ringtone: String) {
+ serviceRepository.meshService?.setRingtone(destNum, ringtone)
+ }
+
+ override suspend fun setCannedMessages(destNum: Int, messages: String) {
+ serviceRepository.meshService?.setCannedMessages(destNum, messages)
+ }
+
+ override suspend fun getOwner(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteOwner(packetId, destNum)
+ }
+
+ override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteConfig(packetId, destNum, configType)
+ }
+
+ override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
+ serviceRepository.meshService?.getModuleConfig(packetId, destNum, moduleConfigType)
+ }
+
+ override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteChannel(packetId, destNum, index)
+ }
+
+ override suspend fun getRingtone(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getRingtone(packetId, destNum)
+ }
+
+ override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getCannedMessages(packetId, destNum)
+ }
+
+ override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getDeviceConnectionStatus(packetId, destNum)
+ }
+
+ override suspend fun reboot(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestReboot(packetId, destNum)
+ }
+
+ override suspend fun shutdown(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestShutdown(packetId, destNum)
+ }
+
+ override suspend fun factoryReset(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestFactoryReset(packetId, destNum)
+ }
+
+ override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
+ serviceRepository.meshService?.requestNodedbReset(packetId, destNum, preserveFavorites)
+ }
+
+ override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
+ serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
+ }
+
+ override suspend fun beginEditSettings(destNum: Int) {
+ serviceRepository.meshService?.beginEditSettings(destNum)
+ }
+
+ override suspend fun commitEditSettings(destNum: Int) {
+ serviceRepository.meshService?.commitEditSettings(destNum)
+ }
+
+ override fun getPacketId(): Int = serviceRepository.meshService?.getPacketId() ?: 0
+
+ override fun startProvideLocation() {
+ serviceRepository.meshService?.startProvideLocation()
+ }
+
+ override fun stopProvideLocation() {
+ serviceRepository.meshService?.stopProvideLocation()
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 77f2b49c0..858e1695b 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
@@ -44,7 +45,7 @@ data class TracerouteResponse(
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
-class ServiceRepository @Inject constructor() {
+open class ServiceRepository @Inject constructor() {
var meshService: IMeshService? = null
private set
@@ -54,7 +55,7 @@ class ServiceRepository @Inject constructor() {
// Connection state to our radio device
private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow
+ open val connectionState: StateFlow
get() = _connectionState
fun setConnectionState(connectionState: ConnectionState) {
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
new file mode 100644
index 000000000..0df2b76e5
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.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.service.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.service.AndroidRadioControllerImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ServiceModule {
+
+ @Binds abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
+}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 81d60db69..84a5e9538 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.datastore.BootloaderWarningDataSource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
@@ -71,7 +72,6 @@ import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_updating
import org.meshtastic.core.resources.firmware_update_validating
import org.meshtastic.core.resources.unknown
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.io.File
import javax.inject.Inject
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 5147bef41..7a4215220 100644
--- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -36,7 +36,7 @@ import com.google.accompanist.permissions.rememberPermissionState
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
+fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel = hiltViewModel()) {
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
index 571f3ac0d..10972edb3 100644
--- a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -44,9 +44,9 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 36cbbe824..97b81c776 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -26,8 +26,10 @@ configure { namespace = "org.meshtastic.feature.messaging" }
dependencies {
implementation(projects.core.analytics)
+ implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
+ implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
@@ -50,6 +52,9 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.kermit)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -59,4 +64,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+ testImplementation(libs.androidx.work.testing)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.robolectric)
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
index 2c2835c75..91bda8f2e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -100,6 +100,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 774faac4a..174b48588 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -39,6 +39,7 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.SendMessageUseCase
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
@@ -47,7 +48,6 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
new file mode 100644
index 000000000..616765d1d
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.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.feature.messaging.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.domain.MessageQueue
+import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class MessagingModule {
+
+ @Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
new file mode 100644
index 000000000..49d11fa10
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.feature.messaging.domain.worker
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
+
+@HiltWorker
+class SendMessageWorker
+@AssistedInject
+constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val packetRepository: PacketRepository,
+ private val radioController: RadioController,
+) : CoroutineWorker(context, params) {
+
+ @Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
+ override suspend fun doWork(): Result {
+ val packetId = inputData.getInt(KEY_PACKET_ID, 0)
+ if (packetId == 0) return Result.failure()
+
+ // Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
+ if (radioController.connectionState.value != ConnectionState.Connected) {
+ return Result.retry()
+ }
+
+ val packetEntity =
+ packetRepository.getPacketByPacketId(packetId)
+ ?: return Result.failure() // Packet no longer exists in DB? Do not retry.
+
+ val packetData = packetEntity.packet.data
+
+ return try {
+ radioController.sendMessage(packetData)
+ packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
+ Result.success()
+ } catch (e: Exception) {
+ packetRepository.updateMessageStatus(packetData, MessageStatus.ERROR)
+ Result.retry()
+ }
+ }
+
+ companion object {
+ const val KEY_PACKET_ID = "packet_id"
+ const val WORK_NAME_PREFIX = "send_message_"
+ }
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
new file mode 100644
index 000000000..a7b829be0
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.feature.messaging.domain.worker
+
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import org.meshtastic.core.domain.MessageQueue
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
+@Singleton
+class WorkManagerMessageQueue @Inject constructor(private val workManager: WorkManager) : MessageQueue {
+
+ override suspend fun enqueue(packetId: Int) {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
index f521c5e07..b5b634d6a 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
@@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
class HomoglyphCharacterTransformTest {
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
new file mode 100644
index 000000000..48abe99de
--- /dev/null
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.feature.messaging.domain.worker
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.ListenableWorker
+import androidx.work.WorkerParameters
+import androidx.work.testing.TestListenableWorkerBuilder
+import androidx.work.workDataOf
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.entity.PacketEntity
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SendMessageWorkerTest {
+
+ private lateinit var context: Context
+ private lateinit var packetRepository: PacketRepository
+ private lateinit var radioController: RadioController
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ packetRepository = mockk(relaxed = true)
+ radioController = mockk(relaxed = true)
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+ }
+
+ @Test
+ fun `doWork returns success when packet is sent successfully`() = runTest {
+ // Arrange
+ val packetId = 12345
+ val dataPacket = DataPacket("dest", 0, "Hello")
+ val packet = mockk(relaxed = true)
+ val packetEntity = PacketEntity(packet = packet)
+ every { packet.data } returns dataPacket
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+ coEvery { radioController.sendMessage(any()) } just Runs
+ coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.success(), result)
+ coVerify { radioController.sendMessage(dataPacket) }
+ coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
+ }
+
+ @Test
+ fun `doWork returns retry when radio is disconnected`() = runTest {
+ // Arrange
+ val packetId = 12345
+ val dataPacket = DataPacket("dest", 0, "Hello")
+ val packet = mockk(relaxed = true)
+ val packetEntity = PacketEntity(packet = packet)
+ every { packet.data } returns dataPacket
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.retry(), result)
+ coVerify(exactly = 0) { radioController.sendMessage(any()) }
+ }
+
+ @Test
+ fun `doWork returns failure when packet is missing`() = runTest {
+ // Arrange
+ val packetId = 999
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.failure(), result)
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 755c68175..f8b895552 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
@@ -63,7 +64,6 @@ import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.voltage
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AirQualityInfo
import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index 919515426..5546b3cbe 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
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.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -46,7 +47,6 @@ 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.service.ConnectionState
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 6b95f55fa..f2a823296 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
@@ -79,7 +80,6 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 9a7de65a8..5c02a427e 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
+ implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.nfc)
@@ -57,7 +58,10 @@ dependencies {
implementation(libs.nordic.common.permissions.ble)
testImplementation(libs.junit)
+ testImplementation(libs.mockk)
testImplementation(libs.robolectric)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
index 1d5c16f4e..d5fbcc31f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
@@ -47,8 +47,8 @@ import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.radio.AdminRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
index 77dc42419..61d551d8e 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
@@ -35,8 +35,8 @@ import org.meshtastic.core.resources.device_configuration
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
index 630d19c0b..788292573 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -36,8 +36,8 @@ import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index bd5ebc655..d24a6c1cd 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -16,101 +16,54 @@
*/
package org.meshtastic.feature.settings
-import android.Manifest
import android.app.Activity
import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.annotation.VisibleForTesting
-import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
-import androidx.compose.material.icons.filled.Abc
-import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.rounded.AppSettingsAlt
-import androidx.compose.material.icons.rounded.FormatPaint
-import androidx.compose.material.icons.rounded.Info
-import androidx.compose.material.icons.rounded.Language
-import androidx.compose.material.icons.rounded.LocationOn
-import androidx.compose.material.icons.rounded.Memory
-import androidx.compose.material.icons.rounded.Output
-import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.gpsDisabled
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.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.acknowledgements
-import org.meshtastic.core.resources.analytics_okay
-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.choose_theme
-import org.meshtastic.core.resources.device_db_cache_limit
-import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
-import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.import_configuration
-import org.meshtastic.core.resources.intro_show
-import org.meshtastic.core.resources.location_disabled
-import org.meshtastic.core.resources.modules_already_unlocked
-import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
-import org.meshtastic.core.resources.provide_location_to_mesh
import org.meshtastic.core.resources.remotely_administrating
-import org.meshtastic.core.resources.save_rangetest
-import org.meshtastic.core.resources.system_settings
-import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
-import org.meshtastic.core.resources.use_homoglyph_characters_encoding
-import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
-import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.settings.component.AppInfoSection
+import org.meshtastic.feature.settings.component.AppearanceSection
+import org.meshtastic.feature.settings.component.PersistenceSection
+import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
@@ -119,7 +72,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Locale
-import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -259,226 +211,37 @@ fun SettingsScreen(
onNavigate = onNavigate,
)
- val context = LocalContext.current
+ PrivacySection(
+ analyticsAvailable = state.analyticsAvailable,
+ analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
+ onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
+ provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
+ onToggleLocation = { settingsViewModel.setProvideLocation(it) },
+ homoglyphEnabled = viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false).value,
+ onToggleHomoglyph = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
+ startProvideLocation = { settingsViewModel.startProvidingLocation() },
+ stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
+ )
- ExpressiveSection(title = stringResource(Res.string.app_settings)) {
- if (state.analyticsAvailable) {
- val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
- SwitchListItem(
- text = stringResource(Res.string.analytics_okay),
- checked = allowed,
- leadingIcon = Icons.Default.BugReport,
- onClick = { viewModel.toggleAnalyticsAllowed() },
- )
- }
+ AppearanceSection(
+ onShowLanguagePicker = { showLanguagePickerDialog = true },
+ onShowThemePicker = { showThemePickerDialog = true },
+ )
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- val isGpsDisabled = context.gpsDisabled()
- val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
+ PersistenceSection(
+ cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
+ onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
+ nodeShortName = ourNode?.user?.short_name ?: "",
+ onExportData = { settingsViewModel.saveDataCsv(it) },
+ )
- LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
- if (provideLocation) {
- if (locationPermissionsState.allPermissionsGranted) {
- if (!isGpsDisabled) {
- settingsViewModel.meshService?.startProvideLocation()
- } else {
- context.showToast(Res.string.location_disabled)
- }
- } else {
- // Request permissions if not granted and user wants to provide location
- locationPermissionsState.launchMultiplePermissionRequest()
- }
- } else {
- settingsViewModel.meshService?.stopProvideLocation()
- }
- }
-
- SwitchListItem(
- text = stringResource(Res.string.provide_location_to_mesh),
- leadingIcon = Icons.Rounded.LocationOn,
- enabled = !isGpsDisabled,
- checked = provideLocation,
- onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
- )
-
- val homoglyphEncodingEnabled by
- viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
-
- HomoglyphSetting(
- homoglyphEncodingEnabled = homoglyphEncodingEnabled,
- onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
- )
-
- val settingsLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
-
- // On Android 12 and below, system app settings for language are not available. Use the in-app language
- // picker for these devices.
- val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
- ListItem(
- text = stringResource(Res.string.preferences_language),
- leadingIcon = Icons.Rounded.Language,
- trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- if (useInAppLangPicker) {
- showLanguagePickerDialog = true
- } else {
- val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
- if (intent.resolveActivity(context.packageManager) != null) {
- settingsLauncher.launch(intent)
- } else {
- // Fall back to the in-app picker
- showLanguagePickerDialog = true
- }
- }
- }
-
- ListItem(
- text = stringResource(Res.string.theme),
- leadingIcon = Icons.Rounded.FormatPaint,
- trailingIcon = null,
- ) {
- showThemePickerDialog = true
- }
-
- // Node DB cache limit (App setting)
- val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
- val cacheItems = remember {
- (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
- it.toLong() to it.toString()
- }
- }
- DropDownPreference(
- title = stringResource(Res.string.device_db_cache_limit),
- enabled = true,
- items = cacheItems,
- selectedItem = cacheLimit.toLong(),
- onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
- summary = stringResource(Res.string.device_db_cache_limit_summary),
- )
-
- val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
- val nodeName = ourNode?.user?.short_name ?: ""
-
- val exportRangeTestLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.save_rangetest),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
- }
- exportRangeTestLauncher.launch(intent)
- }
-
- val exportDataLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.export_data_csv),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
- }
- exportDataLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.intro_show),
- leadingIcon = Icons.Rounded.WavingHand,
- trailingIcon = null,
- ) {
- settingsViewModel.showAppIntro()
- }
-
- ListItem(
- text = stringResource(Res.string.system_settings),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = null,
- ) {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
- intent.data = Uri.fromParts("package", context.packageName, null)
- settingsLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.acknowledgements),
- leadingIcon = Icons.Rounded.Info,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- onNavigate(SettingsRoutes.About)
- }
-
- AppVersionButton(
- excludedModulesUnlocked = excludedModulesUnlocked,
- appVersionName = settingsViewModel.appVersionName,
- ) {
- settingsViewModel.unlockExcludedModules()
- }
- }
- }
- }
-}
-
-private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
-private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
-private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
-
-/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
-@Composable
-private fun AppVersionButton(
- excludedModulesUnlocked: Boolean,
- appVersionName: String,
- onUnlockExcludedModules: () -> Unit,
-) {
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- var clickCount by remember { mutableIntStateOf(0) }
-
- LaunchedEffect(clickCount) {
- if (clickCount in 1.. {
- clickCount = 0
- scope.launch { context.showToast(Res.string.modules_already_unlocked) }
- }
-
- clickCount == UNLOCK_CLICK_COUNT -> {
- clickCount = 0
- onUnlockExcludedModules()
- scope.launch { context.showToast(Res.string.modules_unlocked) }
- }
+ AppInfoSection(
+ appVersionName = settingsViewModel.appVersionName,
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
+ onShowAppIntro = { settingsViewModel.showAppIntro() },
+ onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
+ )
}
}
}
@@ -525,18 +288,3 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit
},
)
}
-
-@VisibleForTesting
-@Composable
-fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
- val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
- val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
- if (currentLocale?.language in supportedLanguages) {
- SwitchListItem(
- text = stringResource(Res.string.use_homoglyph_characters_encoding),
- checked = homoglyphEncodingEnabled,
- leadingIcon = Icons.Default.Abc,
- onClick = onToggle,
- )
- }
-}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index 9ed773068..a75296c13 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -16,8 +16,6 @@
*/
package org.meshtastic.feature.settings
-import android.app.Application
-import android.icu.text.SimpleDateFormat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -27,64 +25,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.data.repository.DeviceHardwareRepository
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.datastore.UiPreferencesDataSource
-import org.meshtastic.core.model.Capabilities
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.util.positionToMeter
+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.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
-import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.prefs.radio.isBle
-import org.meshtastic.core.prefs.radio.isSerial
-import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
-import java.util.Locale
import javax.inject.Inject
-import kotlin.math.roundToInt
-import org.meshtastic.proto.Position as ProtoPosition
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
- private val app: Application,
+ private val app: android.app.Application,
radioConfigRepository: RadioConfigRepository,
- private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val nodeRepository: NodeRepository,
- private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
- private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
- private val deviceHardwareRepository: DeviceHardwareRepository,
- private val radioPrefs: RadioPrefs,
private val meshLogPrefs: MeshLogPrefs,
+ private val setThemeUseCase: SetThemeUseCase,
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
+ private val setProvideLocationUseCase: SetProvideLocationUseCase,
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
+ private val meshLocationUseCase: MeshLocationUseCase,
+ private val exportDataUseCase: ExportDataUseCase,
+ private val isOtaCapableUseCase: IsOtaCapableUseCase,
) : ViewModel() {
val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
@@ -94,14 +85,11 @@ constructor(
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
val isConnected =
- serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
+ radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val localConfig: StateFlow =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
- val meshService: IMeshService?
- get() = serviceRepository.meshService
-
val provideLocation: StateFlow =
myNodeInfo
.flatMapLatest { myNodeEntity ->
@@ -114,41 +102,27 @@ constructor(
}
.stateInWhileSubscribed(initialValue = false)
+ fun startProvidingLocation() {
+ meshLocationUseCase.startProvidingLocation()
+ }
+
+ fun stopProvidingLocation() {
+ meshLocationUseCase.stopProvidingLocation()
+ }
+
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow()
val appVersionName
get() = buildConfigProvider.versionName
- val isOtaCapable: StateFlow =
- combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
- .flatMapLatest { (node, connectionState) ->
- if (node == null || !connectionState.isConnected()) {
- flowOf(false)
- } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
- val hwModel = node.user.hw_model.value
- val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
- // Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
- val capabilities = Capabilities(node.metadata?.firmware_version)
-
- // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
- // TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
- val isEsp32OtaSupported = false
- // hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
-
- flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
- } else {
- flowOf(false)
- }
- }
- .stateInWhileSubscribed(initialValue = false)
+ val isOtaCapable: StateFlow = isOtaCapableUseCase().stateInWhileSubscribed(initialValue = false)
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
- val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
- databaseManager.setCacheLimit(clamped)
+ setDatabaseCacheLimitUseCase(limit)
}
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
@@ -159,32 +133,25 @@ constructor(
val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow()
fun setMeshLogRetentionDays(days: Int) {
- val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
- meshLogPrefs.retentionDays = clamped
- _meshLogRetentionDays.value = clamped
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
+ viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
+ _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
}
fun setMeshLogLoggingEnabled(enabled: Boolean) {
- meshLogPrefs.loggingEnabled = enabled
+ viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
_meshLogLoggingEnabled.value = enabled
- if (!enabled) {
- viewModelScope.launch { meshLogRepository.deleteAll() }
- } else {
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
- }
}
fun setProvideLocation(value: Boolean) {
- myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
+ myNodeNum?.let { setProvideLocationUseCase(it, value) }
}
fun setTheme(theme: Int) {
- uiPreferencesDataSource.setTheme(theme)
+ setThemeUseCase(theme)
}
fun showAppIntro() {
- uiPreferencesDataSource.setAppIntroCompleted(false)
+ setAppIntroCompletedUseCase(false)
}
fun unlockExcludedModules() {
@@ -204,112 +171,8 @@ constructor(
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
- // Extract distances to this device from position messages and put (node,SNR,distance)
- // in the file_uri
val myNodeNum = myNodeNum ?: return@launch
-
- // Capture the current node value while we're still on main thread
- val nodes = nodeRepository.nodeDBbyNum.value
-
- // Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
- // The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
- val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
- meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
- }
-
- writeToUri(uri) { writer ->
- val nodePositions = mutableMapOf()
-
- @Suppress("MaxLineLength")
- writer.appendLine(
- "\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
- )
-
- // Packets are ordered by time, we keep most recent position of
- // our device in localNodePosition.
- val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
- meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
- // If we get a NodeInfo packet, use it to update our position data (if valid)
- packet.nodeInfo?.let { nodeInfo ->
- positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
- }
-
- packet.meshPacket?.let { proto ->
- // If the packet contains position data then use it to update, if valid
- packet.position?.let { position ->
- positionToPos.invoke(position)?.let {
- nodePositions[
- proto.from.takeIf { it != 0 } ?: myNodeNum,
- ] = position
- }
- }
-
- // packets must have rxSNR, and optionally match the filter given as a param.
- if (
- (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
- (proto.rx_snr ?: 0f) != 0.0f
- ) {
- val rxDateTime = dateFormat.format(packet.received_date)
- val rxFrom = proto.from.toUInt()
- val senderName = nodes[proto.from]?.user?.long_name ?: ""
-
- // sender lat & long
- val senderPosition = nodePositions[proto.from]
- val senderPos = positionToPos.invoke(senderPosition)
- val senderLat = senderPos?.latitude ?: ""
- val senderLong = senderPos?.longitude ?: ""
-
- // rx lat, long, and elevation
- val rxPosition = nodePositions[myNodeNum]
- val rxPos = positionToPos.invoke(rxPosition)
- val rxLat = rxPos?.latitude ?: ""
- val rxLong = rxPos?.longitude ?: ""
- val rxAlt = rxPos?.altitude ?: ""
- val rxSnr = proto.rx_snr
-
- // Calculate the distance if both positions are valid
-
- val dist =
- if (senderPos == null || rxPos == null) {
- ""
- } else {
- positionToMeter(
- Position(rxPosition!!), // Use rxPosition but only if rxPos was
- // valid
- Position(senderPosition!!), // Use senderPosition but only if
- // senderPos was valid
- )
- .roundToInt()
- .toString()
- }
-
- val hopLimit = proto.hop_limit ?: 0
-
- val decoded = proto.decoded
- val encrypted = proto.encrypted
- val payload =
- when {
- (decoded?.portnum?.value ?: 0) !in
- setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
- "<${decoded?.portnum}>"
-
- decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
-
- encrypted != null -> "${encrypted.size} encrypted bytes"
- else -> ""
- }
-
- // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
- // elevation,rx
- // snr,distance,hop limit,payload
- @Suppress("MaxLineLength")
- writer.appendLine(
- "$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
- )
- }
- }
- }
- }
+ writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
new file mode 100644
index 000000000..cb6ef918b
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.AppSettingsAlt
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Memory
+import androidx.compose.material.icons.rounded.WavingHand
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.acknowledgements
+import org.meshtastic.core.resources.app_version
+import org.meshtastic.core.resources.info
+import org.meshtastic.core.resources.intro_show
+import org.meshtastic.core.resources.modules_already_unlocked
+import org.meshtastic.core.resources.modules_unlocked
+import org.meshtastic.core.resources.system_settings
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+import kotlin.time.Duration.Companion.seconds
+
+/** Section displaying application information and related actions. */
+@Composable
+fun AppInfoSection(
+ appVersionName: String,
+ excludedModulesUnlocked: Boolean,
+ onUnlockExcludedModules: () -> Unit,
+ onShowAppIntro: () -> Unit,
+ onNavigateToAbout: () -> Unit,
+) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ ExpressiveSection(title = stringResource(Res.string.info)) {
+ ListItem(
+ text = stringResource(Res.string.intro_show),
+ leadingIcon = Icons.Rounded.WavingHand,
+ trailingIcon = null,
+ ) {
+ onShowAppIntro()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.system_settings),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = null,
+ ) {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.fromParts("package", context.packageName, null)
+ settingsLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.acknowledgements),
+ leadingIcon = Icons.Rounded.Info,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ onNavigateToAbout()
+ }
+
+ AppVersionButton(
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ appVersionName = appVersionName,
+ onUnlockExcludedModules = onUnlockExcludedModules,
+ )
+ }
+}
+
+private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
+private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
+private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
+
+@Composable
+private fun AppVersionButton(
+ excludedModulesUnlocked: Boolean,
+ appVersionName: String,
+ onUnlockExcludedModules: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var clickCount by remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(clickCount) {
+ if (clickCount in 1.. {
+ clickCount = 0
+ scope.launch { context.showToast(Res.string.modules_already_unlocked) }
+ }
+
+ clickCount == UNLOCK_CLICK_COUNT -> {
+ clickCount = 0
+ onUnlockExcludedModules()
+ scope.launch { context.showToast(Res.string.modules_unlocked) }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppInfoSectionPreview() {
+ AppTheme {
+ AppInfoSection(
+ appVersionName = "2.5.0",
+ excludedModulesUnlocked = false,
+ onUnlockExcludedModules = {},
+ onShowAppIntro = {},
+ onNavigateToAbout = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
new file mode 100644
index 000000000..48807d8fa
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.FormatPaint
+import androidx.compose.material.icons.rounded.Language
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+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.preferences_language
+import org.meshtastic.core.resources.theme
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+
+/** Section for app appearance settings like language and theme. */
+@Composable
+fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ // On Android 12 and below, system app settings for language are not available. Use the in-app language
+ // picker for these devices.
+ val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ ListItem(
+ text = stringResource(Res.string.preferences_language),
+ leadingIcon = Icons.Rounded.Language,
+ trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ if (useInAppLangPicker) {
+ onShowLanguagePicker()
+ } else {
+ val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
+ if (intent.resolveActivity(context.packageManager) != null) {
+ settingsLauncher.launch(intent)
+ } else {
+ // Fall back to the in-app picker
+ onShowLanguagePicker()
+ }
+ }
+ }
+
+ ListItem(
+ text = stringResource(Res.string.theme),
+ leadingIcon = Icons.Rounded.FormatPaint,
+ trailingIcon = null,
+ ) {
+ onShowThemePicker()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppearanceSectionPreview() {
+ AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
new file mode 100644
index 000000000..49dbe2252
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.feature.settings.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/** A styled section container for settings screens. */
+@Composable
+fun ExpressiveSection(
+ title: String,
+ modifier: Modifier = Modifier,
+ titleColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = titleColor,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ content = content,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
new file mode 100644
index 000000000..161367ee2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.feature.settings.component
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Abc
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.core.os.ConfigurationCompat
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.core.ui.component.SwitchListItem
+
+@Composable
+fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
+ val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
+ val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
+ if (currentLocale?.language in supportedLanguages) {
+ SwitchListItem(
+ text = stringResource(Res.string.use_homoglyph_characters_encoding),
+ checked = homoglyphEncodingEnabled,
+ leadingIcon = Icons.Default.Abc,
+ onClick = onToggle,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
new file mode 100644
index 000000000..c22235bd2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity.RESULT_OK
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Output
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.tooling.preview.Preview
+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.database.DatabaseConstants
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.device_db_cache_limit
+import org.meshtastic.core.resources.device_db_cache_limit_summary
+import org.meshtastic.core.resources.export_data_csv
+import org.meshtastic.core.resources.save_rangetest
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+/** Section for settings related to data persistence and exports. */
+@Composable
+fun PersistenceSection(
+ cacheLimit: Int,
+ onSetCacheLimit: (Int) -> Unit,
+ nodeShortName: String,
+ onExportData: (android.net.Uri) -> Unit,
+) {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
+
+ val exportRangeTestLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ val exportDataLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ val cacheItems = remember {
+ (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
+ }
+ DropDownPreference(
+ title = stringResource(Res.string.device_db_cache_limit),
+ enabled = true,
+ items = cacheItems,
+ selectedItem = cacheLimit.toLong(),
+ onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
+ summary = stringResource(Res.string.device_db_cache_limit_summary),
+ )
+
+ ListItem(
+ text = stringResource(Res.string.save_rangetest),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeShortName}_$timestamp.csv")
+ }
+ exportRangeTestLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.export_data_csv),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeShortName}_$timestamp.csv")
+ }
+ exportDataLauncher.launch(intent)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PersistenceSectionPreview() {
+ AppTheme { PersistenceSection(cacheLimit = 100, onSetCacheLimit = {}, nodeShortName = "TEST", onExportData = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
new file mode 100644
index 000000000..cecdc27b8
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.feature.settings.component
+
+import android.Manifest
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.rounded.LocationOn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.gpsDisabled
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.analytics_okay
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.location_disabled
+import org.meshtastic.core.resources.provide_location_to_mesh
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+
+/** Section managing privacy settings like analytics and location sharing. */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PrivacySection(
+ analyticsAvailable: Boolean,
+ analyticsEnabled: Boolean,
+ onToggleAnalytics: () -> Unit,
+ provideLocation: Boolean,
+ onToggleLocation: (Boolean) -> Unit,
+ homoglyphEnabled: Boolean,
+ onToggleHomoglyph: () -> Unit,
+ startProvideLocation: () -> Unit,
+ stopProvideLocation: () -> Unit,
+) {
+ val context = LocalContext.current
+ val locationPermissionsState =
+ rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ val isGpsDisabled = context.gpsDisabled()
+
+ LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
+ if (provideLocation) {
+ if (locationPermissionsState.allPermissionsGranted) {
+ if (!isGpsDisabled) {
+ startProvideLocation()
+ } else {
+ context.showToast(Res.string.location_disabled)
+ }
+ } else {
+ locationPermissionsState.launchMultiplePermissionRequest()
+ }
+ } else {
+ stopProvideLocation()
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ if (analyticsAvailable) {
+ SwitchListItem(
+ text = stringResource(Res.string.analytics_okay),
+ checked = analyticsEnabled,
+ leadingIcon = Icons.Default.BugReport,
+ onClick = onToggleAnalytics,
+ )
+ }
+
+ SwitchListItem(
+ text = stringResource(Res.string.provide_location_to_mesh),
+ leadingIcon = Icons.Rounded.LocationOn,
+ enabled = !isGpsDisabled,
+ checked = provideLocation,
+ onClick = { onToggleLocation(!provideLocation) },
+ )
+
+ HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PrivacySectionPreview() {
+ AppTheme {
+ PrivacySection(
+ analyticsAvailable = true,
+ analyticsEnabled = true,
+ onToggleAnalytics = {},
+ provideLocation = true,
+ onToggleLocation = {},
+ homoglyphEnabled = false,
+ onToggleHomoglyph = {},
+ startProvideLocation = {},
+ stopProvideLocation = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
index 51ca46704..db9cd8fd5 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
@@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clean_node_database_description
import org.meshtastic.core.resources.clean_node_database_title
@@ -150,7 +150,7 @@ private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boo
* @param nodesToDelete The list of nodes to be deleted.
*/
@Composable
-private fun NodesDeletionPreview(nodesToDelete: List) {
+private fun NodesDeletionPreview(nodesToDelete: List) {
Text(
stringResource(Res.string.nodes_queued_for_deletion, nodesToDelete.size),
modifier = Modifier.padding(bottom = 16.dp),
@@ -160,8 +160,6 @@ private fun NodesDeletionPreview(nodesToDelete: List) {
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
- nodesToDelete.forEach { node ->
- NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
- }
+ nodesToDelete.forEach { node -> NodeChip(node = node, modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
index 344ee0890..d17df93ff 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
@@ -24,16 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.clean_node_database_confirmation
import org.meshtastic.core.resources.clean_now
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
-import kotlin.time.Duration.Companion.days
private const val MIN_DAYS_THRESHOLD = 7f
@@ -45,8 +43,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
class CleanNodeDatabaseViewModel
@Inject
constructor(
- private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
@@ -55,7 +52,7 @@ constructor(
private val _onlyUnknownNodes = MutableStateFlow(false)
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
- private val _nodesToDelete = MutableStateFlow>(emptyList())
+ private val _nodesToDelete = MutableStateFlow>(emptyList())
val nodesToDelete = _nodesToDelete.asStateFlow()
fun onOlderThanDaysChanged(value: Float) {
@@ -69,40 +66,15 @@ constructor(
}
}
- /**
- * Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
- * - The "older than X days" filter (controlled by the slider) is always active.
- * - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
- * - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
- * - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
- * - Nodes marked as ignored or favorite are always excluded from deletion.
- */
+ /** Updates the list of nodes to be deleted based on the current filter criteria. */
fun getNodesToDelete() {
viewModelScope.launch {
- val onlyUnknownEnabled = _onlyUnknownNodes.value
- val currentTimeSeconds = nowSeconds
- val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
- val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
-
- val initialNodesToConsider =
- if (onlyUnknownEnabled) {
- // Both "older than X days" and "only unknown nodes" filters apply
- val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- val unknownNodes = nodeRepository.getUnknownNodes()
- olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
- } else {
- // Only "older than X days" filter applies
- nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- }
-
_nodesToDelete.value =
- initialNodesToConsider.filterNot { node ->
- // Exclude nodes with PKI heard in the last 7 days
- (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
- // Exclude ignored or favorite nodes
- node.isIgnored ||
- node.isFavorite
- }
+ cleanNodeDatabaseUseCase.getNodesToClean(
+ olderThanDays = _olderThanDays.value,
+ onlyUnknownNodes = _onlyUnknownNodes.value,
+ currentTimeSeconds = nowSeconds,
+ )
}
}
@@ -126,16 +98,7 @@ constructor(
fun cleanNodes() {
viewModelScope.launch {
val nodeNums = _nodesToDelete.value.map { it.num }
- if (nodeNums.isNotEmpty()) {
- nodeRepository.deleteNodes(nodeNums)
-
- val service = serviceRepository.meshService
- if (service != null) {
- for (nodeNum in nodeNums) {
- service.removeByNodenum(service.packetId, nodeNum)
- }
- }
- }
+ cleanNodeDatabaseUseCase.cleanNodes(nodeNums)
// Clear the list after deletion or if it was empty
_nodesToDelete.value = emptyList()
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index b87987539..3bae7ef2b 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@@ -35,16 +33,11 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@@ -72,10 +65,9 @@ import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigItemList(
state: RadioConfigState,
@@ -89,130 +81,135 @@ fun RadioConfigItemList(
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ConfigRoute.radioConfigRoutes.forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.device_configuration),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.DeviceConfiguration)
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.module_settings)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.module_settings),
- leadingIcon = Icons.Rounded.Settings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.ModuleConfiguration)
- }
- }
+ RadioConfigSection(isManaged, enabled, onRouteClick)
+ DeviceConfigSection(isManaged, enabled, onNavigate)
+ ModuleSettingsSection(isManaged, enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- ListItem(
- text = stringResource(Res.string.import_configuration),
- leadingIcon = Icons.Rounded.Download,
- enabled = enabled,
- onClick = onImport,
- )
- ListItem(
- text = stringResource(Res.string.export_configuration),
- leadingIcon = Icons.Rounded.Upload,
- enabled = enabled,
- onClick = onExport,
- )
- }
+ BackupRestoreSection(isManaged, enabled, onImport, onExport)
}
- ExpressiveSection(title = stringResource(Res.string.administration)) {
- ListItem(
- text = stringResource(Res.string.administration),
- leadingIcon = Icons.Rounded.AdminPanelSettings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- leadingIconTint = MaterialTheme.colorScheme.error,
- textColor = MaterialTheme.colorScheme.error,
- trailingIconTint = MaterialTheme.colorScheme.error,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.Administration)
- }
- }
+ AdministrationSection(enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- if (isOtaCapable) {
- ListItem(
- text = stringResource(Res.string.firmware_update_title),
- leadingIcon = Icons.Rounded.SystemUpdate,
- enabled = enabled,
- onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
- )
- }
-
- ListItem(
- text = stringResource(Res.string.clean_node_database_title),
- leadingIcon = Icons.Rounded.CleaningServices,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
- )
-
- ListItem(
- text = stringResource(Res.string.debug_panel),
- leadingIcon = Icons.Rounded.BugReport,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.DebugPanel) },
- )
- }
+ AdvancedSection(isManaged, isOtaCapable, enabled, onNavigate)
}
}
}
@Composable
-fun ExpressiveSection(
- title: String,
- modifier: Modifier = Modifier,
- titleColor: Color = MaterialTheme.colorScheme.primary,
- content: @Composable ColumnScope.() -> Unit,
-) {
- Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text(
- text = title,
- modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = titleColor,
+private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClick: (Enum<*>) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ConfigRoute.radioConfigRoutes.forEach {
+ ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
+ }
+ }
+}
+
+@Composable
+private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.device_configuration),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.DeviceConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.module_settings),
+ leadingIcon = Icons.Rounded.Settings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.ModuleConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: () -> Unit, onExport: () -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.import_configuration),
+ leadingIcon = Icons.Rounded.Download,
+ enabled = enabled,
+ onClick = onImport,
)
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
- content = content,
+ ListItem(
+ text = stringResource(Res.string.export_configuration),
+ leadingIcon = Icons.Rounded.Upload,
+ enabled = enabled,
+ onClick = onExport,
+ )
+ }
+}
+
+@Composable
+private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.administration)) {
+ ListItem(
+ text = stringResource(Res.string.administration),
+ leadingIcon = Icons.Rounded.AdminPanelSettings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIconTint = MaterialTheme.colorScheme.error,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.Administration)
+ }
+ }
+}
+
+@Composable
+private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ if (isOtaCapable) {
+ ListItem(
+ text = stringResource(Res.string.firmware_update_title),
+ leadingIcon = Icons.Rounded.SystemUpdate,
+ enabled = enabled,
+ onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ )
+ }
+
+ ListItem(
+ text = stringResource(Res.string.clean_node_database_title),
+ leadingIcon = Icons.Rounded.CleaningServices,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
+ )
+
+ ListItem(
+ text = stringResource(Res.string.debug_panel),
+ leadingIcon = Icons.Rounded.BugReport,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 2cb947c8f..bc61b70c4 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -21,8 +21,6 @@ import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
-import android.os.RemoteException
-import android.util.Base64
import androidx.annotation.RequiresPermission
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
@@ -44,15 +42,23 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
-import org.json.JSONObject
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
@@ -61,8 +67,6 @@ import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -79,8 +83,6 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
@@ -119,20 +121,26 @@ constructor(
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
+ private val importProfileUseCase: ImportProfileUseCase,
+ private val exportProfileUseCase: ExportProfileUseCase,
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
+ private val installProfileUseCase: InstallProfileUseCase,
+ private val radioConfigUseCase: RadioConfigUseCase,
+ private val adminActionsUseCase: AdminActionsUseCase,
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
) : ViewModel() {
- private val meshService: IMeshService?
- get() = serviceRepository.meshService
-
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
fun toggleAnalyticsAllowed() {
- analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ toggleAnalyticsUseCase()
}
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
fun toggleHomoglyphCharactersEncodingEnabled() {
- homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ toggleHomoglyphEncodingUseCase()
}
private val destNum =
@@ -234,52 +242,30 @@ constructor(
Logger.d { "RadioConfigViewModel cleared" }
}
- private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
- viewModelScope.launch {
- meshService?.let { service ->
- val packetId = service.getPacketId()
- try {
- requestAction(service, packetId, destNum)
- requestIds.update { it.apply { add(packetId) } }
- _radioConfigState.update { state ->
- if (state.responseState is ResponseState.Loading) {
- val total = maxOf(requestIds.value.size, state.responseState.total)
- state.copy(responseState = state.responseState.copy(total = total))
- } else {
- state.copy(
- route = "", // setter (response is PortNum.ROUTING_APP)
- responseState = ResponseState.Loading(),
- )
- }
- }
- } catch (ex: RemoteException) {
- Logger.e { "$errorMessage: ${ex.message}" }
- }
- }
- }
-
fun setOwner(user: User) {
- setRemoteOwner(destNode.value?.num ?: return, user)
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
+ _radioConfigState.update { it.copy(userConfig = user) }
+ val packetId = radioConfigUseCase.setOwner(destNum, user)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteOwner(destNum: Int, user: User) = request(
- destNum,
- { service, packetId, _ ->
- _radioConfigState.update { it.copy(userConfig = user) }
- service.setRemoteOwner(packetId, destNum, user.encode())
- },
- "Request setOwner error",
- )
-
- private fun getOwner(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
- "Request getOwner error",
- )
+ private fun getOwner(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getOwner(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun updateChannels(new: List, old: List) {
val destNum = destNode.value?.num ?: return
- getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
+ getChannelList(new, old).forEach { channel ->
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
+ registerRequestId(packetId)
+ }
+ }
if (destNum == myNodeNum) {
viewModelScope.launch {
@@ -290,25 +276,16 @@ constructor(
_radioConfigState.update { it.copy(channelList = new) }
}
- private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
- destNum,
- { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
- "Request setRemoteChannel error",
- )
-
- private fun getChannel(destNum: Int, index: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
- "Request getChannel error",
- )
-
- fun setConfig(config: Config) {
- setRemoteConfig(destNode.value?.num ?: return, config)
+ private fun getChannel(destNum: Int, index: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getChannel(destNum, index)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteConfig(destNum: Int, config: Config) = request(
- destNum,
- { service, packetId, dest ->
+ fun setConfig(config: Config) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
radioConfig =
@@ -324,24 +301,22 @@ constructor(
),
)
}
- service.setRemoteConfig(packetId, dest, config.encode())
- },
- "Request setConfig error",
- )
-
- private fun getConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
- "Request getConfig error",
- )
-
- fun setModuleConfig(config: ModuleConfig) {
- setRemoteModuleConfig(destNode.value?.num ?: return, config)
+ val packetId = radioConfigUseCase.setConfig(destNum, config)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
- destNum,
- { service, packetId, dest ->
+ private fun getConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod")
+ fun setModuleConfig(config: ModuleConfig) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
@@ -366,97 +341,78 @@ constructor(
),
)
}
- service.setModuleConfig(packetId, dest, config.encode())
- },
- "Request setModuleConfig error",
- )
+ val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
+ registerRequestId(packetId)
+ }
+ }
- private fun getModuleConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
- "Request getModuleConfig error",
- )
+ private fun getModuleConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getModuleConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
- try {
- meshService?.setRingtone(destNum, ringtone)
- } catch (ex: RemoteException) {
- Logger.e { "Set ringtone error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
- private fun getRingtone(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRingtone(packetId, dest) },
- "Request getRingtone error",
- )
+ private fun getRingtone(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getRingtone(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
- try {
- meshService?.setCannedMessages(destNum, messages)
- } catch (ex: RemoteException) {
- Logger.e { "Set canned messages error: ${ex.message}" }
+ viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
+ }
+
+ private fun getCannedMessages(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getCannedMessages(destNum)
+ registerRequestId(packetId)
}
}
- private fun getCannedMessages(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getCannedMessages(packetId, dest) },
- "Request getCannedMessages error",
- )
+ private fun getDeviceConnectionStatus(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun getDeviceConnectionStatus(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getDeviceConnectionStatus(packetId, dest) },
- "Request getDeviceConnectionStatus error",
- )
+ private fun requestShutdown(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.shutdown(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun requestShutdown(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.requestShutdown(packetId, dest) },
- "Request shutdown error",
- )
-
- private fun requestReboot(destNum: Int) =
- request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
+ private fun requestReboot(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.reboot(destNum)
+ registerRequestId(packetId)
+ }
+ }
private fun requestFactoryReset(destNum: Int) {
- request(
- destNum,
- { service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
- "Request factory reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache first so screens refresh immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB()
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
+ registerRequestId(packetId)
}
}
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
- request(
- destNum,
- { service, packetId, dest -> service.requestNodedbReset(packetId, dest, preserveFavorites) },
- "Request NodeDB reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache as well so UI updates immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB(preserveFavorites)
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
+ registerRequestId(packetId)
}
}
@@ -484,21 +440,18 @@ constructor(
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
- try {
- meshService?.setFixedPosition(destNum, position)
- } catch (ex: RemoteException) {
- Logger.e { "Set fixed position error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
}
- fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
+ fun removeFixedPosition() {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
+ }
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
- app.contentResolver.openInputStream(uri).use { inputStream ->
- val bytes = inputStream?.readBytes() ?: ByteArray(0)
- val protobuf = DeviceProfile.ADAPTER.decode(bytes)
- onResult(protobuf)
+ app.contentResolver.openInputStream(uri)?.use { inputStream ->
+ importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
}
} catch (ex: Exception) {
Logger.e { "Import DeviceProfile error: ${ex.message}" }
@@ -506,104 +459,44 @@ constructor(
}
}
- fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
-
- private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(message.encode())
+ fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ try {
+ app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
+ FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
+ exportProfileUseCase(outputStream, profile)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
+ }
}
+ } catch (ex: Exception) {
+ Logger.e { "Can't write file error: ${ex.message}" }
+ sendError(ex.customMessage)
}
- setResponseStateSuccess()
- } catch (ex: Exception) {
- Logger.e { "Can't write file error: ${ex.message}" }
- sendError(ex.customMessage)
}
}
- fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
- viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
-
- private val indentSpaces = 4
-
- private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
+ fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
- val publicKeyBytes = securityConfig.public_key.toByteArray()
- val privateKeyBytes = securityConfig.private_key.toByteArray()
-
- // Convert byte arrays to Base64 strings for human readability in JSON
- val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
- val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
-
- // Create a JSON object
- val jsonObject =
- JSONObject().apply {
- put("timestamp", nowMillis)
- put("public_key", publicKeyBase64)
- put("private_key", privateKeyBase64)
- }
-
- // Convert JSON object to a string
- val jsonString = jsonObject.toString(indentSpaces)
-
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
+ exportSecurityConfigUseCase(outputStream, securityConfig)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
}
}
- setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Logger.e { errorMessage }
sendError(ex.customMessage)
}
}
+ }
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
- with(protobuf) {
- meshService?.beginEditSettings(destNum)
- if (long_name != null || short_name != null) {
- destNode.value?.user?.let {
- val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
- setOwner(user)
- }
- }
- config?.let { lc ->
- lc.device?.let { setConfig(Config(device = it)) }
- lc.position?.let { setConfig(Config(position = it)) }
- lc.power?.let { setConfig(Config(power = it)) }
- lc.network?.let { setConfig(Config(network = it)) }
- lc.display?.let { setConfig(Config(display = it)) }
- lc.lora?.let { setConfig(Config(lora = it)) }
- lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
- lc.security?.let { setConfig(Config(security = it)) }
- }
- if (fixed_position != null) {
- setFixedPosition(Position(fixed_position!!))
- }
- module_config?.let { lmc ->
- lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
- lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
- lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
- lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
- lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
- lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
- lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
- lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
- lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
- lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
- lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
- lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
- lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
- lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
- lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
- lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
- }
- meshService?.commitEditSettings(destNum)
- }
+ viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
}
fun clearPacketResponse() {
@@ -686,6 +579,8 @@ constructor(
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
+ private fun sendError(error: UiText) = setResponseStateError(error)
+
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
@@ -701,171 +596,156 @@ constructor(
}
}
- private fun processPacketResponse(packet: MeshPacket) {
- val data = packet.decoded ?: return
- if (data.request_id !in requestIds.value) return
- val route = radioConfigState.value.route
-
- val destNum = destNode.value?.num ?: return
- val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
-
- if (data.portnum == PortNum.ROUTING_APP) {
- val parsed = Routing.ADAPTER.decode(data.payload)
- Logger.d { debugMsg.format(parsed.error_reason?.name) }
- if (parsed.error_reason != Routing.Error.NONE) {
- sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
- } else if (packet.from == destNum && route.isEmpty()) {
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- setResponseStateSuccess()
- } else {
- incrementCompleted()
- }
+ private fun registerRequestId(packetId: Int) {
+ requestIds.update { it.apply { add(packetId) } }
+ _radioConfigState.update { state ->
+ if (state.responseState is ResponseState.Loading) {
+ val total = maxOf(requestIds.value.size, state.responseState.total)
+ state.copy(responseState = state.responseState.copy(total = total))
+ } else {
+ state.copy(
+ route = "", // setter (response is PortNum.ROUTING_APP)
+ responseState = ResponseState.Loading(),
+ )
}
}
- if (data.portnum == PortNum.ADMIN_APP) {
- val parsed = AdminMessage.ADAPTER.decode(data.payload)
- // Explicitly log the non-null field name for clarity
- val variant =
- when {
- parsed.get_device_metadata_response != null -> "get_device_metadata_response"
- parsed.get_channel_response != null -> "get_channel_response"
- parsed.get_owner_response != null -> "get_owner_response"
- parsed.get_config_response != null -> "get_config_response"
- parsed.get_module_config_response != null -> "get_module_config_response"
- parsed.get_canned_message_module_messages_response != null ->
- "get_canned_message_module_messages_response"
- parsed.get_ringtone_response != null -> "get_ringtone_response"
- parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
- else -> "unknown"
- }
- Logger.d { debugMsg.format(variant) }
- if (destNum != packet.from) {
- sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
- return
- }
- when {
- parsed.get_device_metadata_response != null -> {
- _radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
- incrementCompleted()
- }
+ }
- parsed.get_channel_response != null -> {
- val response = parsed.get_channel_response!!
- // Stop once we get to the first disabled entry
- if (response.role != Channel.Role.DISABLED) {
- _radioConfigState.update { state ->
- state.copy(
- channelList =
- state.channelList.toMutableList().apply {
- val index = response.index
- val settings = response.settings ?: ChannelSettings()
- // Make sure list is large enough
- while (size <= index) add(ChannelSettings())
- set(index, settings)
- },
- )
- }
- incrementCompleted()
- val index = response.index
- if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
- // Not done yet, request next channel
- getChannel(destNum, index + 1)
- }
+ private fun processPacketResponse(packet: MeshPacket) {
+ val destNum = destNode.value?.num ?: return
+ val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
+ val route = radioConfigState.value.route
+
+ when (result) {
+ is RadioResponseResult.Error -> sendError(result.message)
+ is RadioResponseResult.Success -> {
+ if (route.isEmpty()) {
+ val data = packet.decoded!!
+ requestIds.update { it.apply { remove(data.request_id) } }
+ if (requestIds.value.isEmpty()) {
+ setResponseStateSuccess()
} else {
- // Received last channel, update total and start channel editor
- setResponseStateTotal(response.index + 1)
+ incrementCompleted()
}
}
+ }
- parsed.get_owner_response != null -> {
- _radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
- incrementCompleted()
- }
+ is RadioResponseResult.Metadata -> {
+ _radioConfigState.update { it.copy(metadata = result.metadata) }
+ incrementCompleted()
+ }
- parsed.get_config_response != null -> {
- val response = parsed.get_config_response!!
+ is RadioResponseResult.ChannelResponse -> {
+ val response = result.channel
+ // Stop once we get to the first disabled entry
+ if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
- radioConfig =
- state.radioConfig.copy(
- device = response.device ?: state.radioConfig.device,
- position = response.position ?: state.radioConfig.position,
- power = response.power ?: state.radioConfig.power,
- network = response.network ?: state.radioConfig.network,
- display = response.display ?: state.radioConfig.display,
- lora = response.lora ?: state.radioConfig.lora,
- bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
- security = response.security ?: state.radioConfig.security,
- ),
+ channelList =
+ state.channelList.toMutableList().apply {
+ val index = response.index
+ val settings = response.settings ?: ChannelSettings()
+ // Make sure list is large enough
+ while (size <= index) add(ChannelSettings())
+ set(index, settings)
+ },
)
}
incrementCompleted()
- }
-
- parsed.get_module_config_response != null -> {
- val response = parsed.get_module_config_response!!
- _radioConfigState.update { state ->
- state.copy(
- moduleConfig =
- state.moduleConfig.copy(
- mqtt = response.mqtt ?: state.moduleConfig.mqtt,
- serial = response.serial ?: state.moduleConfig.serial,
- external_notification =
- response.external_notification ?: state.moduleConfig.external_notification,
- store_forward = response.store_forward ?: state.moduleConfig.store_forward,
- range_test = response.range_test ?: state.moduleConfig.range_test,
- telemetry = response.telemetry ?: state.moduleConfig.telemetry,
- canned_message = response.canned_message ?: state.moduleConfig.canned_message,
- audio = response.audio ?: state.moduleConfig.audio,
- remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
- neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
- ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
- detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
- paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
- statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
- traffic_management =
- response.traffic_management ?: state.moduleConfig.traffic_management,
- tak = response.tak ?: state.moduleConfig.tak,
- ),
- )
+ val index = response.index
+ if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
+ // Not done yet, request next channel
+ getChannel(destNum, index + 1)
}
- incrementCompleted()
+ } else {
+ // Received last channel, update total and start channel editor
+ setResponseStateTotal(response.index + 1)
}
-
- parsed.get_canned_message_module_messages_response != null -> {
- _radioConfigState.update {
- it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
- }
- incrementCompleted()
- }
-
- parsed.get_ringtone_response != null -> {
- _radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
- incrementCompleted()
- }
-
- parsed.get_device_connection_status_response != null -> {
- _radioConfigState.update {
- it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!)
- }
- incrementCompleted()
- }
-
- else -> Logger.d { "No custom processing needed for $parsed" }
}
- if (AdminRoute.entries.any { it.name == route }) {
- sendAdminRequest(destNum)
+ is RadioResponseResult.Owner -> {
+ _radioConfigState.update { it.copy(userConfig = result.user) }
+ incrementCompleted()
}
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
- clearPacketResponse()
- } else if (route.isEmpty()) {
- setResponseStateSuccess()
+ is RadioResponseResult.ConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ radioConfig =
+ state.radioConfig.copy(
+ device = response.device ?: state.radioConfig.device,
+ position = response.position ?: state.radioConfig.position,
+ power = response.power ?: state.radioConfig.power,
+ network = response.network ?: state.radioConfig.network,
+ display = response.display ?: state.radioConfig.display,
+ lora = response.lora ?: state.radioConfig.lora,
+ bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
+ security = response.security ?: state.radioConfig.security,
+ ),
+ )
}
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ModuleConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ moduleConfig =
+ state.moduleConfig.copy(
+ mqtt = response.mqtt ?: state.moduleConfig.mqtt,
+ serial = response.serial ?: state.moduleConfig.serial,
+ external_notification =
+ response.external_notification ?: state.moduleConfig.external_notification,
+ store_forward = response.store_forward ?: state.moduleConfig.store_forward,
+ range_test = response.range_test ?: state.moduleConfig.range_test,
+ telemetry = response.telemetry ?: state.moduleConfig.telemetry,
+ canned_message = response.canned_message ?: state.moduleConfig.canned_message,
+ audio = response.audio ?: state.moduleConfig.audio,
+ remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
+ neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
+ ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
+ detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
+ paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
+ statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management =
+ response.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = response.tak ?: state.moduleConfig.tak,
+ ),
+ )
+ }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.CannedMessages -> {
+ _radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.Ringtone -> {
+ _radioConfigState.update { it.copy(ringtone = result.ringtone) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ConnectionStatus -> {
+ _radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
+ incrementCompleted()
+ }
+ }
+
+ if (AdminRoute.entries.any { it.name == route }) {
+ sendAdminRequest(destNum)
+ }
+
+ val requestId = packet.decoded?.request_id ?: return
+ requestIds.update { it.apply { remove(requestId) } }
+
+ if (requestIds.value.isEmpty()) {
+ if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
+ clearPacketResponse()
+ } else if (route.isEmpty()) {
+ setResponseStateSuccess()
}
}
}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
index 1c1863346..8ffb10fae 100644
--- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
@@ -28,6 +28,7 @@ import org.junit.runner.RunWith
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
new file mode 100644
index 000000000..9879d8903
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.feature.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.DatabaseManager
+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.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.prefs.ui.UiPrefs
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SettingsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val radioController: RadioController = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val uiPrefs: UiPrefs = mockk(relaxed = true)
+ private val buildConfigProvider: BuildConfigProvider = mockk(relaxed = true)
+ private val databaseManager: DatabaseManager = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+
+ private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true)
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true)
+ private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true)
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true)
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true)
+ private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true)
+ private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true)
+ private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: SettingsViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ // Return real StateFlows to avoid ClassCastException
+ every { databaseManager.cacheLimit } returns MutableStateFlow(100)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
+ every { radioController.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { isOtaCapableUseCase() } returns flowOf(false)
+
+ viewModel =
+ SettingsViewModel(
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ radioController = radioController,
+ nodeRepository = nodeRepository,
+ uiPrefs = uiPrefs,
+ buildConfigProvider = buildConfigProvider,
+ databaseManager = databaseManager,
+ meshLogPrefs = meshLogPrefs,
+ setThemeUseCase = setThemeUseCase,
+ setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
+ setProvideLocationUseCase = setProvideLocationUseCase,
+ setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
+ setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
+ meshLocationUseCase = meshLocationUseCase,
+ exportDataUseCase = exportDataUseCase,
+ isOtaCapableUseCase = isOtaCapableUseCase,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setTheme calls useCase`() {
+ viewModel.setTheme(1)
+ verify { setThemeUseCase(1) }
+ }
+
+ @Test
+ fun `setDbCacheLimit calls useCase`() {
+ viewModel.setDbCacheLimit(50)
+ verify { setDatabaseCacheLimitUseCase(50) }
+ }
+
+ @Test
+ fun `startProvidingLocation calls useCase`() {
+ viewModel.startProvidingLocation()
+ verify { meshLocationUseCase.startProvidingLocation() }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
new file mode 100644
index 000000000..b7a256bf4
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.feature.settings.debugging
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DebugViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+ private val alertManager: AlertManager = mockk(relaxed = true)
+
+ private lateinit var viewModel: DebugViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { meshLogRepository.getAllLogs() } returns flowOf(emptyList())
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { meshLogPrefs.retentionDays } returns 7
+ every { meshLogPrefs.loggingEnabled } returns true
+
+ viewModel =
+ DebugViewModel(
+ meshLogRepository = meshLogRepository,
+ nodeRepository = nodeRepository,
+ meshLogPrefs = meshLogPrefs,
+ alertManager = alertManager,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
+ viewModel.setRetentionDays(14)
+
+ verify { meshLogPrefs.retentionDays = 14 }
+ coVerify { meshLogRepository.deleteLogsOlderThan(14) }
+ assertEquals(14, viewModel.retentionDays.value)
+ }
+
+ @Test
+ fun `setLoggingEnabled false deletes all logs`() = runTest {
+ viewModel.setLoggingEnabled(false)
+
+ verify { meshLogPrefs.loggingEnabled = false }
+ coVerify { meshLogRepository.deleteAll() }
+ assertEquals(false, viewModel.loggingEnabled.value)
+ }
+
+ @Test
+ fun `search filters results correctly`() = runTest {
+ val logs =
+ listOf(
+ DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
+ DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
+ )
+
+ viewModel.searchManager.updateMatches("Apple", logs)
+
+ val state = viewModel.searchState.value
+ assertEquals(true, state.hasMatches)
+ assertEquals(1, state.allMatches.size)
+ assertEquals(0, state.allMatches[0].logIndex)
+ }
+
+ @Test
+ fun `requestDeleteAllLogs shows alert`() {
+ viewModel.requestDeleteAllLogs()
+ verify { alertManager.showAlert(titleRes = any(), messageRes = any(), onConfirm = any()) }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
new file mode 100644
index 000000000..35fd61f2b
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.feature.settings.filter
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.service.filter.MessageFilterService
+
+class FilterSettingsViewModelTest {
+
+ private val filterPrefs: FilterPrefs = mockk(relaxed = true)
+ private val messageFilterService: MessageFilterService = mockk(relaxed = true)
+
+ private lateinit var viewModel: FilterSettingsViewModel
+
+ @Before
+ fun setUp() {
+ every { filterPrefs.filterEnabled } returns true
+ every { filterPrefs.filterWords } returns setOf("apple", "banana")
+
+ viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService)
+ }
+
+ @Test
+ fun `setFilterEnabled updates prefs and state`() {
+ viewModel.setFilterEnabled(false)
+ verify { filterPrefs.filterEnabled = false }
+ assertEquals(false, viewModel.filterEnabled.value)
+ }
+
+ @Test
+ fun `addFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.addFilterWord("cherry")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
+ }
+
+ @Test
+ fun `removeFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.removeFilterWord("apple")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("banana"), viewModel.filterWords.value)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
new file mode 100644
index 000000000..07beee89d
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.feature.settings.radio
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CleanNodeDatabaseViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
+ private lateinit var alertManager: AlertManager
+ private lateinit var viewModel: CleanNodeDatabaseViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ cleanNodeDatabaseUseCase = mockk(relaxed = true)
+ alertManager = mockk(relaxed = true)
+ viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `getNodesToDelete updates state`() = runTest {
+ val nodes = listOf(Node(num = 1), Node(num = 2))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ assertEquals(nodes, viewModel.nodesToDelete.value)
+ }
+
+ @Test
+ fun `cleanNodes calls useCase and clears state`() = runTest {
+ val nodes = listOf(Node(num = 1))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ viewModel.cleanNodes()
+ advanceUntilIdle()
+
+ coVerify { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
+ assertEquals(0, viewModel.nodesToDelete.value.size)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
new file mode 100644
index 000000000..cc45c7075
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -0,0 +1,241 @@
+/*
+ * 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.feature.settings.radio
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.LocationRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import org.meshtastic.core.prefs.map.MapConsentPrefs
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.MeshPacket
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RadioConfigViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val locationRepository: LocationRepository = mockk(relaxed = true)
+ private val mapConsentPrefs: MapConsentPrefs = mockk(relaxed = true)
+ private val analyticsPrefs: AnalyticsPrefs = mockk(relaxed = true)
+ private val homoglyphEncodingPrefs: HomoglyphPrefs = mockk(relaxed = true)
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mockk(relaxed = true)
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mockk(relaxed = true)
+ private val importProfileUseCase: ImportProfileUseCase = mockk(relaxed = true)
+ private val exportProfileUseCase: ExportProfileUseCase = mockk(relaxed = true)
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mockk(relaxed = true)
+ private val installProfileUseCase: InstallProfileUseCase = mockk(relaxed = true)
+ private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
+ private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: RadioConfigViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
+ every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
+ every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
+ every { serviceRepository.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ viewModel = createViewModel()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel() = RadioConfigViewModel(
+ savedStateHandle = SavedStateHandle(),
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ packetRepository = packetRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ locationRepository = locationRepository,
+ mapConsentPrefs = mapConsentPrefs,
+ analyticsPrefs = analyticsPrefs,
+ homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ toggleAnalyticsUseCase = toggleAnalyticsUseCase,
+ toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
+ importProfileUseCase = importProfileUseCase,
+ exportProfileUseCase = exportProfileUseCase,
+ exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ installProfileUseCase = installProfileUseCase,
+ radioConfigUseCase = radioConfigUseCase,
+ adminActionsUseCase = adminActionsUseCase,
+ processRadioResponseUseCase = processRadioResponseUseCase,
+ )
+
+ @Test
+ fun `setConfig updates state and calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
+ coEvery { radioConfigUseCase.setConfig(123, any()) } returns 42
+
+ viewModel.setConfig(config)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals(Config.DeviceConfig.Role.ROUTER, state.radioConfig.device?.role)
+ coVerify { radioConfigUseCase.setConfig(123, config) }
+ }
+
+ @Test
+ fun `processPacketResponse updates state on metadata result`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packet = MeshPacket()
+ val metadata = DeviceMetadata(firmware_version = "3.0.0")
+ val packetFlow = MutableSharedFlow()
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
+
+ viewModel = createViewModel()
+
+ packetFlow.emit(packet)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals("3.0.0", state.metadata?.firmware_version)
+ }
+
+ @Test
+ fun `setOwner calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val user = org.meshtastic.proto.User(long_name = "Test")
+ coEvery { radioConfigUseCase.setOwner(123, any()) } returns 42
+
+ viewModel.setOwner(user)
+
+ coVerify { radioConfigUseCase.setOwner(123, user) }
+ }
+
+ @Test
+ fun `updateChannels calls useCase for each changed channel`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val old = listOf(ChannelSettings(name = "Old"))
+ val new = listOf(ChannelSettings(name = "New"))
+
+ coEvery { radioConfigUseCase.setRemoteChannel(123, any()) } returns 42
+
+ viewModel.updateChannels(new, old)
+
+ coVerify { radioConfigUseCase.setRemoteChannel(123, any()) }
+ assertEquals(new, viewModel.radioConfigState.value.channelList)
+ }
+
+ @Test
+ fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.reboot(123) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.REBOOT)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.reboot(123) }
+ }
+
+ @Test
+ fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.factoryReset(123, any()) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.factoryReset(123, any()) }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 2b135dd18..b0a71dbe3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,59 +1,38 @@
+## For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
#
-# Copyright (c) 2025 Meshtastic LLC
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
-# 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 .
-#
-
-# Project-wide Gradle settings.
-org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
-
-# Parallelism & Caching
-org.gradle.parallel=true
-org.gradle.caching=true
-org.gradle.configuration-cache=true
-org.gradle.isolated-projects=true
-org.gradle.vfs.watch=true
-org.gradle.configureondemand=false
-
-# Kotlin Optimization
-# Parallelize Kotlin tasks within a single project (great for KMP)
-kotlin.parallel.tasks.in.project=true
-# Give Kotlin daemon enough breathing room
-kotlin.daemon.jvm.options=-Xmx4g -XX:+UseParallelGC
-kotlin.code.style=official
-
-# Android (AGP) Optimization
-android.useAndroidX=true
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+#Sat Feb 28 21:28:07 CST 2026
android.enableJetifier=false
-android.nonTransitiveRClass=true
-# More aggressive R8 optimizations
android.enableR8.fullMode=true
-# Parallel lint analysis
android.experimental.lint.analysisPerComponent=true
-
-# KSP 2 Configuration
-ksp.useKSP2=true
-ksp.run.in.process=true
-ksp.incremental=true
-ksp.incremental.classpath=true
-ksp.incremental.intermodule=true
-
-# UI & Analysis
+android.newDsl=false
+android.nonTransitiveRClass=true
+android.useAndroidX=true
dependency.analysis.print.build.health=true
enableComposeCompilerMetrics=false
enableComposeCompilerReports=false
-
-# Housekeeping
+kotlin.code.style=official
+kotlin.daemon.jvm.options=-Xmx4g -XX\:+UseParallelGC
+kotlin.parallel.tasks.in.project=true
+ksp.incremental=true
+ksp.incremental.classpath=true
+ksp.incremental.intermodule=true
+ksp.run.in.process=true
+ksp.useKSP2=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+org.gradle.configureondemand=false
+org.gradle.isolated-projects=true
+org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.vfs.watch=true
org.gradle.welcome=never
-android.newDsl=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d51b3cce8..48fe82c7c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ ktlint = "1.7.1"
kover = "0.9.7"
mockk = "1.14.9"
testRetry = "1.6.4"
+turbine = "1.1.0"
# Compose Multiplatform
compose-multiplatform = "1.11.0-alpha03"
@@ -108,6 +109,7 @@ androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
+androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }
# AndroidX Compose
androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.01" }
@@ -178,6 +180,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
junit = { module = "junit:junit", version = "4.13.2" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
# Other
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16c8309fb..0db4cf6c0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,6 +26,7 @@ include(
":core:database",
":core:datastore",
":core:di",
+ ":core:domain",
":core:model",
":core:navigation",
":core:network",
From 728c30031540ae77f5d850c4d075ec710a7339fd Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 15:47:19 -0600
Subject: [PATCH 010/440] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4683)
---
.../src/commonMain/composeResources/values-et/strings.xml | 1 +
core/service/README.md | 1 +
feature/messaging/README.md | 2 ++
feature/settings/README.md | 1 +
4 files changed, 5 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index e07369ea9..e0a24d297 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -311,6 +311,7 @@
Otsesõnum
NodeDB lähtestamine
Kohale toimetatud
+ Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda.
Viga
Eira
Eemalda ignoreeritute hulgast
diff --git a/core/service/README.md b/core/service/README.md
index 197c5e1f0..2b38a9171 100644
--- a/core/service/README.md
+++ b/core/service/README.md
@@ -25,6 +25,7 @@ graph TB
:core:service[service]:::android-library
:core:service --> :core:api
:core:service -.-> :core:common
+ :core:service -.-> :core:data
:core:service -.-> :core:database
:core:service -.-> :core:model
:core:service -.-> :core:prefs
diff --git a/feature/messaging/README.md b/feature/messaging/README.md
index 59d8736bb..c323ea7a2 100644
--- a/feature/messaging/README.md
+++ b/feature/messaging/README.md
@@ -27,8 +27,10 @@ A security-focused utility that detects and transforms homoglyphs (visually simi
graph TB
:feature:messaging[messaging]:::android-feature
:feature:messaging -.-> :core:analytics
+ :feature:messaging -.-> :core:common
:feature:messaging -.-> :core:data
:feature:messaging -.-> :core:database
+ :feature:messaging -.-> :core:domain
:feature:messaging -.-> :core:model
:feature:messaging -.-> :core:navigation
:feature:messaging -.-> :core:prefs
diff --git a/feature/settings/README.md b/feature/settings/README.md
index 78074fb4d..cc5c584bb 100644
--- a/feature/settings/README.md
+++ b/feature/settings/README.md
@@ -29,6 +29,7 @@ graph TB
:feature:settings -.-> :core:data
:feature:settings -.-> :core:database
:feature:settings -.-> :core:datastore
+ :feature:settings -.-> :core:domain
:feature:settings -.-> :core:model
:feature:settings -.-> :core:navigation
:feature:settings -.-> :core:nfc
From bb37c6635353e3da664a80bf30e4d64bb5f5adb2 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:57:48 +0000
Subject: [PATCH 011/440] chore(deps): update co.touchlab:kermit to v2.1.0
(#4684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 48fe82c7c..2e7271980 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -222,7 +222,7 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
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" }
-kermit = { module = "co.touchlab:kermit", version = "2.0.8" }
+kermit = { module = "co.touchlab:kermit", version = "2.1.0" }
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
From 0fc3fd280e4636841b85a0b6a270ec9846b55160 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:34:13 -0600
Subject: [PATCH 012/440] chore(deps): update app.cash.turbine:turbine to
v1.2.1 (#4682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e7271980..6db2d473a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,7 +26,7 @@ ktlint = "1.7.1"
kover = "0.9.7"
mockk = "1.14.9"
testRetry = "1.6.4"
-turbine = "1.1.0"
+turbine = "1.2.1"
# Compose Multiplatform
compose-multiplatform = "1.11.0-alpha03"
From 40244f8337afb916d4b65e2dfe63c764cc0f06aa Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:34:50 -0600
Subject: [PATCH 013/440] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4686)
---
.../src/commonMain/composeResources/values-fi/strings.xml | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 1883a6d50..ea2a0bed0 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -311,6 +311,7 @@
Yksityisviesti
Tyhjennä NodeDB-tietokanta
Toimitus vahvistettu
+ Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön.
Virhe
Jätä huomiotta
Poista huomioimattomista
From 2c49db80418f9d557183e8d84b453afa50465561 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 3 Mar 2026 07:15:28 -0600
Subject: [PATCH 014/440] feat/decoupling (#4685)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.github/workflows/reusable-check.yml | 50 +-
.../filter/MessageFilterIntegrationTest.kt | 4 +-
.../com/geeksville/mesh/ApplicationModule.kt | 24 +-
.../com/geeksville/mesh/MeshServiceClient.kt | 4 +-
.../usecase/GetDiscoveredDevicesUseCase.kt | 6 +-
.../geeksville/mesh/model/DeviceListEntry.kt | 6 +-
.../com/geeksville/mesh/model/UIViewModel.kt | 26 +-
...ice.kt => AndroidRadioInterfaceService.kt} | 69 +--
.../mesh/repository/radio/InterfaceFactory.kt | 32 +-
.../mesh/repository/radio/InterfaceMapKey.kt | 8 +-
.../mesh/repository/radio/MockInterface.kt | 1 +
.../repository/radio/NordicBleInterface.kt | 13 +-
.../radio/NordicBleInterfaceSpec.kt | 13 +-
.../repository/radio/RadioRepositoryModule.kt | 4 +-
.../mesh/repository/radio/SerialInterface.kt | 1 +
.../mesh/repository/radio/StreamInterface.kt | 1 +
.../mesh/repository/radio/TCPInterface.kt | 1 +
.../mesh/service/AndroidAppWidgetUpdater.kt | 40 ++
...nager.kt => AndroidMeshLocationManager.kt} | 9 +-
.../mesh/service/AndroidMeshWorkerManager.kt | 42 ++
.../mesh/service/MarkAsReadReceiver.kt | 14 +-
.../mesh/service/MeshNodeManager.kt | 269 ----------
.../com/geeksville/mesh/service/MeshRouter.kt | 48 --
.../geeksville/mesh/service/MeshService.kt | 33 +-
.../service/MeshServiceNotificationsImpl.kt | 36 +-
.../mesh/service/ReactionReceiver.kt | 4 +-
.../geeksville/mesh/service/ReplyReceiver.kt | 45 +-
...viceBroadcasts.kt => ServiceBroadcasts.kt} | 81 ++-
.../main/java/com/geeksville/mesh/ui/Main.kt | 14 +-
.../ui/connections/ConnectionsViewModel.kt | 12 +-
.../mesh/ui/connections/ScannerViewModel.kt | 21 +-
.../components/CurrentlyConnectedInfo.kt | 2 +-
.../mesh/ui/sharing/ChannelViewModel.kt | 21 +-
.../mesh/widget/LocalStatsWidgetState.kt | 6 +-
.../mesh/widget/RefreshLocalStatsAction.kt | 8 +-
.../mesh/worker/ServiceKeepAliveWorker.kt | 4 +-
.../radio/NordicBleInterfaceRetryTest.kt | 1 +
.../radio/NordicBleInterfaceTest.kt | 3 +-
.../repository/radio/StreamInterfaceTest.kt | 1 +
.../java/com/geeksville/mesh/service/Fakes.kt | 12 +-
.../mesh/service/MeshMessageProcessorTest.kt | 122 -----
...dcastsTest.kt => ServiceBroadcastsTest.kt} | 15 +-
compose_compiler_config.conf | 4 +-
.../core/ble/BluetoothRepository.kt | 20 +-
core/data/build.gradle.kts | 1 +
.../core/data/di/RepositoryModule.kt | 151 ++++++
.../meshtastic/core/data/di/UseCaseModule.kt | 45 ++
.../core/data/manager/CommandSenderImpl.kt | 141 +++--
.../manager/FromRadioPacketHandlerImpl.kt | 56 +-
.../core/data/manager/HistoryManagerImpl.kt | 56 +-
.../data/manager/MeshActionHandlerImpl.kt | 115 +++--
.../data/manager/MeshConfigFlowManagerImpl.kt | 80 +--
.../data/manager/MeshConfigHandlerImpl.kt | 31 +-
.../data/manager/MeshConnectionManagerImpl.kt | 106 ++--
.../core/data/manager/MeshDataHandlerImpl.kt | 228 ++++-----
.../data/manager/MeshMessageProcessorImpl.kt | 98 ++--
.../core/data/manager/MeshRouterImpl.kt | 75 +++
.../core/data/manager/MessageFilterImpl.kt} | 25 +-
.../core/data/manager/MqttManagerImpl.kt | 18 +-
.../data/manager/NeighborInfoHandlerImpl.kt | 31 +-
.../core/data/manager/NodeManagerImpl.kt | 316 ++++++++++++
.../core/data/manager/PacketHandlerImpl.kt | 62 +--
.../data/manager/TracerouteHandlerImpl.kt | 41 +-
...ory.kt => DeviceHardwareRepositoryImpl.kt} | 11 +-
...odeRepository.kt => NodeRepositoryImpl.kt} | 125 +++--
.../core/data/repository/PacketRepository.kt | 361 -------------
.../data/repository/PacketRepositoryImpl.kt | 482 ++++++++++++++++++
...sitory.kt => RadioConfigRepositoryImpl.kt} | 28 +-
.../data/manager/CommandSenderHopLimitTest.kt | 31 +-
.../data/manager/CommandSenderImplTest.kt | 22 +-
.../manager/FromRadioPacketHandlerImplTest.kt | 23 +-
.../data/manager/HistoryManagerImplTest.kt | 12 +-
.../manager/MeshConnectionManagerImplTest.kt | 95 ++--
.../core/data/manager}/MeshDataHandlerTest.kt | 55 +-
.../data/manager/MessageFilterImplTest.kt} | 8 +-
.../core/data/manager/NodeManagerImplTest.kt | 29 +-
.../data/manager/PacketHandlerImplTest.kt | 32 +-
.../DeviceHardwareRepositoryTest.kt | 2 +-
.../data/repository/MeshLogRepositoryTest.kt | 2 +-
.../data/repository/NodeRepositoryTest.kt | 8 +-
core/database/build.gradle.kts | 1 +
.../core/database/dao/NodeInfoDaoTest.kt | 4 +-
.../core/database/DatabaseManager.kt | 19 +-
.../meshtastic/core/database/dao/PacketDao.kt | 14 +-
.../core/database/di/DatabaseModule.kt | 43 +-
.../core/database/entity/NodeEntity.kt | 4 +-
.../meshtastic/core/database/entity/Packet.kt | 32 +-
.../core/database/model/NodeTest.kt | 2 +-
core/domain/build.gradle.kts | 1 +
.../usecase/settings/AdminActionsUseCase.kt | 9 +-
.../settings/CleanNodeDatabaseUseCase.kt | 14 +-
.../usecase/settings/ExportDataUseCase.kt | 4 +-
.../usecase/settings/ExportProfileUseCase.kt | 2 +-
.../settings/ExportSecurityConfigUseCase.kt | 2 +-
.../usecase/settings/ImportProfileUseCase.kt | 2 +-
.../usecase/settings/InstallProfileUseCase.kt | 2 +-
.../usecase/settings/IsOtaCapableUseCase.kt | 8 +-
.../usecase/settings/MeshLocationUseCase.kt | 2 +-
.../settings/ProcessRadioResponseUseCase.kt | 4 +-
.../settings/SetAppIntroCompletedUseCase.kt | 6 +-
.../settings/SetDatabaseCacheLimitUseCase.kt | 4 +-
.../settings/SetMeshLogSettingsUseCase.kt | 2 +-
.../settings/SetProvideLocationUseCase.kt | 2 +-
.../usecase/settings/SetThemeUseCase.kt | 2 +-
.../settings/ToggleAnalyticsUseCase.kt | 2 +-
.../ToggleHomoglyphEncodingUseCase.kt | 2 +-
.../core/domain/FakeRadioController.kt | 20 +
.../domain/usecase/SendMessageUseCaseTest.kt | 24 +-
.../settings/AdminActionsUseCaseTest.kt | 2 +-
.../settings/CleanNodeDatabaseUseCaseTest.kt | 10 +-
.../usecase/settings/ExportDataUseCaseTest.kt | 5 +-
.../settings/IsOtaCapableUseCaseTest.kt | 6 +-
.../SetDatabaseCacheLimitUseCaseTest.kt | 2 +-
core/model/build.gradle.kts | 2 +
.../core/model/util/ChannelSetTest.kt | 2 +-
.../core/model/util/SharedContactTest.kt | 34 +-
.../core/model/util}/MeshDataMapperTest.kt | 38 +-
.../meshtastic/core/model/ChannelOption.kt | 6 +-
.../org/meshtastic/core/model/Contact.kt | 9 +
.../org/meshtastic/core/model}/InterfaceId.kt | 15 +-
.../org/meshtastic/core/model/MeshActivity.kt | 26 +
.../org/meshtastic/core}/model/Message.kt | 4 +-
.../kotlin/org/meshtastic/core}/model/Node.kt | 63 ++-
.../meshtastic/core}/model/NodeSortOption.kt | 2 +-
.../meshtastic/core/model/RadioController.kt | 251 ++++++++-
.../core/model}/RadioNotConnectedException.kt | 16 +-
.../org/meshtastic/core/model/Reaction.kt | 38 ++
.../kotlin/org/meshtastic/core}/model/TAK.kt | 2 +-
.../core/model}/service/ServiceAction.kt | 4 +-
.../core/model/service/TracerouteResponse.kt | 29 ++
.../meshtastic/core/model/util/ChannelSet.kt | 2 +-
core/network/build.gradle.kts | 6 +-
.../network/repository}/MQTTRepository.kt | 8 +-
.../repository}/TrustAllX509TrustManager.kt | 8 +-
core/prefs/build.gradle.kts | 1 +
.../meshtastic/core/prefs/di/PrefsModule.kt | 5 +
.../core/prefs/homoglyph/HomoglyphPrefs.kt | 5 +-
.../repository/build.gradle.kts | 26 +-
.../core/repository/AppWidgetUpdater.kt | 23 +
.../core/repository/CommandSender.kt | 89 ++++
.../core/repository/DatabaseManager.kt | 34 ++
.../repository/DeviceHardwareRepository.kt | 35 ++
.../core/repository/FromRadioPacketHandler.kt | 25 +
.../core/repository/HistoryManager.kt | 46 ++
.../core/repository/HomoglyphPrefs.kt | 21 +
.../core/repository/MeshActionHandler.kt | 123 +++++
.../core/repository/MeshConfigFlowManager.kt | 46 ++
.../core/repository/MeshConfigHandler.kt | 46 ++
.../core/repository/MeshConnectionManager.kt | 44 ++
.../core/repository/MeshDataHandler.kt | 47 ++
.../core/repository/MeshLocationManager.kt | 29 ++
.../core/repository/MeshMessageProcessor.kt | 35 ++
.../meshtastic/core/repository/MeshRouter.kt | 46 ++
.../repository}/MeshServiceNotifications.kt | 13 +-
.../core/repository/MeshWorkerManager.kt | 23 +
.../core/repository/MessageFilter.kt | 32 ++
.../core/repository}/MessageQueue.kt | 2 +-
.../meshtastic/core/repository/MqttManager.kt | 32 ++
.../core/repository/NeighborInfoHandler.kt | 23 +-
.../meshtastic/core/repository/NodeManager.kt | 104 ++++
.../core/repository/NodeRepository.kt | 177 +++++++
.../core/repository/PacketHandler.kt | 43 ++
.../core/repository/PacketRepository.kt | 213 ++++++++
.../core/repository/RadioConfigRepository.kt | 62 +++
.../core/repository/RadioInterfaceService.kt | 72 +++
.../core/repository/ServiceBroadcasts.kt | 39 ++
.../core/repository/ServiceRepository.kt | 147 ++++++
.../core/repository/TracerouteHandler.kt | 36 ++
.../repository}/usecase/SendMessageUseCase.kt | 63 ++-
.../service/AndroidRadioControllerImpl.kt | 54 +-
...ository.kt => AndroidServiceRepository.kt} | 58 +--
.../core/service/di/ServiceModule.kt | 9 +-
.../core/ui/component/ContactSharing.kt | 2 +-
.../core/ui/component/MainAppBar.kt | 2 +-
.../meshtastic/core/ui/component/NodeChip.kt | 2 +-
.../core/ui/component/SignalInfo.kt | 2 +-
.../preview/NodePreviewParameterProvider.kt | 2 +-
.../core/ui/component/preview/PreviewUtils.kt | 2 +-
.../core/ui/qr/ScannedQrCodeViewModel.kt | 20 +-
.../core/ui/share/SharedContactViewModel.kt | 8 +-
.../firmware/FirmwareUpdateViewModel.kt | 18 +-
.../feature/firmware/NordicDfuHandler.kt | 6 +-
.../feature/firmware/UsbUpdateHandler.kt | 14 +-
.../firmware/ota/Esp32OtaUpdateHandler.kt | 28 +-
.../firmware/ota/Esp32OtaUpdateHandlerTest.kt | 9 +-
.../org/meshtastic/feature/map/MapView.kt | 17 +-
.../meshtastic/feature/map/MapViewModel.kt | 14 +-
.../org/meshtastic/feature/map/MapView.kt | 4 +-
.../meshtastic/feature/map/MapViewModel.kt | 17 +-
.../feature/map/component/PulsingNodeChip.kt | 2 +-
.../feature/map/model/NodeClusterItem.kt | 2 +-
.../feature/map/BaseMapViewModel.kt | 152 +++---
.../feature/map/node/NodeMapViewModel.kt | 2 +-
.../feature/map/MapViewModelTest.kt | 14 +-
.../messaging/component/MessageItemTest.kt | 2 +-
.../meshtastic/feature/messaging/Message.kt | 4 +-
.../feature/messaging/MessageListPaged.kt | 9 +-
.../feature/messaging/MessageScreenEvent.kt | 5 +-
.../feature/messaging/MessageViewModel.kt | 20 +-
.../messaging/component/MessageItem.kt | 6 +-
.../feature/messaging/component/Reaction.kt | 17 +-
.../feature/messaging/di/MessagingModule.kt | 2 +-
.../domain/worker/SendMessageWorker.kt | 8 +-
.../domain/worker/WorkManagerMessageQueue.kt | 2 +-
.../feature/messaging/ui/contact/Contacts.kt | 2 +-
.../messaging/ui/contact/ContactsViewModel.kt | 74 +--
.../domain/worker/SendMessageWorkerTest.kt | 19 +-
feature/node/component/DeviceActions.kt | 2 +-
.../feature/node/component/InlineMap.kt | 5 +-
.../feature/node/component/InlineMap.kt | 2 +-
.../feature/node/compass/CompassViewModel.kt | 2 +-
.../node/component/AdministrationSection.kt | 4 +-
.../feature/node/component/DeviceActions.kt | 2 +-
.../node/component/EnvironmentMetrics.kt | 2 +-
.../node/component/LinkedCoordinatesItem.kt | 2 +-
.../node/component/NodeDetailsSection.kt | 2 +-
.../node/component/NodeFilterTextField.kt | 2 +-
.../feature/node/component/NodeItem.kt | 4 +-
.../feature/node/component/NodeMenuAction.kt | 2 +-
.../feature/node/component/NotesSection.kt | 2 +-
.../feature/node/component/PositionSection.kt | 2 +-
.../feature/node/component/PowerMetrics.kt | 2 +-
.../component/TelemetricActionsSection.kt | 2 +-
.../feature/node/detail/NodeDetailScreen.kt | 2 +-
.../node/detail/NodeDetailViewModel.kt | 6 +-
.../node/detail/NodeManagementActions.kt | 45 +-
.../feature/node/detail/NodeRequestActions.kt | 110 ++--
.../domain/usecase/GetFilteredNodesUseCase.kt | 6 +-
.../domain/usecase/GetNodeDetailsUseCase.kt | 10 +-
.../node/list/NodeFilterPreferences.kt | 5 +-
.../feature/node/list/NodeListScreen.kt | 2 +-
.../feature/node/list/NodeListViewModel.kt | 20 +-
.../feature/node/metrics/MetricsViewModel.kt | 6 +-
.../node/model/IsEffectivelyUnmessageable.kt | 4 +-
.../feature/node/model/MetricsState.kt | 2 +-
.../feature/node/model/NodeDetailAction.kt | 4 +-
.../node/detail/NodeManagementActionsTest.kt | 9 +-
.../usecase/GetFilteredNodesUseCaseTest.kt | 6 +-
.../feature/settings/AdministrationScreen.kt | 2 +-
.../feature/settings/SettingsViewModel.kt | 14 +-
.../settings/debugging/DebugViewModel.kt | 2 +-
.../filter/FilterSettingsViewModel.kt | 8 +-
.../settings/radio/CleanNodeDatabaseScreen.kt | 2 +-
.../radio/CleanNodeDatabaseViewModel.kt | 2 +-
.../settings/radio/RadioConfigViewModel.kt | 14 +-
.../component/ShutdownConfirmationDialog.kt | 2 +-
.../radio/component/TAKConfigItemList.kt | 4 +-
.../radio/component/UserConfigItemList.kt | 2 +-
.../feature/settings/SettingsViewModelTest.kt | 33 +-
.../settings/debugging/DebugViewModelTest.kt | 2 +-
.../filter/FilterSettingsViewModelTest.kt | 10 +-
.../radio/CleanNodeDatabaseViewModelTest.kt | 2 +-
.../radio/RadioConfigViewModelTest.kt | 10 +-
settings.gradle.kts | 1 +
254 files changed, 5132 insertions(+), 2666 deletions(-)
rename app/src/main/java/com/geeksville/mesh/repository/radio/{RadioInterfaceService.kt => AndroidRadioInterfaceService.kt} (87%)
create mode 100644 app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
rename app/src/main/java/com/geeksville/mesh/service/{MeshLocationManager.kt => AndroidMeshLocationManager.kt} (93%)
create mode 100644 app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
rename app/src/main/java/com/geeksville/mesh/service/{MeshServiceBroadcasts.kt => ServiceBroadcasts.kt} (62%)
delete mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
rename app/src/test/java/com/geeksville/mesh/service/{MeshServiceBroadcastsTest.kt => ServiceBroadcastsTest.kt} (82%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
rename app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt (77%)
rename app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt (53%)
rename app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt (74%)
rename app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt (74%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt (73%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt (79%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt (79%)
rename app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt (81%)
rename app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt (75%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt => data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt} (69%)
rename app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt (84%)
rename app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt (77%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
rename app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt (77%)
rename app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt (74%)
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{DeviceHardwareRepository.kt => DeviceHardwareRepositoryImpl.kt} (97%)
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{NodeRepository.kt => NodeRepositoryImpl.kt} (67%)
delete mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{RadioConfigRepository.kt => RadioConfigRepositoryImpl.kt} (80%)
rename app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt (78%)
rename app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt (76%)
rename app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt (82%)
rename app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt (86%)
rename app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt (73%)
rename {app/src/test/java/com/geeksville/mesh/service => core/data/src/test/kotlin/org/meshtastic/core/data/manager}/MeshDataHandlerTest.kt (72%)
rename core/{service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt => data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt} (94%)
rename app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt (80%)
rename app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt (75%)
rename {app/src/test/java/com/geeksville/mesh/service => core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util}/MeshDataMapperTest.kt (70%)
rename {app/src/main/java/com/geeksville/mesh/repository/radio => core/model/src/commonMain/kotlin/org/meshtastic/core/model}/InterfaceId.kt (74%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/Message.kt (97%)
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/Node.kt (87%)
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/NodeSortOption.kt (97%)
rename {app/src/main/java/com/geeksville/mesh/service => core/model/src/commonMain/kotlin/org/meshtastic/core/model}/RadioNotConnectedException.kt (58%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/TAK.kt (98%)
rename core/{service/src/main/kotlin/org/meshtastic/core => model/src/commonMain/kotlin/org/meshtastic/core/model}/service/ServiceAction.kt (93%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/TracerouteResponse.kt
rename {app/src/main/java/com/geeksville/mesh/repository/network => core/network/src/main/kotlin/org/meshtastic/core/network/repository}/MQTTRepository.kt (96%)
rename {app/src/main/java/com/geeksville/mesh/repository/network => core/network/src/main/kotlin/org/meshtastic/core/network/repository}/TrustAllX509TrustManager.kt (90%)
rename app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt => core/repository/build.gradle.kts (57%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppWidgetUpdater.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MeshServiceNotifications.kt (84%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageFilter.kt
rename core/{domain/src/main/kotlin/org/meshtastic/core/domain => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MessageQueue.kt (96%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
rename app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt => core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt (58%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt
rename core/{domain/src/main/kotlin/org/meshtastic/core/domain => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/usecase/SendMessageUseCase.kt (73%)
rename core/service/src/main/kotlin/org/meshtastic/core/service/{ServiceRepository.kt => AndroidServiceRepository.kt} (68%)
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 85b9d46ba..e480374fd 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -89,30 +89,18 @@ jobs:
- name: Determine Tasks
id: tasks
run: |
- TASKS=""
- # Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources
+ FLAVOR="${{ matrix.flavor }}"
+ FLAVOR_CAP=$(echo $FLAVOR | awk '{print toupper(substr($0,1,1))substr($0,2)}')
IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
- if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
- [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest "
- fi
-
- FLAVOR="${{ matrix.flavor }}"
- if [ "$IS_FIRST_API" = "true" ]; then
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS assembleGoogleDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS assembleFdroidDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest "
- fi
- fi
+ # Matrix-specific tasks
+ TASKS="assemble${FLAVOR_CAP}Debug "
+ [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS lint${FLAVOR_CAP}Debug "
+ [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS test${FLAVOR_CAP}DebugUnitTest "
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
- [ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest "
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS connectedGoogleDebugAndroidTest "
elif [ "$FLAVOR" = "fdroid" ]; then
@@ -120,20 +108,22 @@ jobs:
fi
fi
- # Run coverage report if unit tests were executed
- if [ "${{ inputs.run_unit_tests }}" = "true" ] && [ "$IS_FIRST_API" = "true" ]; then
- if [ "$IS_FIRST_FLAVOR" = "true" ]; then
- TASKS="$TASKS koverXmlReportDebug "
- fi
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS koverXmlReportGoogleDebug "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS koverXmlReportFdroidDebug "
- fi
+ # Run coverage report for this flavor
+ if [ "${{ inputs.run_unit_tests }}" = "true" ]; then
+ TASKS="$TASKS koverXmlReport${FLAVOR_CAP}Debug "
fi
echo "tasks=$TASKS" >> $GITHUB_OUTPUT
echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT
+ echo "is_first_flavor=$IS_FIRST_FLAVOR" >> $GITHUB_OUTPUT
+
+ - name: Code Style & Static Analysis
+ if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true'
+ run: ./gradlew spotlessCheck detekt -Pci=true
+
+ - name: Shared Unit Tests
+ if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true' && inputs.run_unit_tests == true
+ run: ./gradlew testDebugUnitTest koverXmlReportDebug -Pci=true --continue
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true
@@ -142,7 +132,7 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- - name: Run Check (with Emulator)
+ - name: Run Flavor Check (with Emulator)
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
env:
@@ -155,7 +145,7 @@ jobs:
disable-animations: true
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- - name: Run Check (no Emulator)
+ - name: Run Flavor Check (no Emulator)
if: inputs.run_instrumented_tests == false
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
diff --git a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
index 6a701aa8c..2c327a7af 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
+++ b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
@@ -26,7 +26,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
-import org.meshtastic.core.service.filter.MessageFilterService
+import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@HiltAndroidTest
@@ -37,7 +37,7 @@ class MessageFilterIntegrationTest {
@Inject lateinit var filterPrefs: FilterPrefs
- @Inject lateinit var filterService: MessageFilterService
+ @Inject lateinit var filterService: MessageFilter
@Before
fun setup() {
diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
index 5c546f476..dd07d74e2 100644
--- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,13 +14,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
+import com.geeksville.mesh.repository.radio.AndroidRadioInterfaceService
+import com.geeksville.mesh.service.AndroidAppWidgetUpdater
+import com.geeksville.mesh.service.AndroidMeshLocationManager
+import com.geeksville.mesh.service.AndroidMeshWorkerManager
import com.geeksville.mesh.service.MeshServiceNotificationsImpl
+import com.geeksville.mesh.service.ServiceBroadcasts
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -28,7 +32,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.ProcessLifecycle
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshServiceNotifications
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@@ -37,6 +41,20 @@ interface ApplicationModule {
@Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
+ @Binds
+ fun bindMeshLocationManager(impl: AndroidMeshLocationManager): org.meshtastic.core.repository.MeshLocationManager
+
+ @Binds fun bindMeshWorkerManager(impl: AndroidMeshWorkerManager): org.meshtastic.core.repository.MeshWorkerManager
+
+ @Binds fun bindAppWidgetUpdater(impl: AndroidAppWidgetUpdater): org.meshtastic.core.repository.AppWidgetUpdater
+
+ @Binds
+ fun bindRadioInterfaceService(
+ impl: AndroidRadioInterfaceService,
+ ): org.meshtastic.core.repository.RadioInterfaceService
+
+ @Binds fun bindServiceBroadcasts(impl: ServiceBroadcasts): org.meshtastic.core.repository.ServiceBroadcasts
+
companion object {
@Provides @ProcessLifecycle
fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
diff --git a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
index ca4b141a5..74fcea5bf 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
@@ -29,10 +29,10 @@ import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.SequentialJob
+import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceClient
-import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */
@@ -41,7 +41,7 @@ class MeshServiceClient
@Inject
constructor(
@ActivityContext private val context: Context,
- private val serviceRepository: ServiceRepository,
+ private val serviceRepository: AndroidServiceRepository,
private val serviceSetupJob: SequentialJob,
) : ServiceClient(IMeshService.Stub::asInterface),
DefaultLifecycleObserver {
diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
index a6759dae6..4b7a25c50 100644
--- a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
+++ b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
@@ -22,18 +22,18 @@ import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.meshtastic
import java.util.Locale
diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
index 6d2e4c448..d66d6fff0 100644
--- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
@@ -17,14 +17,14 @@
package com.geeksville.mesh.model
import android.hardware.usb.UsbManager
-import com.geeksville.mesh.repository.radio.InterfaceId
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.anonymize
+import org.meshtastic.core.repository.RadioInterfaceService
/**
* A sealed class is used here to represent the different types of devices that can be displayed in the list. This is
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
index 52ef78ce5..a3511ca74 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
@@ -22,8 +22,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.MeshActivity
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
@@ -45,21 +43,24 @@ import org.jetbrains.compose.resources.getString
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
+import org.meshtastic.core.model.MeshActivity
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
+import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
+import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
@@ -75,7 +76,8 @@ class UIViewModel
@Inject
constructor(
private val nodeDB: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val serviceRepository: AndroidServiceRepository,
+ private val radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
@@ -161,6 +163,10 @@ constructor(
val meshService: IMeshService?
get() = serviceRepository.meshService
+ fun setDeviceAddress(address: String) {
+ radioController.setDeviceAddress(address)
+ }
+
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
@@ -172,7 +178,7 @@ constructor(
}
// hardware info about our local device (can be null)
- val myNodeInfo: StateFlow
+ val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
init {
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
similarity index 87%
rename from app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
rename to app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
index f7cf8fbd5..cd190ad45 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
@@ -49,8 +49,11 @@ import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -65,9 +68,9 @@ import javax.inject.Singleton
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
* can be stubbed out with a simulated version as needed.
*/
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-open class RadioInterfaceService
+class AndroidRadioInterfaceService
@Inject
constructor(
private val context: Application,
@@ -78,20 +81,20 @@ constructor(
private val radioPrefs: RadioPrefs,
private val interfaceFactory: InterfaceFactory,
private val analytics: PlatformAnalytics,
-) {
+) : RadioInterfaceService {
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow = _connectionState.asStateFlow()
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64)
- val receivedData: SharedFlow = _receivedData
+ override val receivedData: SharedFlow = _receivedData
private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
val connectionError: SharedFlow = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
- val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
+ override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
private val logSends = false
private val logReceives = false
@@ -100,8 +103,11 @@ constructor(
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
+ override val serviceScope: CoroutineScope
+ get() = _serviceScope
+
/** We recreate this scope each time we stop an interface */
- var serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
+ private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: IRadioInterface = NopInterface("")
@@ -165,10 +171,10 @@ constructor(
}
/** Constructs a full radio address for the specific interface type. */
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
+ override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.toInterfaceAddress(interfaceId, rest)
- fun isMockInterface(): Boolean =
+ override fun isMockInterface(): Boolean =
BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
/**
@@ -185,7 +191,7 @@ constructor(
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
* path)
*/
- fun getDeviceAddress(): String? {
+ override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
var address = radioPrefs.devAddr
@@ -228,10 +234,11 @@ constructor(
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
- open fun handleFromRadio(p: ByteArray) {
+ @Suppress("TooGenericExceptionCaught")
+ override fun handleFromRadio(bytes: ByteArray) {
if (logReceives) {
try {
- receivedPacketsLog.write(p)
+ receivedPacketsLog.write(bytes)
receivedPacketsLog.flush()
} catch (t: Throwable) {
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
@@ -239,29 +246,33 @@ constructor(
}
try {
- processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
+ processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
emitReceiveActivity()
} catch (t: Throwable) {
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
}
}
- fun onConnect() {
+ override fun onConnect() {
if (_connectionState.value != ConnectionState.Connected) {
broadcastConnectionChanged(ConnectionState.Connected)
}
}
- fun onDisconnect(isPermanent: Boolean) {
+ override fun onDisconnect(isPermanent: Boolean) {
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
}
- fun onDisconnect(error: BleError) {
- processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) }
- onDisconnect(!error.shouldReconnect)
+ override fun onDisconnect(error: Any) {
+ if (error is BleError) {
+ processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) }
+ onDisconnect(!error.shouldReconnect)
+ } else {
+ onDisconnect(isPermanent = true)
+ }
}
/** Start our configured interface (if it isn't already running) */
@@ -311,8 +322,8 @@ constructor(
r.close()
// cancel any old jobs and get ready for the new ones
- serviceScope.cancel("stopping interface")
- serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
+ _serviceScope.cancel("stopping interface")
+ _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
if (logSends) {
sentPacketsLog.close()
@@ -356,26 +367,28 @@ constructor(
true
}
- fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { setBondedDeviceAddress(deviceAddr) }
+ override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
+ setBondedDeviceAddress(deviceAddr)
+ }
/**
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
* will not connect to a radio until this call is received.
*/
- fun connect() = toRemoteExceptions {
+ override fun connect() = toRemoteExceptions {
// We don't start actually talking to our device until MeshService binds to us - this prevents
// broadcasting connection events before MeshService is ready to receive them
startInterface()
initStateListeners()
}
- fun sendToRadio(a: ByteArray) {
+ override fun sendToRadio(bytes: ByteArray) {
// Do this in the IO thread because it might take a while (and we don't care about the result code)
- serviceScope.handledLaunch { handleSendToRadio(a) }
+ _serviceScope.handledLaunch { handleSendToRadio(bytes) }
}
private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64)
- val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
+ override val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
// Use tryEmit for SharedFlow as it's non-blocking
@@ -392,9 +405,3 @@ constructor(
}
}
}
-
-sealed class MeshActivity {
- data object Send : MeshActivity()
-
- data object Receive : MeshActivity()
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
index ffb34c2a8..f511cb555 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,41 +14,37 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.repository.radio
+import org.meshtastic.core.model.InterfaceId
import javax.inject.Inject
import javax.inject.Provider
/**
* Entry point for create radio backend instances given a specific address.
*
- * This class is responsible for building and dissecting radio addresses based upon
- * their interface type and the "rest" of the address (which varies per implementation).
+ * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
+ * of the address (which varies per implementation).
*/
-class InterfaceFactory @Inject constructor(
+class InterfaceFactory
+@Inject
+constructor(
private val nopInterfaceFactory: NopInterfaceFactory,
- private val specMap: Map>>
+ private val specMap: Map>>,
) {
- internal val nopInterface by lazy {
- nopInterfaceFactory.create("")
- }
+ internal val nopInterface by lazy { nopInterfaceFactory.create("") }
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String {
- return "${interfaceId.id}$rest"
- }
+ fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
fun createInterface(address: String): IRadioInterface {
val (spec, rest) = splitAddress(address)
return spec?.createInterface(rest) ?: nopInterface
}
- fun addressValid(address: String?): Boolean {
- return address?.let {
- val (spec, rest) = splitAddress(it)
- spec?.addressValid(rest)
- } ?: false
- }
+ fun addressValid(address: String?): Boolean = address?.let {
+ val (spec, rest) = splitAddress(it)
+ spec?.addressValid(rest)
+ } ?: false
private fun splitAddress(address: String): Pair?, String> {
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it]?.get() }
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt
index d6d6ae2ea..fc9170c6a 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,14 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.repository.radio
import dagger.MapKey
+import org.meshtastic.core.model.InterfaceId
-/**
- * Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key.
- */
+/** Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key. */
@MapKey
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
index 5b67d694f..2dc509ed2 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
@@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getInitials
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
index 19e047139..aa72dfdd4 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
@@ -18,7 +18,6 @@ package com.geeksville.mesh.repository.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.service.RadioNotConnectedException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
@@ -58,6 +57,8 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.RadioNotConnectedException
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
@@ -95,7 +96,7 @@ constructor(
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
- service.onDisconnect(BleError.from(throwable))
+ service.onDisconnect(error = BleError.from(throwable))
}
private val connectionScope: CoroutineScope =
@@ -152,7 +153,7 @@ constructor(
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
}
try {
- service.handleFromRadio(p = packet)
+ service.handleFromRadio(packet)
} catch (t: Throwable) {
Logger.e(t) { "[$address] Failed to execute service.handleFromRadio()" }
}
@@ -256,7 +257,7 @@ constructor(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
- service.onDisconnect(BleError.Disconnected(reason = state.reason))
+ service.onDisconnect(error = BleError.Disconnected(reason = state.reason))
}
private suspend fun discoverServicesAndSetupCharacteristics() {
@@ -286,12 +287,12 @@ constructor(
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
- service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
+ service.onDisconnect(error = BleError.DiscoveryFailed("One or more characteristics not found"))
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Service discovery failed" }
bleConnection.disconnect()
- service.onDisconnect(BleError.from(e))
+ service.onDisconnect(error = BleError.from(e))
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
index 49f989452..112d38e29 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
@@ -31,13 +31,10 @@ constructor(
override fun createInterface(rest: String): NordicBleInterface = factory.create(rest)
/** Return true if this address is still acceptable. For BLE that means, still bonded */
- override fun addressValid(rest: String): Boolean {
- val allPaired = bluetoothRepository.state.value.bondedDevices.map { it.address }.toSet()
- return if (!allPaired.contains(rest)) {
- Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
- false
- } else {
- true
- }
+ override fun addressValid(rest: String): Boolean = if (!bluetoothRepository.isBonded(rest)) {
+ Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
+ false
+ } else {
+ true
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
index 6a1d91f1a..88d957917 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.repository.radio
import dagger.Binds
@@ -23,6 +22,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoMap
import dagger.multibindings.Multibinds
+import org.meshtastic.core.model.InterfaceId
@Suppress("unused") // Used by hilt
@Module
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
index 4ebaf85d5..04d67b879 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
@@ -23,6 +23,7 @@ import com.geeksville.mesh.repository.usb.UsbRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.repository.RadioInterfaceService
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
index 538f4088a..973c38838 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import org.meshtastic.core.repository.RadioInterfaceService
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
index e2eeefa4c..a6a8320a5 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.Exceptions
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream
diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
new file mode 100644
index 000000000..9735b0ab5
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import com.geeksville.mesh.widget.LocalStatsWidget
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.meshtastic.core.repository.AppWidgetUpdater
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AndroidAppWidgetUpdater @Inject constructor(@ApplicationContext private val context: Context) : AppWidgetUpdater {
+ override suspend fun updateAll() {
+ // Kickstart the widget composition.
+ // The widget internally uses collectAsState() and its own sampled StateFlow
+ // to drive updates automatically without excessive IPC and recreation.
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ LocalStatsWidget().updateAll(context)
+ } catch (e: Exception) {
+ co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
similarity index 93%
rename from app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
rename to app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
index 482424a5e..7ab35c151 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
@@ -29,23 +29,24 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.MeshLocationManager
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import org.meshtastic.proto.Position as ProtoPosition
@Singleton
-class MeshLocationManager
+class AndroidMeshLocationManager
@Inject
constructor(
private val context: Application,
private val locationRepository: LocationRepository,
-) {
+) : MeshLocationManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var locationFlow: Job? = null
@SuppressLint("MissingPermission")
- fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {
+ override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {
this.scope = scope
if (locationFlow?.isActive == true) return
@@ -76,7 +77,7 @@ constructor(
}
}
- fun stop() {
+ override fun stop() {
if (locationFlow?.isActive == true) {
Logger.i { "Stopping location requests" }
locationFlow?.cancel()
diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
new file mode 100644
index 000000000..8b235ea5c
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AndroidMeshWorkerManager @Inject constructor(private val workManager: WorkManager) : MeshWorkerManager {
+ override fun enqueueSendMessage(packetId: Int) {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
index 3f1a85ec3..23f6b1737 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
@@ -25,32 +25,34 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.PacketRepository
import javax.inject.Inject
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
@AndroidEntryPoint
class MarkAsReadReceiver : BroadcastReceiver() {
+
@Inject lateinit var packetRepository: PacketRepository
- @Inject lateinit var meshServiceNotifications: MeshServiceNotifications
+ @Inject lateinit var serviceNotifications: MeshServiceNotifications
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
- const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ_ACTION"
- const val CONTACT_KEY = "contactKey"
+ const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"
+ const val CONTACT_KEY = "contact_key"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MARK_AS_READ_ACTION) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return
val pendingResult = goAsync()
+
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, nowMillis)
- meshServiceNotifications.cancelMessageNotification(contactKey)
+ serviceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
deleted file mode 100644
index 1f284c7a7..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ /dev/null
@@ -1,269 +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 com.geeksville.mesh.service
-
-import androidx.annotation.VisibleForTesting
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
-import okio.ByteString
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.MyNodeInfo
-import org.meshtastic.core.model.NodeInfo
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.util.NodeIdLookup
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.proto.DeviceMetadata
-import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.Paxcount
-import org.meshtastic.proto.StatusMessage
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.User
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
-import org.meshtastic.proto.Position as ProtoPosition
-
-@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
-@Singleton
-class MeshNodeManager
-@Inject
-constructor(
- private val nodeRepository: NodeRepository?,
- private val serviceBroadcasts: MeshServiceBroadcasts?,
- private val serviceNotifications: MeshServiceNotifications?,
-) : NodeIdLookup {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
-
- val nodeDBbyNodeNum = ConcurrentHashMap()
- val nodeDBbyID = ConcurrentHashMap()
-
- fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
- val isNodeDbReady = MutableStateFlow(false)
- val allowNodeDbWrites = MutableStateFlow(false)
-
- var myNodeNum: Int? = null
-
- companion object {
- private const val TIME_MS_TO_S = 1000L
- }
-
- @VisibleForTesting internal constructor() : this(null, null, null)
-
- fun loadCachedNodeDB() {
- scope.handledLaunch {
- val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap()
- nodeDBbyNodeNum.putAll(nodes)
- nodes.values.forEach { nodeDBbyID[it.user.id] = it }
- myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum
- }
- }
-
- fun clear() {
- nodeDBbyNodeNum.clear()
- nodeDBbyID.clear()
- isNodeDbReady.value = false
- allowNodeDbWrites.value = false
- myNodeNum = null
- }
-
- fun getMyNodeInfo(): MyNodeInfo? {
- val mi = nodeRepository?.myNodeInfo?.value ?: return null
- val myNode = nodeDBbyNodeNum[mi.myNodeNum]
- return MyNodeInfo(
- myNodeNum = mi.myNodeNum,
- hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
- model = mi.model ?: myNode?.user?.hw_model?.name,
- firmwareVersion = mi.firmwareVersion,
- couldUpdate = mi.couldUpdate,
- shouldUpdate = mi.shouldUpdate,
- currentPacketId = mi.currentPacketId,
- messageTimeoutMsec = mi.messageTimeoutMsec,
- minAppVersion = mi.minAppVersion,
- maxChannels = mi.maxChannels,
- hasWifi = mi.hasWifi,
- channelUtilization = 0f,
- airUtilTx = 0f,
- deviceId = mi.deviceId ?: myNode?.user?.id,
- )
- }
-
- fun getMyId(): String {
- val num = myNodeNum ?: nodeRepository?.myNodeInfo?.value?.myNodeNum ?: return ""
- return nodeDBbyNodeNum[num]?.user?.id ?: ""
- }
-
- fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
-
- fun removeByNodenum(nodeNum: Int) {
- nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
- }
-
- fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) {
- val userId = DataPacket.nodeNumToDefaultId(n)
- val defaultUser =
- User(
- id = userId,
- long_name = "Meshtastic ${userId.takeLast(n = 4)}",
- short_name = userId.takeLast(n = 4),
- hw_model = HardwareModel.UNSET,
- )
-
- NodeEntity(
- num = n,
- user = defaultUser,
- longName = defaultUser.long_name,
- shortName = defaultUser.short_name,
- channel = channel,
- )
- }
-
- fun updateNodeInfo(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, updateFn: (NodeEntity) -> Unit) {
- val info = getOrCreateNodeInfo(nodeNum, channel)
- updateFn(info)
- if (info.user.id.isNotEmpty()) {
- nodeDBbyID[info.user.id] = info
- }
-
- if (info.user.id.isNotEmpty() && isNodeDbReady.value) {
- scope.handledLaunch { nodeRepository?.upsert(info) }
- }
-
- if (withBroadcast) {
- serviceBroadcasts?.broadcastNodeChange(info.toNodeInfo())
- }
- }
-
- fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
- scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) }
- }
-
- fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) {
- updateNodeInfo(fromNum) {
- val newNode = (it.isUnknownUser && p.hw_model != HardwareModel.UNSET)
- val shouldPreserve = shouldPreserveExistingUser(it.user, p)
-
- if (shouldPreserve) {
- it.longName = it.user.long_name
- it.shortName = it.user.short_name
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- } else {
- val keyMatch = !it.hasPKC || it.user.public_key == p.public_key
- it.user = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
- it.longName = p.long_name
- it.shortName = p.short_name
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- if (newNode) {
- serviceNotifications?.showNewNodeSeenNotification(it)
- }
- }
- }
- }
-
- fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long = nowMillis) {
- if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
- Logger.d { "Ignoring nop position update for the local node" }
- } else {
- updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) }
- }
- }
-
- fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
- updateNodeInfo(fromNum) { nodeEntity ->
- when {
- telemetry.device_metrics != null -> nodeEntity.deviceTelemetry = telemetry
- telemetry.environment_metrics != null -> nodeEntity.environmentTelemetry = telemetry
- telemetry.power_metrics != null -> nodeEntity.powerTelemetry = telemetry
- }
- }
- }
-
- fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
- updateNodeInfo(fromNum) { it.paxcounter = p }
- }
-
- fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
- updateNodeStatus(fromNum, s.status)
- }
-
- fun updateNodeStatus(nodeNum: Int, status: String?) {
- updateNodeInfo(nodeNum) { it.nodeStatus = status?.takeIf { s -> s.isNotEmpty() } }
- }
-
- fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) {
- updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity ->
- val user = info.user
- if (user != null) {
- if (shouldPreserveExistingUser(entity.user, user)) {
- entity.longName = entity.user.long_name
- entity.shortName = entity.user.short_name
- } else {
- var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
- if (info.via_mqtt) {
- newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
- }
- entity.user = newUser
- entity.longName = newUser.long_name
- entity.shortName = newUser.short_name
- }
- }
- val position = info.position
- if (position != null) {
- entity.position = position
- entity.latitude = Position.degD(position.latitude_i ?: 0)
- entity.longitude = Position.degD(position.longitude_i ?: 0)
- }
- entity.lastHeard = info.last_heard
- if (info.device_metrics != null) {
- entity.deviceTelemetry = Telemetry(device_metrics = info.device_metrics)
- }
- entity.channel = info.channel
- entity.viaMqtt = info.via_mqtt
- entity.hopsAway = info.hops_away ?: -1
- entity.isFavorite = info.is_favorite
- entity.isIgnored = info.is_ignored
- entity.isMuted = info.is_muted
- }
- }
-
- private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
- val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
- val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
- val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
- return hasExistingUser && isDefaultName && isDefaultHwModel
- }
-
- override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
- DataPacket.ID_BROADCAST
- } else {
- nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
deleted file mode 100644
index b61bb6e02..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
+++ /dev/null
@@ -1,48 +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 com.geeksville.mesh.service
-
-import kotlinx.coroutines.CoroutineScope
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Orchestrates the specialized packet handlers for the [MeshService]. This class serves as a central registry and
- * lifecycle manager for all routing sub-components.
- */
-@Suppress("LongParameterList")
-@Singleton
-class MeshRouter
-@Inject
-constructor(
- val dataHandler: MeshDataHandler,
- val configHandler: MeshConfigHandler,
- val tracerouteHandler: MeshTracerouteHandler,
- val neighborInfoHandler: MeshNeighborInfoHandler,
- val configFlowManager: MeshConfigFlowManager,
- val mqttManager: MeshMqttManager,
- val actionHandler: MeshActionHandler,
-) {
- fun start(scope: CoroutineScope) {
- dataHandler.start(scope)
- configHandler.start(scope)
- tracerouteHandler.start(scope)
- neighborInfoHandler.start(scope)
- configFlowManager.start(scope)
- actionHandler.start(scope)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 2f01f3368..cf97cd5c2 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -25,7 +25,6 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -36,17 +35,27 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
-import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioNotConnectedException
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.PortNum
import javax.inject.Inject
@@ -58,17 +67,15 @@ class MeshService : Service() {
@Inject lateinit var serviceRepository: ServiceRepository
- @Inject lateinit var connectionStateHolder: ConnectionStateHandler
-
@Inject lateinit var packetHandler: PacketHandler
- @Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts
+ @Inject lateinit var serviceBroadcasts: ServiceBroadcasts
- @Inject lateinit var nodeManager: MeshNodeManager
+ @Inject lateinit var nodeManager: NodeManager
@Inject lateinit var messageProcessor: MeshMessageProcessor
- @Inject lateinit var commandSender: MeshCommandSender
+ @Inject lateinit var commandSender: CommandSender
@Inject lateinit var locationManager: MeshLocationManager
@@ -90,7 +97,7 @@ class MeshService : Service() {
fun actionReceived(portNum: Int): String {
val portType = PortNum.fromValue(portNum)
val portStr = portType?.toString() ?: portNum.toString()
- return com.geeksville.mesh.service.actionReceived(portStr)
+ return actionReceived(portStr)
}
fun createIntent(context: Context) = Intent(context, MeshService::class.java)
@@ -143,7 +150,7 @@ class MeshService : Service() {
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != NO_DEVICE_SELECTED
- val notification = connectionManager.updateStatusNotification()
+ val notification = connectionManager.updateStatusNotification() as android.app.Notification
val foregroundServiceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -311,7 +318,7 @@ class MeshService : Service() {
override fun getNodes(): List = nodeManager.getNodes()
- override fun connectionState(): String = connectionStateHolder.connectionState.value.toString()
+ override fun connectionState(): String = serviceRepository.connectionState.value.toString()
override fun startProvideLocation() {
locationManager.start(serviceScope) { commandSender.sendPosition(it) }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index babdc5565..47b0a7fb2 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -47,13 +47,15 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Message
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
@@ -86,8 +88,6 @@ import org.meshtastic.core.resources.no_local_stats
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
@@ -309,16 +309,14 @@ constructor(
if (myNodeNum != null) {
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
// and we only do this once if the cache is empty.
- val nodes = runBlocking { repo.getNodeEntityDBbyNumFlow().first() }
- nodes[myNodeNum]?.let { entity ->
+ val nodes = runBlocking { repo.nodeDBbyNum.first() }
+ nodes[myNodeNum]?.let { node ->
if (cachedDeviceMetrics == null) {
- cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
+ cachedDeviceMetrics = node.deviceMetrics
}
if (cachedLocalStats == null) {
// Fallback to DB stats if repository hasn't received any fresh ones yet
- cachedLocalStats =
- repo.localStats.value.takeIf { it.uptime_seconds != 0 }
- ?: entity.deviceTelemetry.local_stats
+ cachedLocalStats = repo.localStats.value.takeIf { it.uptime_seconds != 0 }
}
}
}
@@ -477,12 +475,12 @@ constructor(
notificationManager.notify(name.hashCode(), notification)
}
- override fun showNewNodeSeenNotification(node: NodeEntity) {
+ override fun showNewNodeSeenNotification(node: Node) {
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
notificationManager.notify(node.num, notification)
}
- override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
+ override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
val notification = createLowBatteryNotification(node, isRemote)
notificationManager.notify(node.num, notification)
}
@@ -495,7 +493,7 @@ constructor(
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
- override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
+ override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: ClientNotification) =
notificationManager.cancel(notification.toString().hashCode())
@@ -673,11 +671,11 @@ constructor(
return builder.build()
}
- private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
+ private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
- val title = getString(Res.string.low_battery_title).format(node.shortName)
- val batteryLevel = node.deviceMetrics?.battery_level ?: 0
- val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
+ val title = getString(Res.string.low_battery_title).format(node.user.short_name)
+ val batteryLevel = node.deviceMetrics.battery_level ?: 0
+ val message = getString(Res.string.low_battery_message).format(node.user.long_name, batteryLevel)
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
.setCategory(Notification.CATEGORY_STATUS)
diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
index 8462d8ec9..bea76c147 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
@@ -25,8 +25,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.repository.ServiceRepository
import javax.inject.Inject
@AndroidEntryPoint
diff --git a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
index a80839176..e21039670 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
@@ -17,12 +17,19 @@
package com.geeksville.mesh.service
import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
import androidx.core.app.RemoteInput
import dagger.hilt.android.AndroidEntryPoint
import jakarta.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.ServiceRepository
/**
* A [BroadcastReceiver] that handles inline replies from notifications.
@@ -33,32 +40,42 @@ import org.meshtastic.core.service.ServiceRepository
*/
@AndroidEntryPoint
class ReplyReceiver : BroadcastReceiver() {
- @Inject lateinit var serviceRepository: ServiceRepository
+ @Inject lateinit var radioController: RadioController
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
companion object {
const val REPLY_ACTION = "com.geeksville.mesh.REPLY_ACTION"
const val CONTACT_KEY = "contactKey"
const val KEY_TEXT_REPLY = "key_text_reply"
}
- private fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
- // contactKey: unique contact key filter (channel)+(nodeId)
- val channel = contactKey[0].digitToIntOrNull()
- val dest = if (channel != null) contactKey.substring(1) else contactKey
- val p = DataPacket(dest, channel ?: 0, str)
- serviceRepository.meshService?.send(p)
- }
-
- override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
+ override fun onReceive(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
- sendMessage(message, contactKey)
- meshServiceNotifications.cancelMessageNotification(contactKey)
+
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ sendMessage(message, contactKey)
+ meshServiceNotifications.cancelMessageNotification(contactKey)
+ } finally {
+ pendingResult.finish()
+ }
+ }
}
}
+
+ private suspend fun sendMessage(str: String, contactKey: String) {
+ // contactKey: unique contact key filter (channel)+(nodeId)
+ val channel = contactKey.getOrNull(0)?.digitToIntOrNull()
+ val dest = if (channel != null) contactKey.substring(1) else contactKey
+ val p = DataPacket(dest, channel ?: 0, str)
+ radioController.sendMessage(p)
+ }
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
similarity index 62%
rename from app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
rename to app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
index 34ce09dec..99d0bc724 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
@@ -24,57 +24,99 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.ServiceRepository
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
+import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts
@Singleton
-class MeshServiceBroadcasts
+class ServiceBroadcasts
@Inject
constructor(
@ApplicationContext private val context: Context,
- private val connectionStateHolder: ConnectionStateHandler,
private val serviceRepository: ServiceRepository,
-) {
+) : SharedServiceBroadcasts {
// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf()
- fun subscribeReceiver(receiverName: String, packageName: String) {
+ override fun subscribeReceiver(receiverName: String, packageName: String) {
clientPackages[receiverName] = packageName
}
/** Broadcast some received data Payload will be a DataPacket */
- fun broadcastReceivedData(payload: DataPacket) {
- val action = MeshService.actionReceived(payload.dataType)
- explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, payload))
+ override fun broadcastReceivedData(dataPacket: DataPacket) {
+ val action = MeshService.actionReceived(dataPacket.dataType)
+ explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket))
// Also broadcast with the numeric port number for backwards compatibility with some apps
- val numericAction = actionReceived(payload.dataType.toString())
+ val numericAction = actionReceived(dataPacket.dataType.toString())
if (numericAction != action) {
- explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, payload))
+ explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket))
}
}
- fun broadcastNodeChange(info: NodeInfo) {
- Logger.d { "Broadcasting node change ${info.user?.toPIIString()}" }
- val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info)
+ override fun broadcastNodeChange(node: Node) {
+ Logger.d { "Broadcasting node change ${node.user.toPIIString()}" }
+ val legacy = node.toLegacy()
+ val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy)
explicitBroadcast(intent)
}
- fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status)
+ private fun Node.toLegacy(): NodeInfo = NodeInfo(
+ num = num,
+ user =
+ org.meshtastic.core.model.MeshUser(
+ id = user.id,
+ longName = user.long_name,
+ shortName = user.short_name,
+ hwModel = user.hw_model,
+ role = user.role.value,
+ ),
+ position =
+ org.meshtastic.core.model
+ .Position(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = position.altitude ?: 0,
+ time = position.time,
+ satellitesInView = position.sats_in_view ?: 0,
+ groundSpeed = position.ground_speed ?: 0,
+ groundTrack = position.ground_track ?: 0,
+ precisionBits = position.precision_bits ?: 0,
+ )
+ .takeIf { latitude != 0.0 || longitude != 0.0 },
+ snr = snr,
+ rssi = rssi,
+ lastHeard = lastHeard,
+ deviceMetrics =
+ org.meshtastic.core.model.DeviceMetrics(
+ batteryLevel = deviceMetrics.battery_level ?: 0,
+ voltage = deviceMetrics.voltage ?: 0f,
+ channelUtilization = deviceMetrics.channel_utilization ?: 0f,
+ airUtilTx = deviceMetrics.air_util_tx ?: 0f,
+ uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
+ ),
+ channel = channel,
+ environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
+ hopsAway = hopsAway,
+ nodeStatus = nodeStatus,
+ )
- fun broadcastMessageStatus(id: Int, status: MessageStatus?) {
- if (id == 0) {
+ fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
+
+ override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {
+ if (packetId == 0) {
Logger.d { "Ignoring anonymous packet status" }
} else {
// Do not log, contains PII possibly
// MeshService.Logger.d { "Broadcasting message status $p" }
val intent =
Intent(ACTION_MESSAGE_STATUS).apply {
- putExtra(EXTRA_PACKET_ID, id)
+ putExtra(EXTRA_PACKET_ID, packetId)
putExtra(EXTRA_STATUS, status as Parcelable)
}
explicitBroadcast(intent)
@@ -82,14 +124,13 @@ constructor(
}
/** Broadcast our current connection status */
- fun broadcastConnection() {
- val connectionState = connectionStateHolder.connectionState.value
+ override fun broadcastConnection() {
+ val connectionState = serviceRepository.connectionState.value
// ATAK expects a String: "CONNECTED" or "DISCONNECTED"
// It uses equalsIgnoreCase, but we'll use uppercase to be specific.
val stateStr = connectionState.toString().uppercase(Locale.ROOT)
val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) }
- serviceRepository.setConnectionState(connectionState)
explicitBroadcast(intent)
if (connectionState == ConnectionState.Disconnected) {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index f28f98114..f41dcd8e1 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -85,7 +84,6 @@ import com.geeksville.mesh.navigation.firmwareGraph
import com.geeksville.mesh.navigation.mapGraph
import com.geeksville.mesh.navigation.nodesGraph
import com.geeksville.mesh.navigation.settingsGraph
-import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.ScannerViewModel
@@ -98,6 +96,7 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
+import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
@@ -464,7 +463,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie
private fun VersionChecks(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
- val context = LocalContext.current
val myFirmwareVersion = myNodeInfo?.firmwareVersion
@@ -499,10 +497,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.showAlert(
titleRes = Res.string.app_too_old,
messageRes = Res.string.must_update,
- onConfirm = {
- val service = viewModel.meshService ?: return@showAlert
- MeshService.changeDeviceAddress(context, service, "n")
- },
+ onConfirm = { viewModel.setDeviceAddress("n") },
)
} else {
myFirmwareVersion
@@ -526,10 +521,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.showAlert(
title = title,
html = message,
- onConfirm = {
- val service = viewModel.meshService ?: return@showAlert
- MeshService.changeDeviceAddress(context, service, "n")
- },
+ onConfirm = { viewModel.setDeviceAddress("n") },
)
} else if (curVer < MeshService.minDeviceVersion) {
Logger.w {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
index 88e9391f5..b17281ff6 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
@@ -21,12 +21,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@@ -46,7 +46,7 @@ constructor(
val connectionState = serviceRepository.connectionState
- val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
+ val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
index 131eb33e8..0bfba1faf 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
@@ -16,18 +16,13 @@
*/
package com.geeksville.mesh.ui.connections
-import android.app.Application
-import android.content.Context
-import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.domain.usecase.GetDiscoveredDevicesUseCase
import com.geeksville.mesh.model.DeviceListEntry
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
-import com.geeksville.mesh.service.MeshService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -42,8 +37,10 @@ import kotlinx.coroutines.launch
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import javax.inject.Inject
@@ -52,17 +49,14 @@ import javax.inject.Inject
class ScannerViewModel
@Inject
constructor(
- private val application: Application,
private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
) : ViewModel() {
- private val context: Context
- get() = application.applicationContext
-
val showMockInterface: StateFlow = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val _errorText = MutableStateFlow(null)
@@ -117,11 +111,8 @@ constructor(
}
private fun changeDeviceAddress(address: String) {
- try {
- serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) }
- } catch (ex: RemoteException) {
- Logger.e(ex) { "changeDeviceSelection failed, probably it is shutting down" }
- }
+ Logger.i { "Attempting to change device address to ${address.anonymize()}" }
+ radioController.setDeviceAddress(address)
}
/** Initiates the bonding process and connects to the device upon success. */
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
index eb359ca00..9bf5f3fbc 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
@@ -47,7 +47,7 @@ import no.nordicsemi.android.common.ui.view.RssiIcon
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.resources.firmware_version
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
index dc5f2a7b4..c5ba9bec4 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
@@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.sharing
import android.net.Uri
-import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
@@ -27,9 +26,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.toChannelSet
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.Channel
@@ -42,12 +41,12 @@ import javax.inject.Inject
class ChannelViewModel
@Inject
constructor(
- private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val radioConfigRepository: RadioConfigRepository,
private val analytics: PlatformAnalytics,
) : ViewModel() {
- val connectionState = serviceRepository.connectionState
+ val connectionState = radioController.connectionState
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
@@ -95,20 +94,12 @@ constructor(
}
fun setChannel(channel: Channel) {
- try {
- serviceRepository.meshService?.setChannel(channel.encode())
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Set channel error" }
- }
+ viewModelScope.launch { radioController.setLocalChannel(channel) }
}
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
- try {
- serviceRepository.meshService?.setConfig(config.encode())
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Set config error" }
- }
+ viewModelScope.launch { radioController.setLocalConfig(config) }
}
fun trackShare() {
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index eafbe38a2..1f28a65f7 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -28,11 +28,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
import javax.inject.Singleton
diff --git a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
index 16d6b566e..6a044c90e 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
@@ -20,22 +20,22 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
-import com.geeksville.mesh.service.MeshCommandSender
-import com.geeksville.mesh.service.MeshNodeManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
class RefreshLocalStatsAction : ActionCallback {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface RefreshLocalStatsEntryPoint {
- fun commandSender(): MeshCommandSender
+ fun commandSender(): CommandSender
- fun nodeManager(): MeshNodeManager
+ fun nodeManager(): NodeManager
}
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
diff --git a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
index d980d265e..a468896fb 100644
--- a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
+++ b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
@@ -31,8 +31,8 @@ import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.startService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
/**
* A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
index eb4ac385d..41cceafe2 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
index 2974d3029..1ee5ff9ee 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
@@ -47,6 +47,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
@@ -662,7 +663,7 @@ class NordicBleInterfaceTest {
advanceUntilIdle()
// Verify handleFromRadio was called directly with the payload
- verify(timeout = 2000) { service.handleFromRadio(p = payload) }
+ verify(timeout = 2000) { service.handleFromRadio(payload) }
nordicInterface.close()
}
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
index b0ddc037e..868c5197f 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
@@ -20,6 +20,7 @@ import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
+import org.meshtastic.core.repository.RadioInterfaceService
class StreamInterfaceTest {
diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
index 19b187bdc..86ecc7fb9 100644
--- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
@@ -17,10 +17,10 @@
package com.geeksville.mesh.service
import android.app.Notification
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.mockk
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@@ -64,15 +64,15 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
- override fun showNewNodeSeenNotification(node: NodeEntity) {}
+ override fun showNewNodeSeenNotification(node: Node) {}
- override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {}
+ override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
- override fun cancelLowBatteryNotification(node: NodeEntity) {}
+ override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
deleted file mode 100644
index 9b3aa4cfc..000000000
--- a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
+++ /dev/null
@@ -1,122 +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 com.geeksville.mesh.service
-
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.Data
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.PortNum
-
-class MeshMessageProcessorTest {
-
- private val nodeManager: MeshNodeManager = mockk(relaxed = true)
- private val serviceRepository: ServiceRepository = mockk(relaxed = true)
- private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
- private val router: MeshRouter = mockk(relaxed = true)
- private val fromRadioDispatcher: FromRadioPacketHandler = mockk(relaxed = true)
- private val meshLogRepositoryLazy = dagger.Lazy { meshLogRepository }
- private val dataHandler: MeshDataHandler = mockk(relaxed = true)
-
- private val isNodeDbReady = MutableStateFlow(false)
- private val testDispatcher = StandardTestDispatcher()
- private val testScope = TestScope(testDispatcher)
-
- private lateinit var processor: MeshMessageProcessor
-
- @Before
- fun setUp() {
- every { nodeManager.isNodeDbReady } returns isNodeDbReady
- every { router.dataHandler } returns dataHandler
- processor =
- MeshMessageProcessor(nodeManager, serviceRepository, meshLogRepositoryLazy, router, fromRadioDispatcher)
- processor.start(testScope)
- }
-
- @Test
- fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) {
- val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- // 1. Database is NOT ready
- isNodeDbReady.value = false
- testScheduler.runCurrent() // trigger start() onEach
-
- processor.handleReceivedMeshPacket(packet, 999)
-
- // Verify that handleReceivedData has NOT been called yet
- verify(exactly = 0) { dataHandler.handleReceivedData(any(), any(), any(), any()) }
-
- // 2. Database becomes ready
- isNodeDbReady.value = true
- testScheduler.runCurrent() // trigger onEach(true)
-
- // Verify that handleReceivedData is now called with the buffered packet
- verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 123 }, any(), any(), any()) }
- }
-
- @Test
- fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) {
- val packet = MeshPacket(id = 456, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, 999)
-
- verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) }
- }
-
- @Test
- fun `packets from local node are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) {
- val myNodeNum = 1234
- val packet = MeshPacket(from = myNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, myNodeNum)
- testScheduler.runCurrent() // wait for log insert job
-
- coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) }
- }
-
- @Test
- fun `packets from remote nodes are logged with their node number`() = runTest(testDispatcher) {
- val myNodeNum = 1234
- val remoteNodeNum = 5678
- val packet = MeshPacket(from = remoteNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, myNodeNum)
- testScheduler.runCurrent()
-
- coVerify { meshLogRepository.insert(match { log -> log.fromNum == remoteNodeNum }) }
- }
-}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
similarity index 82%
rename from app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
rename to app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
index 88cee4a4b..3ddfecd61 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
@@ -19,35 +19,36 @@ package com.geeksville.mesh.service
import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
+import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
-class MeshServiceBroadcastsTest {
+class ServiceBroadcastsTest {
private lateinit var context: Context
- private val connectionStateHolder = ConnectionStateHandler()
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
- private lateinit var broadcasts: MeshServiceBroadcasts
+ private lateinit var broadcasts: ServiceBroadcasts
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
- broadcasts = MeshServiceBroadcasts(context, connectionStateHolder, serviceRepository)
+ broadcasts = ServiceBroadcasts(context, serviceRepository)
}
@Test
fun `broadcastConnection sends uppercase state string for ATAK`() {
- connectionStateHolder.setState(ConnectionState.Connected)
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
@@ -58,7 +59,7 @@ class MeshServiceBroadcastsTest {
@Test
fun `broadcastConnection sends legacy connection intent`() {
- connectionStateHolder.setState(ConnectionState.Connected)
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf
index 5952a81bd..032dc04e0 100644
--- a/compose_compiler_config.conf
+++ b/compose_compiler_config.conf
@@ -3,8 +3,8 @@
// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file
// Meshtastic Models
-org.meshtastic.core.database.model.Node
-org.meshtastic.core.database.model.Message
+org.meshtastic.core.model.Node
+org.meshtastic.core.model.Message
org.meshtastic.core.database.entity.Reaction
org.meshtastic.core.database.entity.ReactionEntity
org.meshtastic.core.model.**
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
index e58e804b6..8861b8a11 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
@@ -81,7 +81,7 @@ constructor(
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
- refreshState()
+ updateBluetoothState()
}
internal suspend fun updateBluetoothState() {
@@ -112,6 +112,24 @@ constructor(
emptyList()
}
+ /** @return true if the given address is currently bonded to the system. */
+ @SuppressLint("MissingPermission")
+ fun isBonded(address: String): Boolean {
+ val enabled = androidEnvironment.isBluetoothEnabled
+ val hasPerms =
+ if (androidEnvironment.requiresBluetoothRuntimePermissions) {
+ androidEnvironment.isBluetoothScanPermissionGranted &&
+ androidEnvironment.isBluetoothConnectPermissionGranted
+ } else {
+ androidEnvironment.isLocationPermissionGranted
+ }
+ return if (enabled && hasPerms) {
+ centralManager.getBondedPeripherals().any { it.address == address }
+ } else {
+ false
+ }
+ }
+
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 1f06437b6..90a438478 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -26,6 +26,7 @@ plugins {
configure { namespace = "org.meshtastic.core.data" }
dependencies {
+ api(projects.core.repository)
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
new file mode 100644
index 000000000..333398c10
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
@@ -0,0 +1,151 @@
+/*
+ * 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.data.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.data.manager.CommandSenderImpl
+import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl
+import org.meshtastic.core.data.manager.HistoryManagerImpl
+import org.meshtastic.core.data.manager.MeshActionHandlerImpl
+import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl
+import org.meshtastic.core.data.manager.MeshConfigHandlerImpl
+import org.meshtastic.core.data.manager.MeshConnectionManagerImpl
+import org.meshtastic.core.data.manager.MeshDataHandlerImpl
+import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
+import org.meshtastic.core.data.manager.MeshRouterImpl
+import org.meshtastic.core.data.manager.MessageFilterImpl
+import org.meshtastic.core.data.manager.MqttManagerImpl
+import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl
+import org.meshtastic.core.data.manager.NodeManagerImpl
+import org.meshtastic.core.data.manager.PacketHandlerImpl
+import org.meshtastic.core.data.manager.TracerouteHandlerImpl
+import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
+import org.meshtastic.core.data.repository.NodeRepositoryImpl
+import org.meshtastic.core.data.repository.PacketRepositoryImpl
+import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
+import org.meshtastic.core.model.util.MeshDataMapper
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MessageFilter
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.TracerouteHandler
+import javax.inject.Singleton
+
+@Suppress("TooManyFunctions")
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+
+ @Binds @Singleton
+ abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindDeviceHardwareRepository(
+ deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl,
+ ): DeviceHardwareRepository
+
+ @Binds @Singleton
+ abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
+
+ @Binds @Singleton
+ abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
+
+ @Binds @Singleton
+ abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender
+
+ @Binds @Singleton
+ abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager
+
+ @Binds
+ @Singleton
+ abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler
+
+ @Binds @Singleton
+ abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager
+
+ @Binds @Singleton
+ abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager
+
+ @Binds @Singleton
+ abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor
+
+ @Binds @Singleton
+ abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter
+
+ @Binds
+ @Singleton
+ abstract fun bindFromRadioPacketHandler(
+ fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl,
+ ): FromRadioPacketHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager
+
+ @Binds @Singleton
+ abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter
+
+ companion object {
+ @Provides
+ @Singleton
+ fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager)
+ }
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
new file mode 100644
index 000000000..8093d73e9
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.data.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MessageQueue
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object UseCaseModule {
+
+ @Provides
+ @Singleton
+ fun provideSendMessageUseCase(
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+ radioController: RadioController,
+ homoglyphEncodingPrefs: HomoglyphPrefs,
+ messageQueue: MessageQueue,
+ ): SendMessageUseCase =
+ SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index 6e98b253e..4f262071c 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -14,10 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.os.RemoteException
-import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -28,14 +26,15 @@ import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -54,55 +53,56 @@ import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
-class MeshCommandSender
+class CommandSenderImpl
@Inject
constructor(
- private val packetHandler: PacketHandler?,
- private val nodeManager: MeshNodeManager?,
- private val connectionStateHolder: ConnectionStateHandler?,
- private val radioConfigRepository: RadioConfigRepository?,
-) {
+ private val packetHandler: PacketHandler,
+ private val nodeManager: NodeManager,
+ private val radioConfigRepository: RadioConfigRepository,
+) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
- val tracerouteStartTimes = ConcurrentHashMap()
- val neighborInfoStartTimes = ConcurrentHashMap()
+ override val tracerouteStartTimes = ConcurrentHashMap()
+ override val neighborInfoStartTimes = ConcurrentHashMap()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
- @Volatile var lastNeighborInfo: NeighborInfo? = null
+ override var lastNeighborInfo: NeighborInfo? = null
- fun start(scope: CoroutineScope) {
+ // We'll need a way to track connection state in shared code,
+ // maybe via ServiceRepository or similar.
+ // For now I'll assume it's injected or available.
+
+ override fun start(scope: CoroutineScope) {
this.scope = scope
- radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
- radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
+ radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
+ radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
}
- fun getCachedLocalConfig(): LocalConfig = localConfig.value
+ override fun getCachedLocalConfig(): LocalConfig = localConfig.value
- fun getCachedChannelSet(): ChannelSet = channelSet.value
+ override fun getCachedChannelSet(): ChannelSet = channelSet.value
- @VisibleForTesting internal constructor() : this(null, null, null, null)
+ override fun getCurrentPacketId(): Long = currentPacketId.get()
- fun getCurrentPacketId(): Long = currentPacketId.get()
-
- fun generatePacketId(): Int {
+ override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
return ((next % numPacketIds) + 1L).toInt()
}
- fun setSessionPasskey(key: ByteString) {
+ override fun setSessionPasskey(key: ByteString) {
sessionPasskey.set(key)
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
- val myNum = nodeManager?.myNodeNum ?: return 0
+ val myNum = nodeManager.myNodeNum ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
@@ -118,7 +118,7 @@ constructor(
return adminChannelIndex
}
- fun sendData(p: DataPacket) {
+ override fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
@@ -135,16 +135,15 @@ constructor(
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
p.status = MessageStatus.ERROR
- throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
+ // throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
+ // RemoteException is Android specific. For KMP we might want a custom exception.
+ error("Message too long: $actualSize bytes")
} else {
p.status = MessageStatus.QUEUED
}
- if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
- sendNow(p)
- } else {
- error("Radio is not connected")
- }
+ // TODO: Check connection state
+ sendNow(p)
}
private fun sendNow(p: DataPacket) {
@@ -164,31 +163,26 @@ constructor(
),
)
p.time = nowMillis
- packetHandler?.sendToRadio(meshPacket)
+ packetHandler.sendToRadio(meshPacket)
}
- fun sendAdmin(
- destNum: Int,
- requestId: Int = generatePacketId(),
- wantResponse: Boolean = false,
- initFn: () -> AdminMessage,
- ) {
+ override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.get())
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
- packetHandler?.sendToRadio(packet)
+ packetHandler.sendToRadio(packet)
}
- fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) {
- val myNum = nodeManager?.myNodeNum ?: return
+ override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
+ val myNum = nodeManager.myNodeNum ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
if (localConfig.value.position?.fixed_position != true) {
- nodeManager.handleReceivedPosition(myNum, myNum, pos)
+ nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
}
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
@@ -203,18 +197,18 @@ constructor(
)
}
- fun requestPosition(destNum: Int, currentPosition: Position) {
+ override fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
org.meshtastic.proto.Position(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
- time = nowSeconds.toInt(),
+ time = (nowMillis / 1000L).toInt(),
)
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
@@ -226,7 +220,7 @@ constructor(
)
}
- fun setFixedPosition(destNum: Int, pos: Position) {
+ override fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
org.meshtastic.proto.Position(
latitude_i = Position.degI(pos.latitude),
@@ -240,13 +234,13 @@ constructor(
AdminMessage(remove_fixed_position = true)
}
}
- nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos)
+ nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos, nowMillis)
}
- fun requestUserInfo(destNum: Int) {
- val myNum = nodeManager?.myNodeNum ?: return
- val myNode = nodeManager.getOrCreateNodeInfo(myNum)
- packetHandler?.sendToRadio(
+ override fun requestUserInfo(destNum: Int) {
+ val myNum = nodeManager.myNodeNum ?: return
+ val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
@@ -260,20 +254,20 @@ constructor(
)
}
- fun requestTraceroute(requestId: Int, destNum: Int) {
+ override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true),
),
)
}
- fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
+ override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
@@ -301,19 +295,19 @@ constructor(
.toByteString()
}
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true),
),
)
}
- fun requestNeighborInfo(requestId: Int, destNum: Int) {
+ override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis
- val myNum = nodeManager?.myNodeNum ?: 0
+ val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
lastNeighborInfo
@@ -329,7 +323,7 @@ constructor(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
- last_rx_time = nowSeconds.toInt(),
+ last_rx_time = (nowMillis / 1000L).toInt(),
node_broadcast_interval_secs = oneHour,
),
),
@@ -337,12 +331,12 @@ constructor(
}
// Send the neighbor info from our connected radio to ourselves (simulated)
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
@@ -353,20 +347,19 @@ constructor(
)
} else {
// Send request to remote
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true),
),
)
}
}
- @VisibleForTesting
- internal fun resolveNodeNum(toId: String): Int = when (toId) {
+ fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> {
val numericNum =
@@ -376,7 +369,7 @@ constructor(
null
}
numericNum
- ?: nodeManager?.nodeDBbyID?.get(toId)?.num
+ ?: nodeManager.nodeDBbyID[toId]?.num
?: throw IllegalArgumentException("Unknown node ID $toId")
}
}
@@ -398,12 +391,12 @@ constructor(
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
- publicKey = nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.public_key ?: ByteString.EMPTY
+ publicKey = nodeManager.nodeDBbyNodeNum[to]?.user?.public_key ?: ByteString.EMPTY
actualChannel = 0
}
return MeshPacket(
- from = nodeManager?.myNodeNum ?: 0,
+ from = nodeManager.myNodeNum ?: 0,
to = to,
id = id,
want_ack = wantAck,
diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
similarity index 53%
rename from app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index a771b6fa2..081d1a207 100644
--- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -14,31 +14,32 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import co.touchlab.kermit.Logger
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
+import dagger.Lazy
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import javax.inject.Inject
import javax.inject.Singleton
-/**
- * Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing
- * for config, metadata, and specialized system messages.
- */
+/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@Singleton
-class FromRadioPacketHandler
+class FromRadioPacketHandlerImpl
@Inject
constructor(
private val serviceRepository: ServiceRepository,
- private val router: MeshRouter,
- private val mqttManager: MeshMqttManager,
+ private val router: Lazy,
+ private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val serviceNotifications: MeshServiceNotifications,
-) {
+) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
- fun handleFromRadio(proto: FromRadio) {
+ override fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
val metadata = proto.metadata
val nodeInfo = proto.node_info
@@ -51,34 +52,23 @@ constructor(
val clientNotification = proto.clientNotification
when {
- myInfo != null -> router.configFlowManager.handleMyInfo(myInfo)
- metadata != null -> router.configFlowManager.handleLocalMetadata(metadata)
+ myInfo != null -> router.get().configFlowManager.handleMyInfo(myInfo)
+ metadata != null -> router.get().configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
- router.configFlowManager.handleNodeInfo(nodeInfo)
- serviceRepository.setConnectionProgress("Nodes (${router.configFlowManager.newNodeCount})")
+ router.get().configFlowManager.handleNodeInfo(nodeInfo)
+ serviceRepository.setConnectionProgress("Nodes (${router.get().configFlowManager.newNodeCount})")
}
- configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
+ configCompleteId != null -> router.get().configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
- config != null -> router.configHandler.handleDeviceConfig(config)
- moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
- channel != null -> router.configHandler.handleChannel(channel)
+ config != null -> router.get().configHandler.handleDeviceConfig(config)
+ moduleConfig != null -> router.get().configHandler.handleModuleConfig(moduleConfig)
+ channel != null -> router.get().configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
- packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
+ packetHandler.removeResponse(0, complete = false)
}
- // Logging-only variants are handled by MeshMessageProcessor before dispatching here
- proto.packet != null ||
- proto.log_record != null ||
- proto.rebooted != null ||
- proto.xmodemPacket != null ||
- proto.deviceuiConfig != null ||
- proto.fileInfo != null -> {
- /* No specialized routing needed here */
- }
-
- else -> Logger.d { "Dispatcher ignoring FromRadio variant" }
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index b084433b4..a2df3d73a 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -14,15 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.util.Log
-import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
@@ -32,19 +30,20 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshHistoryManager
+class HistoryManagerImpl
@Inject
constructor(
private val meshPrefs: MeshPrefs,
private val packetHandler: PacketHandler,
-) {
+) : HistoryManager {
+
companion object {
private const val HISTORY_TAG = "HistoryReplay"
private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24
private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
+ private const val NO_DEVICE_SELECTED = "No device selected"
- @VisibleForTesting
- internal fun buildStoreForwardHistoryRequest(
+ fun buildStoreForwardHistoryRequest(
lastRequest: Int,
historyReturnWindow: Int,
historyReturnMax: Int,
@@ -58,32 +57,23 @@ constructor(
return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history)
}
- @VisibleForTesting
- internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
+ fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES
val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES
return resolvedWindow to resolvedMax
}
}
- private fun historyLog(priority: Int = Log.INFO, throwable: Throwable? = null, message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag(HISTORY_TAG)
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
+ private val logger = Logger.withTag(HISTORY_TAG)
+
+ private fun historyLog(message: String, throwable: Throwable? = null) {
+ logger.i(throwable) { message }
}
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
- fun requestHistoryReplay(
+ override fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
@@ -92,7 +82,7 @@ constructor(
val address = activeDeviceAddress()
if (address == null || myNodeNum == null) {
val reason = if (address == null) "no_addr" else "no_my_node"
- historyLog { "requestHistory skipped trigger=$trigger reason=$reason" }
+ historyLog("requestHistory skipped trigger=$trigger reason=$reason")
return
}
@@ -105,10 +95,10 @@ constructor(
val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
- historyLog {
+ historyLog(
"requestHistory trigger=$trigger transport=$transport addr=$address " +
- "lastRequest=$lastRequest window=$window max=$max"
- }
+ "lastRequest=$lastRequest window=$window max=$max",
+ )
runCatching {
packetHandler.sendToRadio(
@@ -120,19 +110,19 @@ constructor(
),
)
}
- .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } }
+ .onFailure { ex -> logger.w(ex) { "requestHistory failed" } }
}
- fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
+ override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
if (lastRequest <= 0) return
val address = activeDeviceAddress() ?: return
val current = meshPrefs.getStoreForwardLastRequest(address)
if (lastRequest != current) {
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
- historyLog {
+ historyLog(
"historyMarker updated source=$source transport=$transport " +
- "addr=$address from=$current to=$lastRequest"
- }
+ "addr=$address from=$current to=$lastRequest",
+ )
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index 5ac1ee1cf..0adf6a80e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
@@ -26,15 +26,22 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.prefs.mesh.MeshPrefs
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.DatabaseManager
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -47,23 +54,23 @@ import javax.inject.Singleton
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
-class MeshActionHandler
+class MeshActionHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
- private val commandSender: MeshCommandSender,
+ private val nodeManager: NodeManager,
+ private val commandSender: CommandSender,
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
- private val dataHandler: MeshDataHandler,
+ private val serviceBroadcasts: ServiceBroadcasts,
+ private val dataHandler: Lazy,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val messageProcessor: Lazy,
-) {
+) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
@@ -72,7 +79,7 @@ constructor(
private const val EMOJI_INDICATOR = 1
}
- fun onServiceAction(action: ServiceAction) {
+ override fun onServiceAction(action: ServiceAction) {
ignoreException {
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
when (action) {
@@ -102,7 +109,7 @@ constructor(
AdminMessage(set_favorite_node = node.num)
}
}
- nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
+ nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
@@ -115,14 +122,14 @@ constructor(
AdminMessage(remove_ignored_node = node.num)
}
}
- nodeManager.updateNodeInfo(node.num) { it.isIgnored = newIgnoredStatus }
+ nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
scope.handledLaunch { packetRepository.get().updateFilteredBySender(node.user.id, newIgnoredStatus) }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
- nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted }
+ nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
}
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
@@ -147,7 +154,7 @@ constructor(
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
- verifiedContact.node_num ?: 0,
+ verifiedContact.node_num,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
@@ -155,11 +162,11 @@ constructor(
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
+ val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
val reaction =
- ReactionEntity(
- myNodeNum = myNodeNum,
+ Reaction(
replyId = action.replyId,
- userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
+ user = user,
emoji = action.emoji,
timestamp = nowMillis,
snr = 0f,
@@ -170,25 +177,25 @@ constructor(
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
- packetRepository.get().insertReaction(reaction)
+ packetRepository.get().insertReaction(reaction, myNodeNum)
}
}
- fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) {
+ override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
- fun handleSend(p: DataPacket, myNodeNum: Int) {
+ override fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
- serviceBroadcasts.broadcastMessageStatus(p)
- dataHandler.rememberDataPacket(p, myNodeNum, false)
+ serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
+ dataHandler.get().rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: okio.ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
- fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
+ override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
val currentPosition =
@@ -201,32 +208,32 @@ constructor(
}
}
- fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
+ override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
- fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
- fun handleGetRemoteOwner(id: Int, destNum: Int) {
+ override fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
- fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
+ override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
}
- fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
}
- fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
+ override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
@@ -236,104 +243,104 @@ constructor(
}
}
- fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
}
- fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
+ override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
- fun handleSetRingtone(destNum: Int, ringtone: String) {
+ override fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
- fun handleGetRingtone(id: Int, destNum: Int) {
+ override fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
- fun handleSetCannedMessages(destNum: Int, messages: String) {
+ override fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
- fun handleGetCannedMessages(id: Int, destNum: Int) {
+ override fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
- fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
+ override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
}
}
- fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
+ override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
}
}
- fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
+ override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
- fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
+ override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
commandSender.requestNeighborInfo(requestId, destNum)
}
- fun handleBeginEditSettings(destNum: Int) {
+ override fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
- fun handleCommitEditSettings(destNum: Int) {
+ override fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
- fun handleRebootToDfu(destNum: Int) {
+ override fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
- fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
+ override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
commandSender.requestTelemetry(requestId, destNum, type)
}
- fun handleRequestShutdown(requestId: Int, destNum: Int) {
+ override fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
- fun handleRequestReboot(requestId: Int, destNum: Int) {
+ override fun handleRequestReboot(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
- fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
+ override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
- fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
+ override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
- fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
+ override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
- fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
+ override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
- fun handleUpdateLastAddress(deviceAddr: String?) {
+ override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress
if (deviceAddr != currentAddr) {
meshPrefs.deviceAddress = deviceAddr
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
similarity index 73%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index 1d666ca2d..86026b9be 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -14,64 +14,71 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
+import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
+import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
+import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-class MeshConfigFlowManager
+class MeshConfigFlowManagerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
- private val connectionManager: MeshConnectionManager,
+ private val nodeManager: NodeManager,
+ private val connectionManager: Lazy,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceRepository: ServiceRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
- private val commandSender: MeshCommandSender,
+ private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
-) {
+) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
private val newNodes = mutableListOf()
- val newNodeCount: Int
+ override val newNodeCount: Int
get() = newNodes.size
- private var rawMyNodeInfo: MyNodeInfo? = null
+ private var rawMyNodeInfo: ProtoMyNodeInfo? = null
private var lastMetadata: DeviceMetadata? = null
- private var newMyNodeInfo: MyNodeEntity? = null
- private var myNodeInfo: MyNodeEntity? = null
+ private var newMyNodeInfo: SharedMyNodeInfo? = null
+ private var myNodeInfo: SharedMyNodeInfo? = null
- fun handleConfigComplete(configCompleteId: Int) {
+ override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
@@ -94,7 +101,7 @@ constructor(
} else {
myNodeInfo = finalizedInfo
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
- connectionManager.onRadioConfigLoaded()
+ connectionManager.get().onRadioConfigLoaded()
}
scope.handledLaunch {
@@ -102,7 +109,7 @@ constructor(
sendHeartbeat()
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
- connectionManager.startNodeInfoOnly()
+ connectionManager.get().startNodeInfoOnly()
}
}
@@ -129,19 +136,19 @@ constructor(
nodeRepository.installConfig(it, entities)
sendAnalytics(it)
}
- nodeManager.isNodeDbReady.value = true
- nodeManager.allowNodeDbWrites.value = true
- connectionStateHolder.setState(ConnectionState.Connected)
+ nodeManager.setNodeDbReady(true)
+ nodeManager.setAllowNodeDbWrites(true)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
- connectionManager.onNodeDbReady()
+ connectionManager.get().onNodeDbReady()
}
}
- private fun sendAnalytics(mi: MyNodeEntity) {
+ private fun sendAnalytics(mi: SharedMyNodeInfo) {
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
}
- fun handleMyInfo(myInfo: MyNodeInfo) {
+ override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.my_node_num
@@ -154,24 +161,29 @@ constructor(
}
}
- fun handleLocalMetadata(metadata: DeviceMetadata) {
+ override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
lastMetadata = metadata
regenMyNodeInfo(metadata)
}
- fun handleNodeInfo(info: NodeInfo) {
+ override fun handleNodeInfo(info: NodeInfo) {
newNodes.add(info)
}
+ override fun triggerWantConfig() {
+ connectionManager.get().startConfigOnly()
+ }
+
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
try {
val mi =
with(myInfo) {
- MyNodeEntity(
- myNodeNum = my_node_num ?: 0,
+ SharedMyNodeInfo(
+ myNodeNum = my_node_num,
+ hasGPS = false,
model =
when (val hwModel = metadata?.hw_model) {
null,
@@ -187,12 +199,14 @@ constructor(
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = myInfo.pio_env.ifEmpty { null },
)
}
if (metadata != null && metadata != DeviceMetadata()) {
- scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
+ scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) }
}
newMyNodeInfo = mi
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
similarity index 79%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
index 616529d14..d5ff32426 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,8 +24,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@@ -35,34 +37,33 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshConfigHandler
+class MeshConfigHandlerImpl
@Inject
constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
- private val nodeManager: MeshNodeManager,
-) {
+ private val nodeManager: NodeManager,
+) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig())
- val localConfig = _localConfig.asStateFlow()
+ override val localConfig = _localConfig.asStateFlow()
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
- val moduleConfig = _moduleConfig.asStateFlow()
+ override val moduleConfig = _moduleConfig.asStateFlow()
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
-
radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
}
- fun handleDeviceConfig(config: Config) {
+ override fun handleDeviceConfig(config: Config) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setConnectionProgress("Device config received")
}
- fun handleModuleConfig(config: ModuleConfig) {
+ override fun handleModuleConfig(config: ModuleConfig) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setConnectionProgress("Module config received")
@@ -71,13 +72,13 @@ constructor(
}
}
- fun handleChannel(ch: Channel) {
+ override fun handleChannel(channel: Channel) {
// We always want to save channel settings we receive from the radio
- scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
+ scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
// Update status message if we have node info, otherwise use a generic one
val mi = nodeManager.getMyNodeInfo()
- val index = ch.index ?: 0
+ val index = channel.index
if (mi != null) {
serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
} else {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
similarity index 79%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index eeb4882dc..a420793df 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -14,19 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.app.Notification
-import android.content.Context
-import androidx.glance.appwidget.updateAll
-import androidx.work.ExistingWorkPolicy
-import androidx.work.OneTimeWorkRequestBuilder
-import androidx.work.WorkManager
-import androidx.work.workDataOf
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.geeksville.mesh.widget.LocalStatsWidget
-import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -43,12 +33,25 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.repository.AppWidgetUpdater
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -56,8 +59,6 @@ import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@@ -70,27 +71,27 @@ import kotlin.time.DurationUnit
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-class MeshConnectionManager
+class MeshConnectionManagerImpl
@Inject
constructor(
- @ApplicationContext private val context: Context,
private val radioInterfaceService: RadioInterfaceService,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceRepository: ServiceRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val uiPrefs: UiPrefs,
private val packetHandler: PacketHandler,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
- private val mqttManager: MeshMqttManager,
- private val historyManager: MeshHistoryManager,
+ private val mqttManager: MqttManager,
+ private val historyManager: HistoryManager,
private val radioConfigRepository: RadioConfigRepository,
- private val commandSender: MeshCommandSender,
- private val nodeManager: MeshNodeManager,
+ private val commandSender: CommandSender,
+ private val nodeManager: NodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
- private val workManager: WorkManager,
-) {
+ private val workerManager: MeshWorkerManager,
+ private val appWidgetUpdater: AppWidgetUpdater,
+) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
@@ -98,18 +99,16 @@ constructor(
private var connectTimeMsec = 0L
@OptIn(FlowPreview::class)
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
- connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
+ serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
- // Kickstart the widget composition. The widget internally uses collectAsState()
- // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
scope.launch {
try {
- LocalStatsWidget().updateAll(context)
+ appWidgetUpdater.updateAll()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
}
@@ -154,7 +153,7 @@ constructor(
}
private fun onConnectionChanged(c: ConnectionState) {
- val current = connectionStateHolder.connectionState.value
+ val current = serviceRepository.connectionState.value
if (current == c) return
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
@@ -171,7 +170,7 @@ constructor(
handshakeTimeout = null
when (c) {
- is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
+ is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting)
is ConnectionState.Connected -> handleConnected()
is ConnectionState.DeviceSleep -> handleDeviceSleep()
is ConnectionState.Disconnected -> handleDisconnected()
@@ -180,8 +179,8 @@ constructor(
private fun handleConnected() {
// The service state remains 'Connecting' until config is fully loaded
- if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
- connectionStateHolder.setState(ConnectionState.Connecting)
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
Logger.i { "Starting mesh handshake (Stage 1)" }
@@ -192,12 +191,12 @@ constructor(
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
- if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
+ if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.w { "Handshake stall detected! Retrying Stage 1." }
startConfigOnly()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
- if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
+ if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
onConnectionChanged(ConnectionState.Disconnected)
}
@@ -206,7 +205,7 @@ constructor(
}
private fun handleDeviceSleep() {
- connectionStateHolder.setState(ConnectionState.DeviceSleep)
+ serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -239,7 +238,7 @@ constructor(
}
private fun handleDisconnected() {
- connectionStateHolder.setState(ConnectionState.Disconnected)
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -254,29 +253,20 @@ constructor(
serviceBroadcasts.broadcastConnection()
}
- fun startConfigOnly() {
+ override fun startConfigOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
}
- fun startNodeInfoOnly() {
+ override fun startNodeInfoOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
}
- fun onRadioConfigLoaded() {
+ override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
queuedPackets.forEach { packet ->
try {
- val workRequest =
- OneTimeWorkRequestBuilder()
- .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id))
- .build()
-
- workManager.enqueueUniqueWork(
- "${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}",
- ExistingWorkPolicy.REPLACE,
- workRequest,
- )
+ workerManager.enqueueSendMessage(packet.id)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to enqueue queued packet worker" }
}
@@ -288,7 +278,7 @@ constructor(
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
- fun onNodeDbReady() {
+ override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -329,14 +319,14 @@ constructor(
)
}
- fun updateTelemetry(telemetry: Telemetry) {
- telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
- updateStatusNotification(telemetry)
+ override fun updateTelemetry(t: Telemetry) {
+ t.local_stats?.let { nodeRepository.updateLocalStats(it) }
+ updateStatusNotification(t)
}
- fun updateStatusNotification(telemetry: Telemetry? = null): Notification {
+ override fun updateStatusNotification(telemetry: Telemetry?): Any {
val summary =
- when (connectionStateHolder.connectionState.value) {
+ when (serviceRepository.connectionState.value) {
is ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
similarity index 81%
rename from app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 36338d493..e84af354c 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -14,13 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.util.Log
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.radio.InterfaceId
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -33,25 +30,36 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.Packet
-import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
-import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MessageFilter
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
@@ -70,33 +78,42 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
+ *
+ * This class handles the complexity of:
+ * 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
+ * 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, SFPP).
+ * 3. Managing message history and persistence.
+ * 4. Triggering notifications for various packet types (Text, Waypoints, Battery).
+ * 5. Tracking received telemetry for node updates.
+ */
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
@Singleton
-class MeshDataHandler
+class MeshDataHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
- private val configHandler: MeshConfigHandler,
- private val configFlowManager: MeshConfigFlowManager,
- private val commandSender: MeshCommandSender,
- private val historyManager: MeshHistoryManager,
- private val meshPrefs: MeshPrefs,
- private val connectionManager: MeshConnectionManager,
- private val tracerouteHandler: MeshTracerouteHandler,
- private val neighborInfoHandler: MeshNeighborInfoHandler,
+ private val configHandler: Lazy,
+ private val configFlowManager: Lazy,
+ private val commandSender: CommandSender,
+ private val historyManager: HistoryManager,
+ private val connectionManager: Lazy,
+ private val tracerouteHandler: TracerouteHandler,
+ private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
- private val messageFilterService: MessageFilterService,
-) {
+ private val messageFilter: MessageFilter,
+) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
@@ -108,7 +125,7 @@ constructor(
PortNum.NODE_STATUS_APP.value,
)
- fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) {
+ override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) {
val dataPacket = dataMapper.toDataPacket(packet) ?: return
val fromUs = myNodeNum == packet.from
dataPacket.status = MessageStatus.RECEIVED
@@ -221,7 +238,7 @@ constructor(
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
- @Suppress("LongMethod")
+ @Suppress("LongMethod", "ReturnCount")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
@@ -340,20 +357,20 @@ constructor(
val fromNum = packet.from
u.get_module_config_response?.let { config ->
if (fromNum == myNodeNum) {
- configHandler.handleModuleConfig(config)
+ configHandler.get().handleModuleConfig(config)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
- u.get_config_response?.let { configHandler.handleDeviceConfig(it) }
- u.get_channel_response?.let { configHandler.handleChannel(it) }
+ u.get_config_response?.let { configHandler.get().handleDeviceConfig(it) }
+ u.get_channel_response?.let { configHandler.get().handleChannel(it) }
}
u.get_device_metadata_response?.let { metadata ->
if (fromNum == myNodeNum) {
- configFlowManager.handleLocalMetadata(metadata)
+ configFlowManager.get().handleLocalMetadata(metadata)
} else {
nodeManager.insertMetadata(fromNum, metadata)
}
@@ -395,39 +412,43 @@ constructor(
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
- connectionManager.updateTelemetry(t)
+ connectionManager.get().updateTelemetry(t)
}
- nodeManager.updateNodeInfo(fromNum) { nodeEntity ->
+ nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
+
+ var nextNode = node
when {
metrics != null -> {
- nodeEntity.deviceTelemetry = t
- if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
+ nextNode = nextNode.copy(deviceMetrics = metrics)
+ if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
if (
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
- serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
+ serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
- serviceNotifications.cancelLowBatteryNotification(nodeEntity)
+ serviceNotifications.cancelLowBatteryNotification(nextNode)
}
}
}
- environment != null -> nodeEntity.environmentTelemetry = t
- power != null -> nodeEntity.powerTelemetry = t
+ environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
+ power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
+ nextNode
}
}
+ @Suppress("ReturnCount")
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
@@ -475,30 +496,26 @@ constructor(
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
scope.handledLaunch {
val isAck = routingError == Routing.Error.NONE.value
- val p = packetRepository.get().getPacketById(requestId)
+ val p = packetRepository.get().getPacketByPacketId(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
@Suppress("MaxLineLength")
Logger.d {
- val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
+ val statusInfo = "status=${p?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
- "packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
+ "packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo"
}
val m =
when {
- isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
+ isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
isAck -> MessageStatus.DELIVERED
else -> MessageStatus.ERROR
}
- if (p != null && p.data.status != MessageStatus.RECEIVED) {
- p.data.status = m
- p.routingError = routingError
- if (isAck) {
- p.data.relays += 1
- }
- p.data.relayNode = relayNode
- packetRepository.get().update(p)
+ if (p != null && p.status != MessageStatus.RECEIVED) {
+ val updatedPacket =
+ p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode)
+ packetRepository.get().update(updatedPacket)
}
reaction?.let { r ->
@@ -517,11 +534,11 @@ constructor(
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
- val transport = currentTransport()
+ // For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
+ // In the original, it was used for logging.
val h = s.history
val lastRequest = h?.last_request ?: 0
- val baseContext = "transport=$transport from=${dataPacket.from}"
- historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" }
+ Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
@@ -533,10 +550,6 @@ constructor(
rememberDataPacket(u, myNodeNum)
}
h != null -> {
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}"
- }
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
@@ -547,20 +560,17 @@ constructor(
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
- historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport)
+ // historyManager call remains same
+ historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
- historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" }
+ Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember"
- }
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
@@ -568,7 +578,7 @@ constructor(
}
}
- fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) {
+ override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
@@ -594,25 +604,16 @@ constructor(
// Check if message should be filtered
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
- val packetToSave =
- Packet(
- uuid = 0L,
- myNodeNum = myNodeNum,
- packetId = dataPacket.id,
- port_num = dataPacket.dataType,
- contact_key = contactKey,
- received_time = nowMillis,
- read = fromLocal || isFiltered,
- data = dataPacket,
- snr = dataPacket.snr,
- rssi = dataPacket.rssi,
- hopsAway = dataPacket.hopsAway,
- filtered = isFiltered,
- )
-
- insert(packetToSave)
+ insert(
+ dataPacket,
+ myNodeNum,
+ contactKey,
+ nowMillis,
+ read = fromLocal || isFiltered,
+ filtered = isFiltered,
+ )
if (!isFiltered) {
- handlePacketNotification(packetToSave, dataPacket, contactKey, updateNotification)
+ handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}
}
@@ -625,11 +626,10 @@ constructor(
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
- return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
+ return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
private suspend fun handlePacketNotification(
- packet: Packet,
dataPacket: DataPacket,
contactKey: String,
updateNotification: Boolean,
@@ -637,7 +637,7 @@ constructor(
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
- if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) {
+ if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
@@ -696,13 +696,14 @@ constructor(
val decoded = packet.decoded ?: return@handledLaunch
val emoji = decoded.payload.toByteArray().decodeToString()
val fromId = nodeManager.toNodeID(packet.from)
- val toId = nodeManager.toNodeID(packet.to)
+
+ val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from)
+ val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to)
val reaction =
- ReactionEntity(
- myNodeNum = nodeManager.myNodeNum ?: 0,
+ Reaction(
replyId = decoded.reply_id,
- userId = fromId,
+ user = fromNode.user,
emoji = emoji,
timestamp = nowMillis,
snr = packet.rx_snr,
@@ -715,7 +716,7 @@ constructor(
},
packetId = packet.id,
status = MessageStatus.RECEIVED,
- to = toId,
+ to = toNode.user.id,
channel = packet.channel,
)
@@ -729,25 +730,25 @@ constructor(
return@handledLaunch
}
- packetRepository.get().insertReaction(reaction)
+ packetRepository.get().insertReaction(reaction, nodeManager.myNodeNum ?: 0)
// Find the original packet to get the contactKey
- packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original ->
+ packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
- if (original.packet.filtered) return@let
-
- val contactKey = original.packet.contact_key
+ val targetId =
+ if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
+ val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (!isSilent) {
val channelName =
- if (original.packet.data.to == DataPacket.ID_BROADCAST) {
+ if (originalPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow
.first()
.settings
- .getOrNull(original.packet.data.channel)
+ .getOrNull(originalPacket.channel)
?.name
} else {
null
@@ -756,7 +757,7 @@ constructor(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
- original.packet.data.to == DataPacket.ID_BROADCAST,
+ originalPacket.to == DataPacket.ID_BROADCAST,
channelName,
isSilent,
)
@@ -764,33 +765,6 @@ constructor(
}
}
- private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
- InterfaceId.BLUETOOTH.id -> "BLE"
- InterfaceId.TCP.id -> "TCP"
- InterfaceId.SERIAL.id -> "Serial"
- InterfaceId.MOCK.id -> "Mock"
- InterfaceId.NOP.id -> "NOP"
- else -> "Unknown"
- }
-
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag("HistoryReplay")
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
- }
-
companion object {
private const val HOPS_AWAY_UNAVAILABLE = -1
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
similarity index 75%
rename from app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 7ed7980c3..1c19c8f31 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.util.Log
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -31,8 +29,13 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
@@ -44,17 +47,18 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
+/** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */
@Suppress("TooManyFunctions")
@Singleton
-class MeshMessageProcessor
+class MeshMessageProcessorImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val meshLogRepository: Lazy,
- private val router: MeshRouter,
+ private val router: Lazy,
private val fromRadioDispatcher: FromRadioPacketHandler,
-) {
+) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val logUuidByPacketId = ConcurrentHashMap()
private val logInsertJobByPacketId = ConcurrentHashMap()
@@ -62,11 +66,11 @@ constructor(
private val earlyReceivedPackets = ArrayDeque()
private val maxEarlyPacketBuffer = 10240
- fun clearEarlyPackets() {
+ override fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
}
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
nodeManager.isNodeDbReady
.onEach { ready ->
@@ -77,7 +81,7 @@ constructor(
.launchIn(scope)
}
- fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
+ override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
runCatching { FromRadio.ADAPTER.decode(bytes) }
.onSuccess { proto -> processFromRadio(proto, myNodeNum) }
.onFailure { primaryException ->
@@ -134,7 +138,7 @@ constructor(
)
}
- fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
+ override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val rxTime =
if (packet.rx_time == 0) {
nowSeconds.toInt()
@@ -149,21 +153,9 @@ constructor(
synchronized(earlyReceivedPackets) {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
- val dropped = earlyReceivedPackets.removeFirst()
- historyLog(Log.WARN) {
- val portLabel =
- dropped.decoded?.portnum?.name ?: dropped.decoded?.portnum?.value?.toString() ?: "unknown"
- "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel"
- }
+ earlyReceivedPackets.removeFirst()
}
earlyReceivedPackets.addLast(preparedPacket)
- val portLabel =
- preparedPacket.decoded?.portnum?.name
- ?: preparedPacket.decoded?.portnum?.value?.toString()
- ?: "unknown"
- historyLog {
- "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel"
- }
}
}
}
@@ -176,11 +168,12 @@ constructor(
earlyReceivedPackets.clear()
list
}
- historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" }
+ Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
+ @Suppress("LongMethod")
private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val decoded = packet.decoded ?: return
val log =
@@ -202,22 +195,24 @@ constructor(
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
- nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { it.lastHeard = nowSeconds.toInt() }
- nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) {
- it.lastHeard = packet.rx_time
- it.viaMqtt = packet.via_mqtt == true
- it.lastTransport = packet.transport_mechanism.value
-
+ nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node ->
+ node.copy(lastHeard = nowSeconds.toInt())
+ }
+ nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node ->
+ val viaMqtt = packet.via_mqtt == true
val isDirect = packet.hop_start == packet.hop_limit
- if (isDirect && packet.isLora() && !it.viaMqtt) {
- it.snr = packet.rx_snr
- it.rssi = packet.rx_rssi
+
+ var snr = node.snr
+ var rssi = node.rssi
+ if (isDirect && packet.isLora() && !viaMqtt) {
+ snr = packet.rx_snr
+ rssi = packet.rx_rssi
}
- it.hopsAway =
+ val hopsAway =
if (decoded.portnum == PortNum.RANGE_TEST_APP) {
0
- } else if (it.viaMqtt) {
+ } else if (viaMqtt) {
-1
} else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) {
-1
@@ -226,10 +221,19 @@ constructor(
} else {
packet.hop_start - packet.hop_limit
}
+
+ node.copy(
+ lastHeard = packet.rx_time,
+ viaMqtt = viaMqtt,
+ lastTransport = packet.transport_mechanism.value,
+ snr = snr,
+ rssi = rssi,
+ hopsAway = hopsAway,
+ )
}
try {
- router.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
+ router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
@@ -239,24 +243,6 @@ constructor(
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag("HistoryReplay")
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
- }
-
private fun ByteArray.toHexString(): String =
this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
new file mode 100644
index 000000000..b079b1d86
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.data.manager
+
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineScope
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.TracerouteHandler
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
+@Suppress("LongParameterList")
+@Singleton
+class MeshRouterImpl
+@Inject
+constructor(
+ private val dataHandlerLazy: Lazy,
+ private val configHandlerLazy: Lazy,
+ private val tracerouteHandlerLazy: Lazy,
+ private val neighborInfoHandlerLazy: Lazy,
+ private val configFlowManagerLazy: Lazy,
+ private val mqttManagerLazy: Lazy,
+ private val actionHandlerLazy: Lazy,
+) : MeshRouter {
+ override val dataHandler: MeshDataHandler
+ get() = dataHandlerLazy.get()
+
+ override val configHandler: MeshConfigHandler
+ get() = configHandlerLazy.get()
+
+ override val tracerouteHandler: TracerouteHandler
+ get() = tracerouteHandlerLazy.get()
+
+ override val neighborInfoHandler: NeighborInfoHandler
+ get() = neighborInfoHandlerLazy.get()
+
+ override val configFlowManager: MeshConfigFlowManager
+ get() = configFlowManagerLazy.get()
+
+ override val mqttManager: MqttManager
+ get() = mqttManagerLazy.get()
+
+ override val actionHandler: MeshActionHandler
+ get() = actionHandlerLazy.get()
+
+ override fun start(scope: CoroutineScope) {
+ dataHandler.start(scope)
+ configHandler.start(scope)
+ tracerouteHandler.start(scope)
+ neighborInfoHandler.start(scope)
+ configFlowManager.start(scope)
+ actionHandler.start(scope)
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
similarity index 69%
rename from core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
index bb8a773aa..906e615ae 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
@@ -14,34 +14,25 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.service.filter
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
-/**
- * Service for filtering messages based on user-configured filter words. Supports both plain text word matching and
- * regex patterns.
- */
+/** Implementation of [MessageFilter] that uses regex and plain text matching. */
@Singleton
-class MessageFilterService @Inject constructor(private val filterPrefs: FilterPrefs) {
+class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs) : MessageFilter {
private var compiledPatterns: List = emptyList()
init {
rebuildPatterns()
}
- /**
- * Determines if a message should be filtered based on the configured filter words.
- *
- * @param message The message text to check.
- * @param isFilteringDisabled Whether filtering is disabled for this contact.
- * @return true if the message should be filtered, false otherwise.
- */
- fun shouldFilter(message: String, isFilteringDisabled: Boolean = false): Boolean {
+ override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
@@ -49,11 +40,7 @@ class MessageFilterService @Inject constructor(private val filterPrefs: FilterPr
return compiledPatterns.any { it.containsMatchIn(textToCheck) }
}
- /**
- * Rebuilds the compiled regex patterns from the current filter words. Should be called whenever the filter words
- * are updated.
- */
- fun rebuildPatterns() {
+ override fun rebuildPatterns() {
compiledPatterns =
filterPrefs.filterWords.mapNotNull { word ->
try {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
similarity index 84%
rename from app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index 314b7c99c..7684ebd20 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -14,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import com.geeksville.mesh.repository.network.MQTTRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,24 +25,27 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshMqttManager
+class MqttManagerImpl
@Inject
constructor(
private val mqttRepository: MQTTRepository,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
-) {
+) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mqttMessageFlow: Job? = null
- fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
+ override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
this.scope = scope
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
@@ -60,7 +62,7 @@ constructor(
}
}
- fun stop() {
+ override fun stop() {
if (mqttMessageFlow?.isActive == true) {
Logger.i { "Stopping MqttClientProxy" }
mqttMessageFlow?.cancel()
@@ -68,7 +70,7 @@ constructor(
}
}
- fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
+ override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
val topic = message.topic ?: ""
Logger.d { "[mqttClientProxyMessage] $topic" }
val retained = message.retained == true
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 3574bf6e1..df19abacf 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -14,17 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.unknown_username
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
@@ -32,21 +33,21 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshNeighborInfoHandler
+class NeighborInfoHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
- private val commandSender: MeshCommandSender,
- private val serviceBroadcasts: MeshServiceBroadcasts,
-) {
+ private val commandSender: CommandSender,
+ private val serviceBroadcasts: ServiceBroadcasts,
+) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- fun handleNeighborInfo(packet: MeshPacket) {
+ override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
@@ -58,7 +59,7 @@ constructor(
}
// Update Node DB
- nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) }
+ nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
@@ -67,11 +68,11 @@ constructor(
val neighbors =
ni.neighbors.joinToString("\n") { n ->
val node = nodeManager.nodeDBbyNodeNum[n.node_id]
- val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username)
+ val name = node?.let { "${it.user.long_name} (${it.user.short_name})" } ?: "Unknown"
"• $name (SNR: ${n.snr})"
}
- val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.longName ?: "Unknown"}:\n$neighbors"
+ val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.user?.long_name ?: "Unknown"}:\n$neighbors"
val responseText =
if (start != null) {
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
new file mode 100644
index 000000000..e9172809b
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -0,0 +1,316 @@
+/*
+ * 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.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import okio.ByteString
+import org.meshtastic.core.common.util.handledLaunch
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.DeviceMetrics
+import org.meshtastic.core.model.EnvironmentMetrics
+import org.meshtastic.core.model.MeshUser
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeInfo
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.Paxcount
+import org.meshtastic.proto.StatusMessage
+import org.meshtastic.proto.Telemetry
+import org.meshtastic.proto.User
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
+import org.meshtastic.proto.Position as ProtoPosition
+
+/**
+ * Implementation of [NodeManager] that maintains an in-memory database of the mesh.
+ *
+ * This component acts as the "brain" for node-related data during a connection session. It manages:
+ * 1. In-memory maps for fast node lookup by number or ID.
+ * 2. Synchronization of node data between the radio and the persistent database.
+ * 3. Processing of incoming node-related packets (User, Position, Telemetry).
+ * 4. Broadcasting changes to the rest of the application.
+ */
+@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
+@Singleton
+class NodeManagerImpl
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
+ private val serviceNotifications: MeshServiceNotifications,
+) : NodeManager {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ override val nodeDBbyNodeNum = ConcurrentHashMap()
+ override val nodeDBbyID = ConcurrentHashMap()
+
+ override val isNodeDbReady = MutableStateFlow(false)
+ override val allowNodeDbWrites = MutableStateFlow(false)
+
+ override fun setNodeDbReady(ready: Boolean) {
+ isNodeDbReady.value = ready
+ }
+
+ override fun setAllowNodeDbWrites(allowed: Boolean) {
+ allowNodeDbWrites.value = allowed
+ }
+
+ override var myNodeNum: Int? = null
+
+ override fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ companion object {
+ private const val TIME_MS_TO_S = 1000L
+ }
+
+ override fun loadCachedNodeDB() {
+ scope.handledLaunch {
+ val nodes = nodeRepository.nodeDBbyNum.first()
+ nodeDBbyNodeNum.putAll(nodes)
+ nodes.values.forEach { nodeDBbyID[it.user.id] = it }
+ myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ }
+ }
+
+ override fun clear() {
+ nodeDBbyNodeNum.clear()
+ nodeDBbyID.clear()
+ isNodeDbReady.value = false
+ allowNodeDbWrites.value = false
+ myNodeNum = null
+ }
+
+ override fun getMyNodeInfo(): MyNodeInfo? {
+ val mi = nodeRepository.myNodeInfo.value ?: return null
+ val myNode = nodeDBbyNodeNum[mi.myNodeNum]
+ return MyNodeInfo(
+ myNodeNum = mi.myNodeNum,
+ hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
+ model = mi.model ?: myNode?.user?.hw_model?.name,
+ firmwareVersion = mi.firmwareVersion,
+ couldUpdate = mi.couldUpdate,
+ shouldUpdate = mi.shouldUpdate,
+ currentPacketId = mi.currentPacketId,
+ messageTimeoutMsec = mi.messageTimeoutMsec,
+ minAppVersion = mi.minAppVersion,
+ maxChannels = mi.maxChannels,
+ hasWifi = mi.hasWifi,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = mi.deviceId ?: myNode?.user?.id,
+ )
+ }
+
+ override fun getMyId(): String {
+ val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
+ return nodeDBbyNodeNum[num]?.user?.id ?: ""
+ }
+
+ override fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
+
+ override fun removeByNodenum(nodeNum: Int) {
+ nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
+ }
+
+ fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) {
+ val userId = DataPacket.nodeNumToDefaultId(n)
+ val defaultUser =
+ User(
+ id = userId,
+ long_name = "Meshtastic ${userId.takeLast(n = 4)}",
+ short_name = userId.takeLast(n = 4),
+ hw_model = HardwareModel.UNSET,
+ )
+
+ Node(num = n, user = defaultUser, channel = channel)
+ }
+
+ override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
+ val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
+ val next = transform(current)
+ nodeDBbyNodeNum[nodeNum] = next
+ if (next.user.id.isNotEmpty()) {
+ nodeDBbyID[next.user.id] = next
+ }
+
+ if (next.user.id.isNotEmpty() && isNodeDbReady.value) {
+ scope.handledLaunch { nodeRepository.upsert(next) }
+ }
+
+ if (withBroadcast) {
+ serviceBroadcasts.broadcastNodeChange(next)
+ }
+ }
+
+ override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
+ updateNode(fromNum) { node ->
+ val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET)
+ val shouldPreserve = shouldPreserveExistingUser(node.user, p)
+
+ val next =
+ if (shouldPreserve) {
+ node.copy(channel = channel, manuallyVerified = manuallyVerified)
+ } else {
+ val keyMatch = !node.hasPKC || node.user.public_key == p.public_key
+ val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
+ node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
+ }
+ if (newNode && !shouldPreserve) {
+ serviceNotifications.showNewNodeSeenNotification(next)
+ }
+ next
+ }
+ }
+
+ override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) {
+ if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
+ Logger.d { "Ignoring nop position update for the local node" }
+ } else {
+ updateNode(fromNum) { node ->
+ node.copy(position = p.copy(time = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt()))
+ }
+ }
+ }
+
+ override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
+ updateNode(fromNum) { node ->
+ when {
+ telemetry.device_metrics != null -> node.copy(deviceMetrics = telemetry.device_metrics!!)
+ telemetry.environment_metrics != null -> node.copy(environmentMetrics = telemetry.environment_metrics!!)
+ telemetry.power_metrics != null -> node.copy(powerMetrics = telemetry.power_metrics!!)
+ else -> node
+ }
+ }
+ }
+
+ override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
+ updateNode(fromNum) { it.copy(paxcounter = p) }
+ }
+
+ override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
+ updateNodeStatus(fromNum, s.status)
+ }
+
+ override fun updateNodeStatus(nodeNum: Int, status: String?) {
+ updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) }
+ }
+
+ override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) {
+ updateNode(info.num, withBroadcast = withBroadcast) { node ->
+ var next = node
+ val user = info.user
+ if (user != null) {
+ if (shouldPreserveExistingUser(node.user, user)) {
+ // keep existing names
+ } else {
+ var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
+ if (info.via_mqtt) {
+ newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
+ }
+ next = next.copy(user = newUser)
+ }
+ }
+ val position = info.position
+ if (position != null) {
+ next = next.copy(position = position)
+ }
+ next =
+ next.copy(
+ lastHeard = info.last_heard,
+ deviceMetrics = info.device_metrics ?: next.deviceMetrics,
+ channel = info.channel,
+ viaMqtt = info.via_mqtt,
+ hopsAway = info.hops_away ?: -1,
+ isFavorite = info.is_favorite,
+ isIgnored = info.is_ignored,
+ isMuted = info.is_muted,
+ )
+ next
+ }
+ }
+
+ override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
+ scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) }
+ }
+
+ private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
+ val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
+ val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
+ val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
+ return hasExistingUser && isDefaultName && isDefaultHwModel
+ }
+
+ override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
+ DataPacket.ID_BROADCAST
+ } else {
+ nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
+ }
+
+ private fun Node.toNodeInfo(): NodeInfo = NodeInfo(
+ num = num,
+ user =
+ MeshUser(
+ id = user.id,
+ longName = user.long_name,
+ shortName = user.short_name,
+ hwModel = user.hw_model,
+ role = user.role.value,
+ ),
+ position =
+ Position(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = position.altitude ?: 0,
+ time = position.time,
+ satellitesInView = position.sats_in_view ?: 0,
+ groundSpeed = position.ground_speed ?: 0,
+ groundTrack = position.ground_track ?: 0,
+ precisionBits = position.precision_bits ?: 0,
+ )
+ .takeIf { latitude != 0.0 || longitude != 0.0 },
+ snr = snr,
+ rssi = rssi,
+ lastHeard = lastHeard,
+ deviceMetrics =
+ DeviceMetrics(
+ batteryLevel = deviceMetrics.battery_level ?: 0,
+ voltage = deviceMetrics.voltage ?: 0f,
+ channelUtilization = deviceMetrics.channel_utilization ?: 0f,
+ airUtilTx = deviceMetrics.air_util_tx ?: 0f,
+ uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
+ ),
+ channel = channel,
+ environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
+ hopsAway = hopsAway,
+ nodeStatus = nodeStatus,
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index d85edd7ad..a29cfed98 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -14,10 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -30,13 +29,18 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
@@ -51,18 +55,18 @@ import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
-class PacketHandler
+class PacketHandlerImpl
@Inject
constructor(
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy,
- private val connectionStateHolder: ConnectionStateHandler,
-) {
+ private val serviceRepository: ServiceRepository,
+) : PacketHandler {
companion object {
- private val TIMEOUT = 5.seconds // Increased from 250ms to be more tolerant
+ private val TIMEOUT = 5.seconds
}
private var queueJob: Job? = null
@@ -71,15 +75,11 @@ constructor(
private val queuedPackets = ConcurrentLinkedQueue()
private val queueResponse = ConcurrentHashMap>()
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- /**
- * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully
- * bound to the RadioInterfaceService
- */
- fun sendToRadio(p: ToRadio) {
+ override fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
@@ -94,7 +94,7 @@ constructor(
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),
- fromNum = MeshLog.NODE_NUM_LOCAL, // Outgoing packets are always from the local node
+ fromNum = MeshLog.NODE_NUM_LOCAL,
portNum = packet.decoded?.portnum?.value ?: 0,
fromRadio = FromRadio(packet = packet),
)
@@ -102,16 +102,12 @@ constructor(
}
}
- /**
- * Send a mesh packet to the radio, if the radio is not currently connected this function will throw
- * NotConnectedException
- */
- fun sendToRadio(packet: MeshPacket) {
+ override fun sendToRadio(packet: MeshPacket) {
queuedPackets.add(packet)
startPacketQueue()
}
- fun stopPacketQueue() {
+ override fun stopPacketQueue() {
if (queueJob?.isActive == true) {
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
@@ -122,33 +118,30 @@ constructor(
}
}
- fun handleQueueStatus(queueStatus: QueueStatus) {
+ override fun handleQueueStatus(queueStatus: QueueStatus) {
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
- if (success && isFull) return // Queue is full, wait for free != 0
+ if (success && isFull) return
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
- // This is slightly suboptimal but matches legacy behavior for packets without IDs
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
- fun removeResponse(dataRequestId: Int, complete: Boolean) {
+ override fun removeResponse(dataRequestId: Int, complete: Boolean) {
queueResponse.remove(dataRequestId)?.complete(complete)
}
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
private fun startPacketQueue() {
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
- while (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
- // take the first packet from the queue head
+ while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queuedPackets.poll() ?: break
+ @Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
- // send packet to the radio and wait for response
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
@@ -164,7 +157,6 @@ constructor(
}
}
- /** Change the status on a DataPacket and update watchers */
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
if (packetId != 0) {
getDataPacketById(packetId)?.let { p ->
@@ -175,11 +167,10 @@ constructor(
}
}
- @Suppress("MagicNumber")
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) {
var dataPacket: DataPacket? = null
while (dataPacket == null) {
- dataPacket = packetRepository.get().getPacketById(packetId)?.data
+ dataPacket = packetRepository.get().getPacketById(packetId)
if (dataPacket == null) delay(100.milliseconds)
}
dataPacket
@@ -187,17 +178,14 @@ constructor(
@Suppress("TooGenericExceptionCaught")
private fun sendPacket(packet: MeshPacket): CompletableDeferred {
- // send the packet to the radio and return a CompletableDeferred that will be completed with
- // the result
val deferred = CompletableDeferred()
queueResponse[packet.id] = deferred
try {
- if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio(packet = packet))
} catch (ex: RadioNotConnectedException) {
- // Expected when radio is not connected, log as warning to avoid Crashlytics noise
Logger.w(ex) { "sendToRadio skipped: Not connected to radio" }
deferred.complete(false)
} catch (ex: Exception) {
@@ -209,8 +197,6 @@ constructor(
private fun insertMeshLog(packetToSave: MeshLog) {
scope.handledLaunch {
- // Do not log, because might contain PII
-
Logger.d {
"insert: ${packetToSave.message_type} = " +
"${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}"
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
index 0ca3e3947..2524e8301 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
@@ -22,48 +22,47 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.traceroute_duration
-import org.meshtastic.core.resources.traceroute_route_back_to_us
-import org.meshtastic.core.resources.traceroute_route_towards_dest
-import org.meshtastic.core.resources.unknown_username
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.TracerouteResponse
+import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshTracerouteHandler
+class TracerouteHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
- private val commandSender: MeshCommandSender,
-) {
+ private val commandSender: CommandSender,
+) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
+ override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
val full =
packet.getFullTracerouteResponse(
getUser = { num ->
- nodeManager.nodeDBbyNodeNum[num]?.let { "${it.longName} (${it.shortName})" }
- ?: getString(Res.string.unknown_username)
+ nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
+ "${node.user.long_name} (${node.user.short_name})"
+ } ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
},
- headerTowards = getString(Res.string.traceroute_route_towards_dest),
- headerBack = getString(Res.string.traceroute_route_back_to_us),
+ headerTowards = "Route towards destination:",
+ headerBack = "Route back to us:",
) ?: return
val requestId = packet.decoded?.request_id ?: 0
@@ -87,7 +86,7 @@ constructor(
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
- val durationText = getString(Res.string.traceroute_duration, "%.1f".format(Locale.US, seconds))
+ val durationText = "Duration: %.1f s".format(Locale.US, seconds)
"$full\n\n$durationText"
} else {
full
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
similarity index 97%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index d189f19f7..d4901d02b 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -29,12 +29,13 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
+import org.meshtastic.core.repository.DeviceHardwareRepository
import javax.inject.Inject
import javax.inject.Singleton
// Annotating with Singleton to ensure a single instance manages the cache
@Singleton
-class DeviceHardwareRepository
+class DeviceHardwareRepositoryImpl
@Inject
constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
@@ -42,7 +43,7 @@ constructor(
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
private val dispatchers: CoroutineDispatchers,
-) {
+) : DeviceHardwareRepository {
/**
* Retrieves device hardware information by its model ID and optional target string.
@@ -59,10 +60,10 @@ constructor(
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
- suspend fun getDeviceHardwareByModel(
+ override suspend fun getDeviceHardwareByModel(
hwModel: Int,
- target: String? = null,
- forceRefresh: Boolean = false,
+ target: String?,
+ forceRefresh: Boolean,
): Result = withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
similarity index 67%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
index 53729ce48..a6af8c51e 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
@@ -40,13 +40,16 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
@@ -56,7 +59,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
-open class NodeRepository
+class NodeRepositoryImpl
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@@ -64,28 +67,29 @@ constructor(
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
private val localStatsDataSource: LocalStatsDataSource,
-) {
+) : NodeRepository {
/** Hardware info about our local device (can be null if not connected). */
- open val myNodeInfo: StateFlow =
+ override val myNodeInfo: StateFlow =
nodeInfoReadDataSource
.myNodeInfoFlow()
+ .map { it?.toMyNodeInfo() }
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
private val _ourNodeInfo = MutableStateFlow(null)
/** Information about the locally connected node, as seen from the mesh. */
- open val ourNodeInfo: StateFlow
+ override val ourNodeInfo: StateFlow
get() = _ourNodeInfo
private val _myId = MutableStateFlow(null)
/** The unique userId (hex string) of our local node. */
- val myId: StateFlow
+ override val myId: StateFlow
get() = _myId
/** The latest local stats telemetry received from the locally connected node. */
- val localStats: StateFlow =
+ override val localStats: StateFlow =
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
@@ -93,12 +97,12 @@ constructor(
)
/** Update the cached local stats telemetry. */
- fun updateLocalStats(stats: LocalStats) {
+ override fun updateLocalStats(stats: LocalStats) {
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
}
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
- val nodeDBbyNum: StateFlow