From 02e01bb3318dd254322ef4fc15e0777df1c8b8f8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 4 Mar 2026 11:34:46 -0600
Subject: [PATCH 001/407] ci: optimize, secure, and modernize CI pipeline
(#4711)
---
.github/workflows/merge-queue.yml | 1 -
.github/workflows/pull-request.yml | 74 +++++++++++++---------------
.github/workflows/release.yml | 8 +--
.github/workflows/reusable-check.yml | 55 ++++++++-------------
4 files changed, 58 insertions(+), 80 deletions(-)
diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml
index 27e532a26..06ecfa2c2 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -14,7 +14,6 @@ jobs:
uses: ./.github/workflows/reusable-check.yml
with:
api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
- flavors: '["google", "fdroid"]'
upload_artifacts: false
secrets: inherit
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 4e215d2dd..8ba00d417 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -1,72 +1,66 @@
+name: Pull Request CI
+
on:
pull_request:
- branches:
- - main
- workflow_dispatch:
+ branches: [ main, develop ]
+ paths-ignore:
+ - '**.md'
+ - 'docs/**'
+ - '.gitignore'
concurrency:
- group: build-pr-${{ github.ref }}
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
+ # 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-latest
outputs:
- code_changed: ${{ steps.filter.outputs.code }}
+ android: ${{ steps.filter.outputs.android }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
- code:
- - '**/*.kt'
- - '**/*.java'
- - '**/*.xml'
- - '**/*.kts'
- - '**/*.properties'
- - 'gradle/**'
- - 'gradlew'
- - 'gradlew.bat'
- - '**/src/**'
- - '.github/workflows/**'
+ android:
+ - 'app/**'
+ - 'core/**'
+ - 'feature/**'
+ - 'build-logic/**'
+ - 'build.gradle.kts'
+ - 'gradle.properties'
- android-check:
+ # 2. VALIDATION & BUILD: Delegate to reusable-check.yml
+ # We disable instrumented tests for PRs to keep feedback fast (< 10 mins).
+ validate-and-build:
needs: check-changes
- if: needs.check-changes.outputs.code_changed == 'true'
+ if: needs.check-changes.outputs.android == 'true'
uses: ./.github/workflows/reusable-check.yml
with:
- api_levels: '[35]' # Only test latest API on PRs for speed
- flavors: '["google","fdroid"]'
+ run_lint: true
+ run_unit_tests: true
+ run_instrumented_tests: false
+ api_levels: '[35]'
+ upload_artifacts: true
secrets: inherit
- skip-notice:
- needs: check-changes
- if: needs.check-changes.outputs.code_changed != 'true'
- runs-on: ubuntu-latest
- steps:
- - name: Skip CI for non-code changes
- run: echo "Skipping CI - no code changes detected (docs/config only)"
-
+ # 3. WORKFLOW STATUS: Ensures required checks are satisfied
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-latest
- needs:
- - check-changes
- - android-check
+ needs: [check-changes, validate-and-build]
if: always()
steps:
- name: Check Workflow Status
run: |
- if [[ "${{ needs.check-changes.outputs.code_changed }}" != "true" ]]; then
- echo "No code changes - CI jobs skipped as expected"
- exit 0
- fi
-
- if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
+ # If changes were detected but build failed, fail the status check
+ if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then
echo "::error::Android Check failed"
exit 1
fi
-
- echo "All jobs passed successfully"
+
+ # If no changes were detected, this still succeeds to satisfy required status check
+ echo "Workflow status satisfied."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d67a5f665..3f69ded64 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,6 +44,10 @@ on:
required: false
GRADLE_CACHE_PASSWORD:
required: false
+ INTERNAL_BUILDS_HOST:
+ required: false
+ INTERNAL_BUILDS_HOST_PAT:
+ required: false
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag_name }}
@@ -62,7 +66,6 @@ jobs:
run_lint: true
run_unit_tests: false
run_instrumented_tests: false
- flavors: '["google"]'
upload_artifacts: false
secrets: inherit
@@ -72,7 +75,6 @@ jobs:
APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@@ -120,7 +122,6 @@ jobs:
needs: [prepare-build-info, run-lint]
environment: Release
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
@@ -212,7 +213,6 @@ jobs:
needs: [prepare-build-info, run-lint]
environment: Release
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index e480374fd..e55d58b2f 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -12,15 +12,9 @@ on:
run_instrumented_tests:
type: boolean
default: true
- flavors:
- type: string
- default: '["google"]'
api_levels:
type: string
default: '[35]'
- num_shards:
- type: number
- default: 1
upload_artifacts:
type: boolean
default: true
@@ -45,14 +39,14 @@ on:
jobs:
check:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
timeout-minutes: 60
strategy:
fail-fast: true
matrix:
api_level: ${{ fromJson(inputs.api_levels) }}
- flavor: ${{ fromJson(inputs.flavors) }}
env:
- GRADLE_OPTS: "-Dorg.gradle.daemon=false"
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
@@ -67,16 +61,21 @@ jobs:
fetch-depth: 0
submodules: 'recursive'
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v4
+
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
- distribution: 'jetbrains'
+ distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
+ dependency-graph: generate-and-submit
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache-cleanup: true
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
@@ -89,40 +88,26 @@ jobs:
- name: Determine Tasks
id: tasks
run: |
- 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 }}"')
# 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 "
+ TASKS="assembleDebug "
+ [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS lintDebug "
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS connectedGoogleDebugAndroidTest "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS connectedFdroidDebugAndroidTest "
- fi
- fi
-
- # Run coverage report for this flavor
- if [ "${{ inputs.run_unit_tests }}" = "true" ]; then
- TASKS="$TASKS koverXmlReport${FLAVOR_CAP}Debug "
+ TASKS="$TASKS connectedDebugAndroidTest "
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'
+ if: steps.tasks.outputs.is_first_api == '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
+ if: steps.tasks.outputs.is_first_api == 'true' && inputs.run_unit_tests == true
run: ./gradlew testDebugUnitTest koverXmlReportDebug -Pci=true --continue
- name: Enable KVM group perms
@@ -143,13 +128,13 @@ jobs:
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
- script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
+ script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --parallel --configuration-cache --continue --scan
- name: Run Flavor Check (no Emulator)
if: inputs.run_instrumented_tests == false
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
- run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
+ run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --parallel --configuration-cache --continue --scan
- name: Upload coverage results to Codecov
if: ${{ !cancelled() }}
@@ -169,10 +154,10 @@ jobs:
- name: Upload debug artifact
if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v4
with:
- name: ${{ matrix.flavor }}Debug
- path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk
+ name: app-debug-apks
+ path: app/build/outputs/apk/*/debug/*.apk
retention-days: 14
- name: Report App Size
@@ -185,9 +170,9 @@ jobs:
- name: Upload reports
if: ${{ always() && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@v4
with:
- name: reports-${{ matrix.flavor }}-api-${{ matrix.api_level }}
+ name: reports-api-${{ matrix.api_level }}
path: |
**/build/reports
**/build/test-results
From 5b43dcb63680768334588c47558676b0fe304432 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 4 Mar 2026 15:12:54 -0600
Subject: [PATCH 002/407] chore(deps): update actions/checkout action to v6
(#4712)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/pull-request.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 8ba00d417..e227d848b 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -20,7 +20,7 @@ jobs:
outputs:
android: ${{ steps.filter.outputs.android }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
From 1e0613d5200d9af45499639eae8fb53b5809b8c1 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 4 Mar 2026 21:13:13 +0000
Subject: [PATCH 003/407] chore(deps): update gradle/actions action to v5
(#4715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/reusable-check.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index e55d58b2f..4b63bb9b3 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -62,7 +62,7 @@ jobs:
submodules: 'recursive'
- name: Validate Gradle Wrapper
- uses: gradle/actions/wrapper-validation@v4
+ uses: gradle/actions/wrapper-validation@v5
- name: Set up JDK 17
uses: actions/setup-java@v5
From 3e986032a5ebee5350cd3f42ea15e589896a119c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 4 Mar 2026 15:14:18 -0600
Subject: [PATCH 004/407] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4709)
---
.../composeResources/values-bg/strings.xml | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 7954340a3..ec95233ae 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -255,6 +255,7 @@
Директно съобщение
Нулиране на базата данни с възли
Съобщението е доставено
+ Устройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките.
Грешка
Игнорирай
Премахване от игнорирани
@@ -545,6 +546,7 @@
Дълго име
Кратко име
Модел на хардуера
+ Лицензиран радиолюбител (Ham)
Активирането на тази опция дезактивира криптирането и не е съвместимо с мрежата Meshtastic по подразбиране.
Точка на оросяване
Налягане
@@ -671,6 +673,7 @@
Въведете съобщение
PAX
WiFi устройства
+ Bluetooth устройства
Сдвоени устройства
Свързано устройство
Преглед на изданието
@@ -682,6 +685,7 @@
Версия на фърмуера
Скорошни мрежови устройства
Открити мрежови устройства
+ Налични Bluetooth устройства
Започнете
Добре дошли в
Останете свързани навсякъде
@@ -752,6 +756,7 @@
Системни настройки
Няма налична статистика
Анализите се събират, за да ни помогнат да подобрим приложението за Android (благодарим ви). Ще получаваме анонимизирана информация за поведението на потребителите. Това включва отчети за сривове, екрани, използвани в приложението и др.
+ Аналитични платформи:
За повече информация вижте нашата политика за поверителност.
Не е зададен - 0
Препредадено от: %1$s
@@ -831,6 +836,14 @@
Назад
Не е зададен
Винаги включен
+
+ - %1$d секунда
+ - %1$d секунди
+
+
+ - %1$d минута
+ - %1$d минути
+
- %1$d час
- %1$d часа
@@ -853,6 +866,7 @@
Зареждане
Активиране на филтрирането
+ Съобщенията, съдържащи тези думи, ще бъдат скрити
%1$d филтрирани
Показване на %1$d филтрирани
Скриване на %1$d филтрирани
@@ -863,10 +877,45 @@
Генериране на QR код
Всички
Bluetooth
+ Конфигуриране на разрешения за Bluetooth
+ Намерете и идентифицирайте устройства Meshtastic близо до вас.
Конфигурация
+ Управлявайте безжично настройките и каналите на вашето устройство.
+ Избор на стил на картата
+ Батерия: %1$d%%
+ Възли: %1$d онлайн / %2$d общо
+ Време на работа: %1$s
+ Трафик: TX %1$d / RX %2$d (D: %3$d)
+ %1$d / %2$d
+ Опресняване
+ Добавяне на мрежов слой
+ Опресняване на слоя
+ Конфигурация на TAK
+ Цвят на екипа
+ Роля на члена
+ Неопределен
+ Бял
+ Жълт
+ Оранжев
+ Магента
Червен
+ Кестеняв
+ Лилав
+ Тъмно син
Син
+ Циан
+ Тийл
Зелен
+ Тъмно зелен
+ Кафяв
+ Неопределена
+ Член на екипа
+ Ръководител на екипа
+ Снайперист
+ Медик
+ Радиотелефонен оператор
+ Управление на трафика
Модулът е активиран
+ Максимален брой отскоци за директен отговор
From 5fc7e46c29b98fba3cb09b10d29a0609908d9624 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 4 Mar 2026 15:26:16 -0600
Subject: [PATCH 005/407] chore(deps): update actions/upload-artifact action to
v7 (#4714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/reusable-check.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 4b63bb9b3..9805e1a17 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -154,7 +154,7 @@ jobs:
- name: Upload debug artifact
if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk
@@ -170,7 +170,7 @@ jobs:
- name: Upload reports
if: ${{ always() && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: reports-api-${{ matrix.api_level }}
path: |
From 6a1a612c38b86f6969d1fa14859a37babd2dee6d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 06:57:22 -0600
Subject: [PATCH 006/407] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4716)
---
app/src/main/assets/firmware_releases.json | 6 ------
1 file changed, 6 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 9d8dd9940..c749a8279 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -205,12 +205,6 @@
"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
From b0258d0cf14f985bf480c961fa0fc557526e29da Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 12:18:34 -0600
Subject: [PATCH 007/407] feat: Add "Mark all as read" and unread message count
indicators (#4720)
---
.../data/repository/PacketRepositoryImpl.kt | 6 ++
.../core/database/dao/PacketDaoTest.kt | 20 +++++++
.../meshtastic/core/database/dao/PacketDao.kt | 29 +++++++--
.../core/repository/PacketRepository.kt | 6 ++
.../org/meshtastic/core/ui/icon/Actions.kt | 4 ++
.../meshtastic/feature/messaging/Message.kt | 59 ++++++++++++++-----
.../feature/messaging/MessageViewModel.kt | 10 +++-
.../feature/messaging/UnreadUiDefaults.kt | 5 +-
.../feature/messaging/ui/contact/Contacts.kt | 14 ++++-
.../messaging/ui/contact/ContactsViewModel.kt | 4 ++
10 files changed, 131 insertions(+), 26 deletions(-)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index e29c82be1..7164d6876 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -77,6 +77,9 @@ constructor(
override suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) }
+ override fun getUnreadCountFlow(contact: String): Flow =
+ dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(contact) }
+
override fun getFirstUnreadMessageUuid(contact: String): Flow =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) }
@@ -89,6 +92,9 @@ constructor(
override suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) }
+ override suspend fun clearAllUnreadCounts() =
+ withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() }
+
override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
diff --git a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt
index 71bd06e24..a75bfa07c 100644
--- a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt
+++ b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt
@@ -158,6 +158,26 @@ class PacketDaoTest {
}
}
+ @Test
+ fun test_getUnreadCount_excludesFiltered() = runBlocking {
+ val filteredContactKey = "0!filteredonly"
+ val filteredPacket =
+ Packet(
+ uuid = 0L,
+ myNodeNum = myNodeNum,
+ port_num = 1,
+ contact_key = filteredContactKey,
+ received_time = nowMillis,
+ read = false,
+ filtered = true,
+ data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
+ )
+ packetDao.insert(filteredPacket)
+
+ val unreadCount = packetDao.getUnreadCount(filteredContactKey)
+ assertEquals(0, unreadCount)
+ }
+
@Test
fun test_clearUnreadCount() = runBlocking {
val timestamp = nowMillis
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 047b2b47c..f8d6947ad 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -94,16 +94,25 @@ interface PacketDao {
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
- AND port_num = 1 AND contact_key = :contact AND read = 0
+ AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0
""",
)
suspend fun getUnreadCount(contact: String): Int
+ @Query(
+ """
+ SELECT COUNT(*) FROM packet
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0
+ """,
+ )
+ fun getUnreadCountFlow(contact: String): Flow
+
@Query(
"""
SELECT uuid FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
- AND port_num = 1 AND contact_key = :contact AND read = 0
+ AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0
ORDER BY received_time ASC
LIMIT 1
""",
@@ -114,7 +123,7 @@ interface PacketDao {
"""
SELECT COUNT(*) > 0 FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
- AND port_num = 1 AND contact_key = :contact AND read = 0
+ AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0
""",
)
fun hasUnreadMessages(contact: String): Flow
@@ -123,7 +132,7 @@ interface PacketDao {
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
- AND port_num = 1 AND read = 0
+ AND port_num = 1 AND read = 0 AND filtered = 0
""",
)
fun getUnreadCountTotal(): Flow
@@ -133,11 +142,21 @@ interface PacketDao {
UPDATE packet
SET read = 1
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
- AND port_num = 1 AND contact_key = :contact AND read = 0 AND received_time <= :timestamp
+ AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 AND received_time <= :timestamp
""",
)
suspend fun clearUnreadCount(contact: String, timestamp: Long)
+ @Query(
+ """
+ UPDATE packet
+ SET read = 1
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND port_num = 1 AND read = 0 AND filtered = 0
+ """,
+ )
+ suspend fun clearAllUnreadCounts()
+
@Upsert suspend fun insert(packet: Packet)
@Transaction
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index c43d559c4..6b5d545b1 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -49,6 +49,9 @@ interface PacketRepository {
/** Returns the count of unread messages in a conversation. */
suspend fun getUnreadCount(contact: String): Int
+ /** Reactive flow of the unread message count in a conversation. */
+ fun getUnreadCountFlow(contact: String): Flow
+
/** Reactive flow of the UUID of the first unread message in a conversation. */
fun getFirstUnreadMessageUuid(contact: String): Flow
@@ -61,6 +64,9 @@ interface PacketRepository {
/** Clears the unread status for messages in a conversation up to the given timestamp. */
suspend fun clearUnreadCount(contact: String, timestamp: Long)
+ /** Clears the unread status for all messages across all conversations. */
+ suspend fun clearAllUnreadCounts()
+
/** Updates the identifier of the last read message in a conversation. */
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
index c58056d76..3506605e3 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.MarkChatRead
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material.icons.rounded.Refresh
@@ -81,5 +82,8 @@ val MeshtasticIcons.SelectAll: ImageVector
val MeshtasticIcons.ThumbUp: ImageVector
get() = Icons.Rounded.ThumbUp
+val MeshtasticIcons.MarkChatRead: ImageVector
+ get() = Icons.Rounded.MarkChatRead
+
val MeshtasticIcons.QrCode2: ImageVector
get() = Icons.Rounded.QrCode2
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 1f5c24626..c28a07792 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
@@ -61,6 +61,8 @@ import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.material.icons.rounded.SpeakerNotesOff
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -222,6 +224,7 @@ fun MessageScreen(
// Track unread messages using lightweight metadata queries
val hasUnreadMessages by viewModel.hasUnreadMessages.collectAsStateWithLifecycle()
+ val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle()
val firstUnreadMessageUuid by viewModel.firstUnreadMessageUuid.collectAsStateWithLifecycle()
var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) }
@@ -231,21 +234,36 @@ fun MessageScreen(
remember(pagedMessages.itemCount, firstUnreadMessageUuid) {
derivedStateOf {
firstUnreadMessageUuid?.let { uuid ->
- (0 until pagedMessages.itemCount).firstOrNull { index -> pagedMessages[index]?.uuid == uuid }
+ pagedMessages.itemSnapshotList.indexOfFirst { it?.uuid == uuid }.takeIf { it != -1 }
}
}
}
// Scroll to first unread message on initial load
- LaunchedEffect(hasPerformedInitialScroll, firstUnreadIndex, pagedMessages.itemCount) {
+ LaunchedEffect(
+ hasPerformedInitialScroll,
+ firstUnreadIndex,
+ pagedMessages.itemCount,
+ hasUnreadMessages,
+ firstUnreadMessageUuid,
+ ) {
if (hasPerformedInitialScroll || pagedMessages.itemCount == 0) return@LaunchedEffect
+ if (hasUnreadMessages == null) return@LaunchedEffect // Wait for DB state to initialize
- val shouldScrollToUnread = hasUnreadMessages && firstUnreadIndex != null
- if (shouldScrollToUnread) {
- val targetIndex = (firstUnreadIndex!! - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0)
- listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex)
- hasPerformedInitialScroll = true
- } else if (!hasUnreadMessages) {
+ if (hasUnreadMessages == true) {
+ if (firstUnreadMessageUuid == null) return@LaunchedEffect // Wait for UUID query
+
+ if (firstUnreadIndex != null) {
+ val targetIndex = (firstUnreadIndex!! - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0)
+ listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex)
+ hasPerformedInitialScroll = true
+ } else {
+ // The first unread message is deeper than the currently loaded pages.
+ // Scroll to the end of the loaded items to trigger the next page load.
+ // This will re-trigger this LaunchedEffect until we find the message.
+ listState.scrollToItem(pagedMessages.itemCount - 1)
+ }
+ } else {
// If no unread messages, just scroll to bottom (most recent)
listState.scrollToItem(0)
hasPerformedInitialScroll = true
@@ -410,7 +428,7 @@ fun MessageScreen(
selectedIds = selectedMessageIds,
contactKey = contactKey,
firstUnreadMessageUuid = firstUnreadMessageUuid,
- hasUnreadMessages = hasUnreadMessages,
+ hasUnreadMessages = hasUnreadMessages == true,
filteredCount = filteredCount,
showFiltered = showFiltered,
filteringDisabled = filteringDisabled,
@@ -430,7 +448,7 @@ fun MessageScreen(
)
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
- ScrollToBottomFab(coroutineScope, listState)
+ ScrollToBottomFab(coroutineScope, listState, unreadCount)
}
}
}
@@ -441,9 +459,11 @@ fun MessageScreen(
*
* @param coroutineScope The coroutine scope for launching the scroll animation.
* @param listState The [LazyListState] of the message list.
+ * @param unreadCount The number of unread messages to display as a badge.
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState) {
+private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState, unreadCount: Int) {
FloatingActionButton(
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
onClick = {
@@ -453,10 +473,19 @@ private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState
}
},
) {
- Icon(
- imageVector = Icons.Rounded.ArrowDownward,
- contentDescription = stringResource(Res.string.scroll_to_bottom),
- )
+ if (unreadCount > 0) {
+ BadgedBox(badge = { Badge { Text(unreadCount.toString()) } }) {
+ Icon(
+ imageVector = Icons.Rounded.ArrowDownward,
+ contentDescription = stringResource(Res.string.scroll_to_bottom),
+ )
+ }
+ } else {
+ Icon(
+ imageVector = Icons.Rounded.ArrowDownward,
+ contentDescription = stringResource(Res.string.scroll_to_bottom),
+ )
+ }
}
}
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 d7abd4474..a767eaee0 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
@@ -127,11 +127,17 @@ constructor(
.flatMapLatest { packetRepository.getFirstUnreadMessageUuid(it) }
.stateInWhileSubscribed(null)
- val hasUnreadMessages: StateFlow =
+ val hasUnreadMessages: StateFlow =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.hasUnreadMessages(it) }
- .stateInWhileSubscribed(false)
+ .stateInWhileSubscribed(null)
+
+ val unreadCount: StateFlow =
+ contactKeyForPagedMessages
+ .filterNotNull()
+ .flatMapLatest { packetRepository.getUnreadCountFlow(it) }
+ .stateInWhileSubscribed(0)
val filteredCount: StateFlow =
contactKeyForPagedMessages
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt
index 2c65b947c..0cdb7c50a 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.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.feature.messaging
/**
@@ -45,5 +44,5 @@ internal object UnreadUiDefaults {
* A longer debounce prevents thrashing the database during quick scrubs yet still feels responsive once the user
* settles on a position.
*/
- const val SCROLL_DEBOUNCE_MILLIS = 3_000L
+ const val SCROLL_DEBOUNCE_MILLIS = 500L
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index f256e23e2..82348cc07 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -80,6 +80,7 @@ import org.meshtastic.core.resources.currently
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_selection
+import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
@@ -99,6 +100,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.MarkChatRead
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
@@ -235,7 +237,17 @@ fun ContactsScreen(
showNodeChip = ourNode != null && connectionState.isConnected(),
canNavigateUp = false,
onNavigateUp = {},
- actions = {},
+ actions = {
+ val unreadCountTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle(0)
+ if (unreadCountTotal > 0) {
+ IconButton(onClick = { viewModel.markAllAsRead() }) {
+ Icon(
+ MeshtasticIcons.MarkChatRead,
+ contentDescription = stringResource(Res.string.mark_as_read),
+ )
+ }
+ }
+ },
onClickChip = { onClickNodeChip(it.num) },
)
},
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
index 2b645bac2..595e4a1e4 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
@@ -55,6 +55,8 @@ constructor(
val connectionState = serviceRepository.connectionState
+ val unreadCountTotal = packetRepository.getUnreadCountTotal().stateInWhileSubscribed(0)
+
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
// Combine node info and myId to reduce argument count in subsequent combines
@@ -192,6 +194,8 @@ constructor(
fun deleteContacts(contacts: List) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
+ fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() }
+
fun setMuteUntil(contacts: List, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
From af3f36b64869d6efeb190cbe1b7631491fd835fc Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 12:18:41 -0600
Subject: [PATCH 008/407] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4719)
---
app/src/main/assets/firmware_releases.json | 6 ++++++
.../src/commonMain/composeResources/values-el/strings.xml | 1 +
2 files changed, 7 insertions(+)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index c749a8279..9074bfdbc 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -188,6 +188,12 @@
]
},
"pullRequests": [
+ {
+ "id": "9827",
+ "title": "Align 920–925 MHz limits as per NBTC regulations in Thailand (27 dBm, 10% duty cycle) ",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9827",
+ "zip_url": "https://discord.com/invite/meshtastic"
+ },
{
"id": "9798",
"title": "Attempt to fix issue 9713",
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index 25abf61a7..3c25faa10 100644
--- a/core/resources/src/commonMain/composeResources/values-el/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml
@@ -26,6 +26,7 @@
Λήξη χρονικού ορίου
Εσφαλμένο Αίτημα
Άγνωστο Δημόσιο Κλειδί
+ Πελάτης Βάση
Όνομα Καναλιού
Κώδικας QR
From c1309545eaeb4253281c6e0e3d1a86b01bd17615 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 5 Mar 2026 12:18:52 -0600
Subject: [PATCH 009/407] chore(deps): update core/proto/src/main/proto digest
to 2edc5ab (#4717)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index a229208f2..2edc5ab7b 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit a229208f29a59cf1d8cfa24cbb7567a08f2d1771
+Subproject commit 2edc5ab7b16a34996396c4fef691f1465980fa50
From 5a5aa1f026514396e81d5aa35086e27a0678fb38 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 5 Mar 2026 12:46:01 -0600
Subject: [PATCH 010/407] chore(deps): update nordic.common to v2.9.2 (#4718)
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 e85055e89..710c9fda8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -59,7 +59,7 @@ wire = "6.0.0-alpha03"
vico = "3.0.2"
dependency-guard = "0.5.0"
nordic-ble = "2.0.0-alpha15"
-nordic-common = "2.9.1"
+nordic-common = "2.9.2"
[libraries]
From 68b2b6d88ea2db38b95a1816cfa9aa6b848b93e0 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 12:58:34 -0600
Subject: [PATCH 011/407] refactor(ble): improve connection lifecycle and
enhance OTA reliability (#4721)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../radio/AndroidRadioInterfaceService.kt | 19 +-
.../radio/MeshtasticRadioProfile.kt | 33 ++
.../radio/MeshtasticRadioServiceImpl.kt | 94 +++++
.../repository/radio/NordicBleInterface.kt | 327 ++++++------------
.../radio/NordicBleInterfaceRetryTest.kt | 5 +-
.../radio/NordicBleInterfaceTest.kt | 8 +-
core/ble/README.md | 33 +-
.../org/meshtastic/core/ble/BleConnection.kt | 132 ++++---
.../org/meshtastic/core/ble/BleError.kt | 135 --------
.../org/meshtastic/core/ble/BleModule.kt | 5 +-
.../org/meshtastic/core/ble/BleRetry.kt | 3 +-
.../core/ble/BluetoothRepository.kt | 28 +-
.../core/ble/MeshtasticBleConstants.kt | 11 +
.../core/ble/BluetoothRepositoryTest.kt | 33 ++
.../core/repository/RadioInterfaceService.kt | 5 +-
feature/firmware/README.md | 3 +-
.../feature/firmware/ota/BleOtaTransport.kt | 193 +++++++----
.../BleOtaTransportServiceDiscoveryTest.kt | 209 +++++++++++
gradle/libs.versions.toml | 2 +-
19 files changed, 741 insertions(+), 537 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioProfile.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioServiceImpl.kt
delete mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportServiceDiscoveryTest.kt
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
index cd190ad45..47230a08a 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
@@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.BuildUtils
@@ -89,8 +88,8 @@ constructor(
private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64)
override val receivedData: SharedFlow = _receivedData
- private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
- val connectionError: SharedFlow = _connectionError.asSharedFlow()
+ 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)
@@ -259,22 +258,16 @@ constructor(
}
}
- override fun onDisconnect(isPermanent: Boolean) {
+ override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
+ if (errorMessage != null) {
+ processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
+ }
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
}
- 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) */
private fun startInterface() {
if (radioIf !is NopInterface) {
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioProfile.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioProfile.kt
new file mode 100644
index 000000000..512b04fdd
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioProfile.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 com.geeksville.mesh.repository.radio
+
+import kotlinx.coroutines.flow.Flow
+
+/** A definition of the Meshtastic BLE Service profile. */
+interface MeshtasticRadioProfile {
+ interface State {
+ /** The flow of incoming packets from the radio. */
+ val fromRadio: Flow
+
+ /** The flow of incoming log packets from the radio. */
+ val logRadio: Flow
+
+ /** Sends a packet to the radio. */
+ suspend fun sendToRadio(packet: ByteArray)
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioServiceImpl.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioServiceImpl.kt
new file mode 100644
index 000000000..266df6651
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MeshtasticRadioServiceImpl.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.repository.radio
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.launch
+import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
+import no.nordicsemi.kotlin.ble.client.RemoteService
+import no.nordicsemi.kotlin.ble.core.WriteType
+import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
+import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
+import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
+import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
+import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+
+class MeshtasticRadioServiceImpl(private val remoteService: RemoteService) : MeshtasticRadioProfile.State {
+
+ private val toRadioCharacteristic: RemoteCharacteristic =
+ remoteService.characteristics.first { it.uuid == TORADIO_CHARACTERISTIC }
+ private val fromRadioCharacteristic: RemoteCharacteristic =
+ remoteService.characteristics.first { it.uuid == FROMRADIO_CHARACTERISTIC }
+ private val fromRadioSyncCharacteristic: RemoteCharacteristic? =
+ remoteService.characteristics.firstOrNull { it.uuid == FROMRADIOSYNC_CHARACTERISTIC }
+ private val fromNumCharacteristic: RemoteCharacteristic? =
+ if (fromRadioSyncCharacteristic == null) {
+ remoteService.characteristics.first { it.uuid == FROMNUM_CHARACTERISTIC }
+ } else {
+ null
+ }
+ private val logRadioCharacteristic: RemoteCharacteristic =
+ remoteService.characteristics.first { it.uuid == LOGRADIO_CHARACTERISTIC }
+
+ private val triggerDrain = MutableSharedFlow(extraBufferCapacity = 64)
+
+ init {
+ require(toRadioCharacteristic.isWritable()) { "TORADIO must be writable" }
+ require(fromRadioCharacteristic.isReadable()) { "FROMRADIO must be readable" }
+ fromRadioSyncCharacteristic?.let { require(it.isSubscribable()) { "FROMRADIOSYNC must be subscribable" } }
+ fromNumCharacteristic?.let { require(it.isSubscribable()) { "FROMNUM must be subscribable" } }
+ require(logRadioCharacteristic.isSubscribable()) { "LOGRADIO must be subscribable" }
+ }
+
+ override val fromRadio: Flow =
+ if (fromRadioSyncCharacteristic != null) {
+ fromRadioSyncCharacteristic.subscribe()
+ } else {
+ // Legacy path: drain fromRadio characteristic when notified or after write
+ channelFlow {
+ launch { fromNumCharacteristic!!.subscribe().collect { triggerDrain.tryEmit(Unit) } }
+
+ triggerDrain.collect {
+ var keepReading = true
+ while (keepReading) {
+ try {
+ val packet = fromRadioCharacteristic.read()
+ if (packet.isEmpty()) {
+ keepReading = false
+ } else {
+ send(packet)
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ co.touchlab.kermit.Logger.e(e) { "BLE: Failed to read from FROMRADIO" }
+ keepReading = false
+ }
+ }
+ }
+ }
+ }
+
+ override val logRadio: Flow = logRadioCharacteristic.subscribe()
+
+ override suspend fun sendToRadio(packet: ByteArray) {
+ toRadioCharacteristic.write(packet, WriteType.WITHOUT_RESPONSE)
+ if (fromRadioSyncCharacteristic == null) {
+ triggerDrain.tryEmit(Unit)
+ }
+ }
+}
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 aa72dfdd4..7e06206ba 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
@@ -20,41 +20,26 @@ import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
-import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.channelFlow
-import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withTimeout
-import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
-import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
-import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.BleConnection
-import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
-import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
-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.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
@@ -70,7 +55,11 @@ private val SCAN_TIMEOUT = 5.seconds
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
* https://github.com/NordicSemiconductor/Kotlin-BLE-Library.
*
- * This class is responsible for connecting to and communicating with a Meshtastic device over BLE.
+ * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
+ * - Bonding and discovery.
+ * - Automatic reconnection logic.
+ * - MTU and connection parameter monitoring.
+ * - Routing raw byte packets between the radio and [RadioInterfaceService].
*
* @param serviceScope The coroutine scope to use for launching coroutines.
* @param centralManager The central manager provided by Nordic BLE Library.
@@ -96,13 +85,13 @@ constructor(
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
- service.onDisconnect(error = BleError.from(throwable))
+ val (isPermanent, msg) = throwable.toDisconnectReason()
+ service.onDisconnect(isPermanent, errorMessage = msg)
}
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
- private val drainMutex: Mutex = Mutex()
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
@@ -111,66 +100,10 @@ constructor(
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
- private var toRadioCharacteristic: RemoteCharacteristic? = null
- private var fromNumCharacteristic: RemoteCharacteristic? = null
- private var fromRadioCharacteristic: RemoteCharacteristic? = null
- private var logRadioCharacteristic: RemoteCharacteristic? = null
- private var fromRadioSyncCharacteristic: RemoteCharacteristic? = null
-
init {
connect()
}
- // --- Packet Flow Management ---
-
- private fun fromRadioPacketFlow(): Flow = channelFlow {
- while (isActive) {
- val packet =
- try {
- fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() }
- } catch (e: InvalidAttributeException) {
- Logger.w(e) { "[$address] Attribute invalidated during read, clearing characteristics" }
- handleInvalidAttribute(e)
- null
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Error reading fromRadioCharacteristic (likely disconnected)" }
- null
- }
-
- if (packet == null) {
- Logger.d { "[$address] fromRadio queue drain complete or error reading characteristic" }
- break
- }
- send(packet)
- }
- }
-
- private fun dispatchPacket(packet: ByteArray) {
- packetsReceived++
- bytesReceived += packet.size
- Logger.d {
- "[$address] Dispatching packet to service.handleFromRadio() - " +
- "Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
- }
- try {
- service.handleFromRadio(packet)
- } catch (t: Throwable) {
- Logger.e(t) { "[$address] Failed to execute service.handleFromRadio()" }
- }
- }
-
- private suspend fun drainPacketQueueAndDispatch() {
- drainMutex.withLock {
- fromRadioPacketFlow()
- .onEach { packet ->
- Logger.d { "[$address] Read packet from queue (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { ex -> Logger.w(ex) { "[$address] Exception while draining packet queue" } }
- .collect()
- }
- }
-
// --- Connection & Discovery Logic ---
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
@@ -211,11 +144,11 @@ constructor(
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
- service.onDisconnect(BleError.from(e))
+ handleFailure(e)
}
.launchIn(connectionScope)
- val p = retryBleOperation(tag = address) { findPeripheral() }
+ val p = findPeripheral()
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (state !is ConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
@@ -226,14 +159,14 @@ constructor(
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
- service.onDisconnect(BleError.from(e))
+ handleFailure(e)
}
}
}
private suspend fun onConnected() {
try {
- bleConnection.peripheral?.let { p ->
+ bleConnection.peripheralFlow.first()?.let { p ->
val rssi = retryBleOperation(tag = address) { p.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
@@ -243,7 +176,7 @@ constructor(
}
private fun onDisconnected(state: ConnectionState.Disconnected) {
- clearCharacteristics()
+ radioService = null
val uptime =
if (connectionStartTime > 0) {
@@ -257,117 +190,64 @@ constructor(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
- service.onDisconnect(error = BleError.Disconnected(reason = state.reason))
+ val (isPermanent, msg) =
+ when (val reason = state.reason) {
+ is ConnectionState.Disconnected.Reason.InsufficientAuthentication ->
+ Pair(true, "Insufficient authentication: please unpair and repair the device")
+ is ConnectionState.Disconnected.Reason.RequiredServiceNotFound ->
+ Pair(false, "Required characteristic missing")
+ else -> Pair(false, reason.toString())
+ }
+ service.onDisconnect(isPermanent, errorMessage = msg)
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
- val chars =
- bleConnection.discoverCharacteristics(
- serviceUuid = SERVICE_UUID,
- requiredUuids =
- listOf(
- TORADIO_CHARACTERISTIC,
- FROMNUM_CHARACTERISTIC,
- FROMRADIO_CHARACTERISTIC,
- LOGRADIO_CHARACTERISTIC,
- ),
- optionalUuids = listOf(FROMRADIOSYNC_CHARACTERISTIC),
- )
+ bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
+ val radioService = MeshtasticRadioServiceImpl(service)
- if (chars != null) {
- toRadioCharacteristic = chars[TORADIO_CHARACTERISTIC]
- fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
- fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
- logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
- fromRadioSyncCharacteristic = chars[FROMRADIOSYNC_CHARACTERISTIC]
+ // Wire up notifications
+ radioService.fromRadio
+ .onEach { packet ->
+ Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
+ dispatchPacket(packet)
+ }
+ .catch { e ->
+ Logger.w(e) { "[$address] Error in fromRadio flow" }
+ handleFailure(e)
+ }
+ .launchIn(this)
- Logger.d { "[$address] Characteristics discovered successfully" }
- setupNotifications()
- service.onConnect()
- } else {
- Logger.w { "[$address] Discovery failed: missing required characteristics" }
- service.onDisconnect(error = BleError.DiscoveryFailed("One or more characteristics not found"))
+ radioService.logRadio
+ .onEach { packet ->
+ Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
+ dispatchPacket(packet)
+ }
+ .catch { e ->
+ Logger.w(e) { "[$address] Error in logRadio flow" }
+ handleFailure(e)
+ }
+ .launchIn(this)
+
+ // Store reference for handleSendToRadio
+ this@NordicBleInterface.radioService = radioService
+
+ Logger.i { "[$address] Profile service active and characteristics subscribed" }
+
+ // Log negotiated MTU for diagnostics
+ val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
+ Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
+
+ this@NordicBleInterface.service.onConnect()
}
} catch (e: Exception) {
- Logger.w(e) { "[$address] Service discovery failed" }
+ Logger.w(e) { "[$address] Profile service discovery or operation failed" }
bleConnection.disconnect()
- service.onDisconnect(error = BleError.from(e))
+ handleFailure(e)
}
}
- // --- Notification Setup ---
-
- @Suppress("LongMethod")
- private suspend fun setupNotifications() {
- val fromRadioReady = CompletableDeferred()
- val logRadioReady = CompletableDeferred()
-
- // 1. Prefer FromRadioSync (Indicate) if available
- if (fromRadioSyncCharacteristic != null) {
- Logger.i { "[$address] Using FromRadioSync for packet reception" }
- fromRadioSyncCharacteristic
- ?.subscribe {
- Logger.d { "[$address] FromRadioSync subscription active" }
- fromRadioReady.complete(Unit)
- }
- ?.onEach { payload ->
- Logger.d { "[$address] FromRadioSync Indication (${payload.size} bytes)" }
- dispatchPacket(payload)
- }
- ?.catch { e ->
- if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in fromRadioSyncCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
- } else {
- // 2. Fallback to legacy FromNum (Notify) + FromRadio (Read)
- Logger.i { "[$address] Using legacy FromNum/FromRadio for packet reception" }
- fromNumCharacteristic
- ?.subscribe {
- Logger.d { "[$address] FromNum subscription active" }
- fromRadioReady.complete(Unit)
- }
- ?.onEach { notifyBytes ->
- Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
- connectionScope.launch { drainPacketQueueAndDispatch() }
- }
- ?.catch { e ->
- if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit)
- }
-
- logRadioCharacteristic
- ?.subscribe {
- Logger.d { "[$address] LogRadio subscription active" }
- logRadioReady.complete(Unit)
- }
- ?.onEach { notifyBytes ->
- Logger.d { "[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet" }
- dispatchPacket(notifyBytes)
- }
- ?.catch { e ->
- if (!logRadioReady.isCompleted) logRadioReady.completeExceptionally(e)
- Logger.w(e) { "[$address] Error in logRadioCharacteristic subscription" }
- service.onDisconnect(BleError.from(e))
- }
- ?.launchIn(connectionScope) ?: logRadioReady.complete(Unit)
-
- try {
- withTimeout(CONNECTION_TIMEOUT_MS) {
- fromRadioReady.await()
- logRadioReady.await()
- }
- Logger.d { "[$address] All notifications successfully subscribed" }
- } catch (e: Exception) {
- Logger.e(e) { "[$address] Timeout or error waiting for characteristic subscriptions" }
- throw e
- }
- }
+ private var radioService: MeshtasticRadioProfile.State? = null
// --- IRadioInterface Implementation ---
@@ -377,44 +257,31 @@ constructor(
* @param p The packet to send.
*/
override fun handleSendToRadio(p: ByteArray) {
- toRadioCharacteristic?.let { characteristic ->
+ val currentService = radioService
+ if (currentService != null) {
connectionScope.launch {
writeMutex.withLock {
try {
- val writeType =
- if (characteristic.properties.contains(CharacteristicProperty.WRITE_WITHOUT_RESPONSE)) {
- WriteType.WITHOUT_RESPONSE
- } else {
- WriteType.WITH_RESPONSE
- }
-
- retryBleOperation(tag = address) { characteristic.write(p, writeType = writeType) }
-
+ retryBleOperation(tag = address) { currentService.sendToRadio(p) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
- "to toRadioCharacteristic with $writeType - " +
+ "to toRadioCharacteristic - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
-
- // Only manually drain if we are using the legacy FromNum/FromRadio flow
- if (fromRadioSyncCharacteristic == null) {
- drainPacketQueueAndDispatch()
- }
- } catch (e: InvalidAttributeException) {
- Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" }
- handleInvalidAttribute(e)
} catch (e: Exception) {
Logger.w(e) {
"[$address] Failed to write packet to toRadioCharacteristic after " +
"$packetsSent successful writes"
}
- service.onDisconnect(BleError.from(e))
+ handleFailure(e)
}
}
}
- } ?: Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
+ } else {
+ Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
+ }
}
override fun keepAlive() {
@@ -423,35 +290,53 @@ constructor(
/** Closes the connection to the device. */
override fun close() {
- runBlocking {
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.i {
- "[$address] BLE close() called - " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
+ val uptime =
+ if (connectionStartTime > 0) {
+ nowMillis - connectionStartTime
+ } else {
+ 0
}
+ Logger.i {
+ "[$address] BLE close() called - " +
+ "Uptime: ${uptime}ms, " +
+ "Packets RX: $packetsReceived ($bytesReceived bytes), " +
+ "Packets TX: $packetsSent ($bytesSent bytes)"
+ }
+ serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
service.onDisconnect(true)
}
}
- private fun handleInvalidAttribute(e: InvalidAttributeException) {
- clearCharacteristics()
- service.onDisconnect(BleError.from(e))
+ private fun dispatchPacket(packet: ByteArray) {
+ packetsReceived++
+ bytesReceived += packet.size
+ Logger.d {
+ "[$address] Dispatching packet to service.handleFromRadio() - " +
+ "Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
+ }
+ service.handleFromRadio(packet)
}
- private fun clearCharacteristics() {
- toRadioCharacteristic = null
- fromNumCharacteristic = null
- fromRadioCharacteristic = null
- logRadioCharacteristic = null
- fromRadioSyncCharacteristic = null
+ private fun handleFailure(throwable: Throwable) {
+ val (isPermanent, msg) = throwable.toDisconnectReason()
+ service.onDisconnect(isPermanent, errorMessage = msg)
+ }
+
+ private fun Throwable.toDisconnectReason(): Pair {
+ val isPermanent =
+ this is no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException ||
+ this is no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
+ val msg =
+ when (this) {
+ is RadioNotConnectedException -> this.message ?: "Device not found"
+ is NoSuchElementException,
+ is IllegalArgumentException,
+ -> "Required characteristic missing"
+ is no.nordicsemi.kotlin.ble.core.exception.GattException -> "GATT Error: ${this.message}"
+ else -> this.message ?: this.javaClass.simpleName
+ }
+ return Pair(isPermanent, msg)
}
}
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 41cceafe2..244167e5c 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
@@ -38,7 +38,6 @@ import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
@@ -169,7 +168,7 @@ class NordicBleInterfaceRetryTest {
assert(writtenValue!!.contentEquals(dataToSend))
// Verify we didn't disconnect due to the retryable error
- verify(exactly = 0) { service.onDisconnect(any()) }
+ verify(exactly = 0) { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
@@ -274,7 +273,7 @@ class NordicBleInterfaceRetryTest {
// Verify onDisconnect was called after retries exhausted
// Nordic BLE wraps RuntimeException in BluetoothException
- verify { service.onDisconnect(any()) }
+ verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
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 1ee5ff9ee..1bf2f5a29 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
@@ -40,7 +40,6 @@ import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
@@ -400,8 +399,7 @@ class NordicBleInterfaceTest {
advanceUntilIdle()
// Verify onDisconnect was called on the service
- // NordicBleInterface calls onDisconnect(BleError.Disconnected)
- verify { service.onDisconnect(any()) }
+ verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
@@ -481,7 +479,7 @@ class NordicBleInterfaceTest {
advanceUntilIdle()
// Verify that discovery failed
- verify { service.onDisconnect(any()) }
+ verify { service.onDisconnect(false, "Required characteristic missing") }
nordicInterface.close()
}
@@ -575,7 +573,7 @@ class NordicBleInterfaceTest {
advanceUntilIdle()
// Verify onDisconnect was called with error
- verify { service.onDisconnect(any()) }
+ verify { service.onDisconnect(any(), any()) }
nordicInterface.close()
}
diff --git a/core/ble/README.md b/core/ble/README.md
index 9989025e3..8b6f34062 100644
--- a/core/ble/README.md
+++ b/core/ble/README.md
@@ -31,33 +31,32 @@ This modernization replaces legacy callback-based implementations with robust, C
## Key Components
-### 1. `NordicBleInterface`
-The primary implementation of `IRadioInterface` for BLE devices. It acts as the bridge between the app's `RadioInterfaceService` and the physical Bluetooth device.
+### 1. `BleConnection`
+A robust wrapper around Nordic's `Peripheral` and `CentralManager` that simplifies the connection lifecycle and service discovery using modern Coroutine APIs.
-- **Responsibility:**
- - Managing the connection lifecycle.
- - Discovering GATT services and characteristics.
- - Handling data transmission (ToRadio) and reception (FromRadio).
- - Managing MTU negotiation and connection priority.
+- **Features:**
+ - **Connection & Await:** Provides suspend functions to connect and wait for a terminal state (Connected or Disconnected).
+ - **Unified Profile Helper:** A `profile` function that manages service discovery, characteristic setup, and lifecycle in a single block, with automatic timeout and error handling.
+ - **Observability:** Exposes `peripheralFlow` and `connectionState` as Flows for reactive UI and service updates.
+ - **Connection Management:** Handles PHY updates, MTU logging, and connection priority requests automatically.
### 2. `BluetoothRepository`
A Singleton repository responsible for the global state of Bluetooth on the Android device.
- **Features:**
- **State Management:** Exposes a `StateFlow` reflecting whether Bluetooth is enabled, permissions are granted, and which devices are bonded.
- - **Scanning:** Uses Nordic's `Scanner` to find devices.
- - **Bonding:** Handles the creation of bonds with peripherals.
+ - **Permission Handling:** Centralizes logic for checking Bluetooth and Location permissions across different Android versions.
+ - **Bonding:** Simplifies the process of creating bonds with peripherals.
-### 3. `BleConnection`
-A wrapper around Nordic's `ClientBleGatt` that simplifies the connection process.
-
-- **Features:**
- - **Connection & Await:** Provides suspend functions to connect and wait for a specific connection state.
- - **Service Discovery:** Helper functions to discover specific services and characteristics with timeouts and retries.
- - **Observability:** Logs connection parameters, PHY updates, and state changes.
+### 3. `BleScanner`
+A wrapper around Nordic's `CentralManager` scanning capabilities to provide a consistent and easy-to-use API for BLE scanning with built-in peripheral deduplication.
### 4. `BleRetry`
-A utility for executing BLE operations with exponential backoff and retry logic. This is crucial for handling the inherent unreliability of wireless communication.
+A utility for executing BLE operations with retry logic, essential for handling the inherent unreliability of wireless communication.
+
+## Integration in `app`
+
+The `:core:ble` module is used by `NordicBleInterface` in the main application module to implement the `IRadioInterface` for Bluetooth devices.
## Usage
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 1ec635cc6..e31ef96ef 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
@@ -17,33 +17,29 @@
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
-import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
-import no.nordicsemi.kotlin.ble.client.RemoteService
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
+import no.nordicsemi.kotlin.ble.core.WriteType
+import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
-private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
-
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
@@ -61,12 +57,18 @@ class BleConnection(
var peripheral: Peripheral? = null
private set
+ private val _peripheral = MutableSharedFlow(replay = 1)
+
+ /** A flow of the current peripheral. */
+ val peripheralFlow = _peripheral.asSharedFlow()
+
private val _connectionState = simpleSharedFlow()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow = _connectionState.asSharedFlow()
private var stateJob: Job? = null
+ private var profileJob: Job? = null
/**
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
@@ -77,6 +79,7 @@ class BleConnection(
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p
+ _peripheral.emit(p)
centralManager.connect(
peripheral = p,
@@ -103,57 +106,32 @@ class BleConnection(
*
* @param p The peripheral to connect to.
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
+ * @param onRegister Optional block to run before connecting, allowing for profile registration.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
- suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
+ suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long, onRegister: suspend () -> Unit = {}): ConnectionState {
+ onRegister()
connect(p)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
}
}
- /** A flow of discovered services. Useful for reacting to "Service Changed" indications. */
- val services: SharedFlow> =
- _connectionState
- .asSharedFlow()
- .filter { it is ConnectionState.Connected }
- .flatMapLatest { peripheral?.services() ?: flowOf(emptyList()) }
- .filterNotNull()
- .shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
-
- /** Discovers characteristics for a specific service. */
- suspend fun discoverCharacteristics(
- serviceUuid: Uuid,
- requiredUuids: List,
- optionalUuids: List = emptyList(),
- ): Map? {
- val p = peripheral ?: return null
-
- return retryBleOperation(tag = tag) {
- val allRequested = requiredUuids + optionalUuids
- val serviceList =
- withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
- val service = serviceList.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
-
- val result = mutableMapOf()
- for (uuid in allRequested) {
- val char = service.characteristics.find { it.uuid == uuid }
- if (char != null) {
- result[uuid] = char
- }
- }
-
- val hasAllRequired = requiredUuids.all { result.containsKey(it) }
- if (hasAllRequired) result else null
- }
- }
-
+ @Suppress("TooGenericExceptionCaught")
private fun observePeripheralDetails(p: Peripheral) {
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
- .onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
+ .onEach { params ->
+ Logger.i { "[$tag] BLE connection parameters changed to $params" }
+ try {
+ val maxWriteLen = p.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
+ Logger.i { "[$tag] Negotiated MTU (Write): $maxWriteLen bytes" }
+ } catch (e: Exception) {
+ Logger.d { "[$tag] Could not read MTU: ${e.message}" }
+ }
+ }
.launchIn(scope)
}
@@ -161,7 +139,65 @@ class BleConnection(
suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
+ profileJob?.cancel()
+ profileJob = null
peripheral?.disconnect()
peripheral = null
+ _peripheral.emit(null)
+ }
+
+ /**
+ * Executes a block within a discovered profile. Handles peripheral readiness, discovery with a timeout, and cleans
+ * up the profile job if discovery fails.
+ *
+ * @param serviceUuid The UUID of the service to discover.
+ * @param timeout The duration to wait for discovery.
+ * @param block The block to execute with the discovered service.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ suspend fun profile(
+ serviceUuid: Uuid,
+ timeout: kotlin.time.Duration = 10.seconds,
+ setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
+ ): T {
+ val p = peripheralFlow.first { it != null }!!
+ val serviceReady = CompletableDeferred()
+
+ profileJob?.cancel()
+ val job =
+ scope.launch {
+ try {
+ val profileScope = this
+ p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
+ try {
+ val result = setup(service)
+ serviceReady.complete(result)
+ // Keep the profile active until this launch scope (profileJob) is cancelled
+ awaitCancellation()
+ } catch (e: Throwable) {
+ if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
+ throw e
+ }
+ }
+ } catch (e: Throwable) {
+ if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
+ }
+ }
+ profileJob = job
+
+ return try {
+ withTimeout(timeout) { serviceReady.await() }
+ } catch (e: Throwable) {
+ profileJob?.cancel()
+ throw e
+ }
+ }
+
+ /** Returns the maximum write value length for the given write type. */
+ fun maximumWriteValueLength(writeType: WriteType): Int? = peripheral?.maximumWriteValueLength(writeType)
+
+ /** Requests a new connection priority for the current peripheral. */
+ suspend fun requestConnectionPriority(priority: ConnectionPriority) {
+ peripheral?.requestConnectionPriority(priority)
}
}
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
deleted file mode 100644
index 4bbf155c8..000000000
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
+++ /dev/null
@@ -1,135 +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.ble
-
-import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
-import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
-import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
-import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
-import no.nordicsemi.kotlin.ble.client.exception.ScanningException
-import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
-import no.nordicsemi.kotlin.ble.core.ConnectionState
-import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
-import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
-import no.nordicsemi.kotlin.ble.core.exception.GattException
-import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
-
-/**
- * Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
- * granular error handling and intelligent reconnection strategies.
- */
-sealed class BleError(val message: String, val shouldReconnect: Boolean) {
-
- /**
- * An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
- * reconnect.
- */
- data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
-
- /**
- * An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
- * warranted.
- */
- class ConnectionFailed(exception: Throwable) :
- BleError("Connection failed: ${exception.message}", shouldReconnect = true)
-
- /**
- * An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
- * attempt is warranted.
- */
- class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
-
- /**
- * An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
- * is warranted.
- */
- class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
- BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
-
- /**
- * Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
- *
- * @param exception The underlying GattException.
- */
- class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
-
- /**
- * Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
- *
- * @param exception The underlying BluetoothException.
- */
- class BluetoothError(exception: BluetoothException) :
- BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
-
- /** The BLE manager was closed. This is a non-recoverable error. */
- class ManagerClosed(exception: ManagerClosedException) :
- BleError("Manager closed: ${exception.message}", shouldReconnect = false)
-
- /** A BLE operation failed. This may be recoverable. */
- class OperationFailed(exception: OperationFailedException) :
- BleError("Operation failed: ${exception.message}", shouldReconnect = true)
-
- /**
- * An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
- * change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
- */
- class InvalidAttribute(exception: InvalidAttributeException) :
- BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
-
- /** An error occurred while scanning for devices. This may be recoverable. */
- class Scanning(exception: ScanningException) :
- BleError("Scanning error: ${exception.message}", shouldReconnect = true)
-
- /** Bluetooth is unavailable on the device. This is a non-recoverable error. */
- class BluetoothUnavailable(exception: BluetoothUnavailableException) :
- BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
-
- /** The peripheral is not connected. This may be recoverable. */
- class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
- BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
-
- /** A value did not match what was expected. This may be recoverable. */
- class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
- BleError("Value does not match: ${exception.message}", shouldReconnect = true)
-
- /** A generic error for other exceptions that may occur. */
- class GenericError(exception: Throwable) :
- BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
-
- companion object {
- fun from(exception: Throwable): BleError = when (exception) {
- is GattException -> {
- when (exception) {
- is ConnectionFailedException -> ConnectionFailed(exception)
- is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
- is OperationFailedException -> OperationFailed(exception)
- is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
- else -> GattError(exception)
- }
- }
- is BluetoothException -> {
- when (exception) {
- is BluetoothUnavailableException -> BluetoothUnavailable(exception)
- is InvalidAttributeException -> InvalidAttribute(exception)
- is ScanningException -> Scanning(exception)
- else -> BluetoothError(exception)
- }
- }
- else -> GenericError(exception)
- }
- }
-}
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleModule.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleModule.kt
index 0086932f9..4970cfa89 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleModule.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleModule.kt
@@ -23,12 +23,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
+import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@@ -47,5 +47,6 @@ object BleModule {
@Provides
@Singleton
- fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
+ CoroutineScope(SupervisorJob() + dispatchers.default)
}
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
index 5cde0ca9f..c636d4718 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
@@ -30,7 +30,6 @@ import kotlinx.coroutines.delay
* @return The result of the operation.
* @throws Exception if the operation fails after all attempts.
*/
-@Suppress("TooGenericExceptionCaught")
suspend fun retryBleOperation(
count: Int = 3,
delayMs: Long = 500L,
@@ -43,7 +42,7 @@ suspend fun retryBleOperation(
return block()
} catch (e: CancellationException) {
throw e
- } catch (e: Exception) {
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
currentAttempt++
if (currentAttempt >= count) {
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
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 8861b8a11..dbf68f811 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
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import no.nordicsemi.kotlin.ble.client.RemoteServices
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
@@ -85,13 +86,7 @@ constructor(
}
internal suspend fun updateBluetoothState() {
- val hasPerms =
- if (androidEnvironment.requiresBluetoothRuntimePermissions) {
- androidEnvironment.isBluetoothScanPermissionGranted &&
- androidEnvironment.isBluetoothConnectPermissionGranted
- } else {
- androidEnvironment.isLocationPermissionGranted
- }
+ val hasPerms = hasRequiredPermissions()
val enabled = androidEnvironment.isBluetoothEnabled
val newState =
BluetoothState(
@@ -116,13 +111,7 @@ constructor(
@SuppressLint("MissingPermission")
fun isBonded(address: String): Boolean {
val enabled = androidEnvironment.isBluetoothEnabled
- val hasPerms =
- if (androidEnvironment.requiresBluetoothRuntimePermissions) {
- androidEnvironment.isBluetoothScanPermissionGranted &&
- androidEnvironment.isBluetoothConnectPermissionGranted
- } else {
- androidEnvironment.isLocationPermissionGranted
- }
+ val hasPerms = hasRequiredPermissions()
return if (enabled && hasPerms) {
centralManager.getBondedPeripherals().any { it.address == address }
} else {
@@ -130,10 +119,19 @@ constructor(
}
}
+ private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
+ androidEnvironment.isBluetoothScanPermissionGranted &&
+ androidEnvironment.isBluetoothConnectPermissionGranted
+ } else {
+ androidEnvironment.isLocationPermissionGranted
+ }
+
/** 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
- val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
+ val hasRequiredService =
+ (peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
+ ?: false
return nameMatches || hasRequiredService
}
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
index 789110ac6..389516521 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
@@ -39,4 +39,15 @@ object MeshtasticBleConstants {
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
+
+ // --- OTA Characteristics ---
+
+ /** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
+ val OTA_SERVICE_UUID: Uuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
+
+ /** Characteristic for writing OTA commands and firmware data. */
+ val OTA_WRITE_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
+
+ /** Characteristic for receiving OTA status notifications/ACKs. */
+ val OTA_NOTIFY_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
}
diff --git a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BluetoothRepositoryTest.kt b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BluetoothRepositoryTest.kt
index a4477c5e7..84b2d697b 100644
--- a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BluetoothRepositoryTest.kt
+++ b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BluetoothRepositoryTest.kt
@@ -124,4 +124,37 @@ class BluetoothRepositoryTest {
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
assertEquals(address, state.bondedDevices.first().address)
}
+
+ @Test
+ fun `isBonded returns false when permissions are not granted`() = runTest(testDispatcher) {
+ val noPermsEnv =
+ MockAndroidEnvironment.Api31(
+ isBluetoothEnabled = true,
+ isBluetoothScanPermissionGranted = false,
+ isBluetoothConnectPermissionGranted = false,
+ )
+ val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
+
+ val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
+ runCurrent()
+
+ assertFalse(repository.isBonded("C0:00:00:00:00:03"))
+ }
+
+ @Test
+ fun `state has no permissions when bluetooth permissions denied`() = runTest(testDispatcher) {
+ val noPermsEnv =
+ MockAndroidEnvironment.Api31(
+ isBluetoothEnabled = true,
+ isBluetoothScanPermissionGranted = true,
+ isBluetoothConnectPermissionGranted = false,
+ )
+ val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
+
+ val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
+ runCurrent()
+
+ val state = repository.state.value
+ assertFalse("hasPermissions should be false when connect permission is denied", state.hasPermissions)
+ }
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
index 787863341..863761bef 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
@@ -59,10 +59,7 @@ interface RadioInterfaceService {
fun onConnect()
/** Called by an interface when it has disconnected. */
- fun onDisconnect(isPermanent: Boolean)
-
- /** Called by an interface when it has disconnected with an error. */
- fun onDisconnect(error: Any)
+ fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null)
/** Called by an interface when it has received raw data from the radio. */
fun handleFromRadio(bytes: ByteArray)
diff --git a/feature/firmware/README.md b/feature/firmware/README.md
index 1c811faf2..99479ba2d 100644
--- a/feature/firmware/README.md
+++ b/feature/firmware/README.md
@@ -42,11 +42,12 @@ The `:feature:firmware` module provides a unified interface for updating Meshtas
Meshtastic-Android supports three primary firmware update flows:
#### 1. ESP32 Unified OTA (WiFi & BLE)
-Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency with the rest of the application.
+Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency and modern coroutine support.
**Key Features:**
- **Pre-shared Hash Verification**: The app sends the firmware SHA256 hash in an initial `AdminMessage` trigger. The device stores this in NVS and verifies the incoming stream against it.
- **Connection Retry**: Robust logic to wait for the device to reboot and start the OTA listener.
+- **Automatic MTU Handling & Fragmentation**: The BLE transport automatically detects the negotiated MTU and fragments data chunks into packets that fit. It carefully manages acknowledgments for each fragmented packet to ensure reliability even on congested connections.
```mermaid
sequenceDiagram
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
index 0b07b4146..af6df6cba 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt
@@ -32,13 +32,16 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.android.CentralManager
+import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleScanner
+import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
+import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
+import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
import kotlin.time.Duration.Companion.seconds
-import kotlin.uuid.Uuid
/**
* BLE transport implementation for ESP32 Unified OTA protocol. Uses Nordic Kotlin-BLE-Library for modern coroutine
@@ -161,57 +164,81 @@ class BleOtaTransport(
Logger.i { "BLE OTA: Connected to ${p.address}, discovering services..." }
- // Discover services
- val chars =
- bleConnection.discoverCharacteristics(SERVICE_UUID, listOf(OTA_CHARACTERISTIC_UUID, TX_CHARACTERISTIC_UUID))
- ?: throw OtaProtocolException.ConnectionFailed("Required OTA service or characteristics not found")
+ // Increase connection priority for OTA
+ bleConnection.requestConnectionPriority(ConnectionPriority.HIGH)
- otaCharacteristic = chars[OTA_CHARACTERISTIC_UUID]
- val txChar = chars[TX_CHARACTERISTIC_UUID]
-
- if (otaCharacteristic == null || txChar == null) {
- throw OtaProtocolException.ConnectionFailed("Required characteristics not found")
- }
-
- // Enable notifications and collect responses
- val subscribed = CompletableDeferred()
- txChar
- .subscribe {
- Logger.d { "BLE OTA: TX characteristic subscribed" }
- subscribed.complete(Unit)
- }
- .onEach { notifyBytes ->
- try {
- val response = notifyBytes.decodeToString()
- Logger.d { "BLE OTA: Received response: $response" }
- responseChannel.trySend(response)
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.e(e) { "BLE OTA: Failed to decode response bytes" }
+ // Discover services using our unified profile helper
+ bleConnection.profile(OTA_SERVICE_UUID) { service ->
+ val ota =
+ requireNotNull(service.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
+ "OTA characteristic not found"
+ }
+ val txChar =
+ requireNotNull(service.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
+ "TX characteristic not found"
}
- }
- .catch { e ->
- if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
- Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" }
- }
- .launchIn(transportScope)
- subscribed.await()
- Logger.i { "BLE OTA: Service discovered and ready" }
+ otaCharacteristic = ota
+
+ // Log negotiated MTU for diagnostics
+ val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
+ Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" }
+
+ // Enable notifications and collect responses
+ val subscribed = CompletableDeferred()
+ txChar
+ .subscribe {
+ Logger.d { "BLE OTA: TX characteristic subscribed" }
+ subscribed.complete(Unit)
+ }
+ .onEach { notifyBytes ->
+ try {
+ val response = notifyBytes.decodeToString()
+ Logger.d { "BLE OTA: Received response: $response" }
+ responseChannel.trySend(response)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "BLE OTA: Failed to decode response bytes" }
+ }
+ }
+ .catch { e ->
+ if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
+ Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" }
+ }
+ .launchIn(this)
+
+ subscribed.await()
+ Logger.i { "BLE OTA: Service discovered and ready" }
+ }
}
+ /**
+ * Initiates the OTA update by sending the size and hash.
+ *
+ * Note: If the start command is fragmented into multiple BLE packets, the protocol may send multiple responses
+ * (usually one ACK per packet followed by a final OK/ERASING).
+ */
+ @Suppress("CyclomaticComplexMethod")
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result = runCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
- sendCommand(command)
+ val packetsSent = sendCommand(command)
var handshakeComplete = false
+ var responsesReceived = 0
while (!handshakeComplete) {
val response = waitForResponse(ERASING_TIMEOUT_MS)
+ responsesReceived++
when (val parsed = OtaResponse.parse(response)) {
- is OtaResponse.Ok -> handshakeComplete = true
+ is OtaResponse.Ok -> {
+ // Only consider handshake complete after consuming all potential fragmented responses
+ if (responsesReceived >= packetsSent) {
+ handshakeComplete = true
+ }
+ }
+
is OtaResponse.Erasing -> {
Logger.i { "BLE OTA: Device erasing flash..." }
onHandshakeStatus(OtaHandshakeStatus.Erasing)
@@ -231,6 +258,14 @@ class BleOtaTransport(
}
}
+ /**
+ * Streams the firmware data in chunks.
+ *
+ * Each chunk is potentially fragmented into multiple BLE packets based on the negotiated MTU. The transport ensures
+ * that every fragmented packet is acknowledged by the device before proceeding, preventing buffer overflows on the
+ * radio.
+ */
+ @Suppress("CyclomaticComplexMethod")
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
@@ -248,43 +283,49 @@ class BleOtaTransport(
val currentChunkSize = minOf(chunkSize, remainingBytes)
val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize)
- // Write chunk
- writeData(chunk, WriteType.WITHOUT_RESPONSE)
+ // Write chunk (potentially fragmented into multiple BLE packets)
+ val packetsSentForChunk = writeData(chunk, WriteType.WITHOUT_RESPONSE)
- // Wait for response (ACK or OK for last chunk)
- val response = waitForResponse(ACK_TIMEOUT_MS)
+ // Wait for responses (The protocol expects one response per GATT write)
val nextSentBytes = sentBytes + currentChunkSize
- when (val parsed = OtaResponse.parse(response)) {
- is OtaResponse.Ack -> {
- // Normal chunk success
- }
+ repeat(packetsSentForChunk) { i ->
+ val response = waitForResponse(ACK_TIMEOUT_MS)
+ val isLastPacketOfChunk = i == packetsSentForChunk - 1
- is OtaResponse.Ok -> {
- // OK indicates completion (usually on last chunk)
- if (nextSentBytes >= totalBytes) {
- sentBytes = nextSentBytes
- onProgress(1.0f)
- return@runCatching Unit
- } else {
- throw OtaProtocolException.TransferFailed("Premature OK received at offset $nextSentBytes")
+ when (val parsed = OtaResponse.parse(response)) {
+ is OtaResponse.Ack -> {
+ // Normal packet success
}
- }
- is OtaResponse.Error -> {
- if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
- throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
+ is OtaResponse.Ok -> {
+ // OK indicates completion (usually on last packet of last chunk)
+ if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
+ sentBytes = nextSentBytes
+ onProgress(1.0f)
+ return@runCatching Unit
+ } else if (!isLastPacketOfChunk) {
+ // Intermediate OK might happen if the device treats packets as chunks
+ } else {
+ throw OtaProtocolException.TransferFailed("Premature OK received at offset $nextSentBytes")
+ }
}
- throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
- }
- else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
+ is OtaResponse.Error -> {
+ if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) {
+ throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer")
+ }
+ throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}")
+ }
+
+ else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response")
+ }
}
sentBytes = nextSentBytes
onProgress(sentBytes.toFloat() / totalBytes)
}
- // If we finished the loop without receiving OK, wait for it now
+ // If we finished the loop without receiving OK, wait for it now (verification stage)
val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS)
when (val parsed = OtaResponse.parse(finalResponse)) {
is OtaResponse.Ok -> Unit
@@ -305,20 +346,37 @@ class BleOtaTransport(
transportScope.cancel()
}
- private suspend fun sendCommand(command: OtaCommand) {
+ private suspend fun sendCommand(command: OtaCommand): Int {
val data = command.toString().toByteArray()
- writeData(data, WriteType.WITH_RESPONSE)
+ return writeData(data, WriteType.WITH_RESPONSE)
}
- private suspend fun writeData(data: ByteArray, writeType: WriteType) {
+ /**
+ * Writes data to the OTA characteristic, fragmenting the data into multiple BLE packets if it exceeds the
+ * negotiated MTU (maximum write length).
+ *
+ * @return The number of packets sent.
+ */
+ private suspend fun writeData(data: ByteArray, writeType: WriteType): Int {
val characteristic =
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
+ val maxLen = bleConnection.maximumWriteValueLength(writeType) ?: data.size
+ var offset = 0
+ var packetsSent = 0
+
try {
- characteristic.write(data, writeType = writeType)
+ while (offset < data.size) {
+ val chunkSize = minOf(data.size - offset, maxLen)
+ val packet = data.copyOfRange(offset, offset + chunkSize)
+ characteristic.write(packet, writeType = writeType)
+ offset += chunkSize
+ packetsSent++
+ }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- throw OtaProtocolException.TransferFailed("Failed to write data", e)
+ throw OtaProtocolException.TransferFailed("Failed to write data at offset $offset", e)
}
+ return packetsSent
}
private suspend fun waitForResponse(timeoutMs: Long): String = try {
@@ -328,11 +386,6 @@ class BleOtaTransport(
}
companion object {
- // Service and Characteristic UUIDs from ESP32 Unified OTA spec
- private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
- private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
- private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
-
// Timeouts and retries
private val SCAN_TIMEOUT = 10.seconds
private const val CONNECTION_TIMEOUT_MS = 15_000L
diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportServiceDiscoveryTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportServiceDiscoveryTest.kt
new file mode 100644
index 000000000..3b33ed5b6
--- /dev/null
+++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportServiceDiscoveryTest.kt
@@ -0,0 +1,209 @@
+/*
+ * 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.firmware.ota
+
+import co.touchlab.kermit.Logger
+import co.touchlab.kermit.Severity
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import no.nordicsemi.kotlin.ble.client.android.CentralManager
+import no.nordicsemi.kotlin.ble.client.android.mock.mock
+import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
+import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
+import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
+import no.nordicsemi.kotlin.ble.client.mock.Proximity
+import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
+import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
+import no.nordicsemi.kotlin.ble.core.Permission
+import no.nordicsemi.kotlin.ble.core.and
+import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.uuid.Uuid
+
+private val SERVICE_UUID = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
+private val OTA_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
+private val TX_CHARACTERISTIC_UUID = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
+
+/**
+ * Tests for BleOtaTransport service discovery via Nordic's Peripheral.profile() API. These validate the refactored
+ * connect() path that replaced discoverCharacteristics().
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class BleOtaTransportServiceDiscoveryTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val address = "00:11:22:33:44:55"
+
+ @Before
+ fun setup() {
+ Logger.setLogWriters(
+ object : co.touchlab.kermit.LogWriter() {
+ override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
+ println("[$severity] $tag: $message")
+ throwable?.printStackTrace()
+ }
+ },
+ )
+ }
+
+ @Test
+ fun `connect fails when OTA service not found on device`() = runTest(testDispatcher) {
+ val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
+ val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
+
+ // Create a peripheral with a DIFFERENT service UUID (not the OTA service)
+ val wrongServiceUuid = Uuid.parse("0000180A-0000-1000-8000-00805F9B34FB") // Device Info
+ val otaPeripheral =
+ PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
+ advertising(
+ parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
+ ) {
+ CompleteLocalName("ESP32-OTA")
+ }
+ connectable(
+ name = "ESP32-OTA",
+ eventHandler = object : PeripheralSpecEventHandler {},
+ isBonded = true,
+ ) {
+ Service(uuid = wrongServiceUuid) {
+ Characteristic(
+ uuid = OTA_CHARACTERISTIC_UUID,
+ properties =
+ CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
+ permission = Permission.WRITE,
+ )
+ }
+ }
+ }
+
+ centralManager.simulatePeripherals(listOf(otaPeripheral))
+
+ val transport = BleOtaTransport(centralManager, address, testDispatcher)
+ val result = transport.connect()
+
+ assertTrue("Connect should fail when OTA service is missing", result.isFailure)
+ transport.close()
+ }
+
+ @Test
+ fun `connect fails when TX characteristic is missing`() = runTest(testDispatcher) {
+ val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
+ val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
+
+ // Create a peripheral with the OTA service but only the OTA characteristic (no TX)
+ val otaPeripheral =
+ PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
+ advertising(
+ parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
+ ) {
+ CompleteLocalName("ESP32-OTA")
+ }
+ connectable(
+ name = "ESP32-OTA",
+ eventHandler = object : PeripheralSpecEventHandler {},
+ isBonded = true,
+ ) {
+ Service(uuid = SERVICE_UUID) {
+ Characteristic(
+ uuid = OTA_CHARACTERISTIC_UUID,
+ properties =
+ CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
+ permission = Permission.WRITE,
+ )
+ // TX_CHARACTERISTIC intentionally omitted
+ }
+ }
+ }
+
+ centralManager.simulatePeripherals(listOf(otaPeripheral))
+
+ val transport = BleOtaTransport(centralManager, address, testDispatcher)
+ val result = transport.connect()
+
+ assertTrue("Connect should fail when TX characteristic is missing", result.isFailure)
+ transport.close()
+ }
+
+ @Test
+ fun `connect fails when device is not found during scan`() = runTest(testDispatcher) {
+ val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
+ val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
+
+ // Don't simulate any peripherals — scan will find nothing
+ val transport = BleOtaTransport(centralManager, address, testDispatcher)
+ val result = transport.connect()
+
+ assertTrue("Connect should fail when device is not found", result.isFailure)
+ val exception = result.exceptionOrNull()
+ assertTrue(
+ "Should be ConnectionFailed, got: $exception",
+ exception is OtaProtocolException.ConnectionFailed,
+ )
+ transport.close()
+ }
+
+ @Test
+ fun `connect succeeds with valid OTA service and characteristics`() = runTest(testDispatcher) {
+ val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
+ val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
+
+ val otaPeripheral =
+ PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
+ advertising(
+ parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
+ ) {
+ CompleteLocalName("ESP32-OTA")
+ }
+ connectable(
+ name = "ESP32-OTA",
+ eventHandler =
+ object : PeripheralSpecEventHandler {
+ override fun onConnectionRequest(
+ preferredPhy: List,
+ ): ConnectionResult = ConnectionResult.Accept
+ },
+ isBonded = true,
+ ) {
+ Service(uuid = SERVICE_UUID) {
+ Characteristic(
+ uuid = OTA_CHARACTERISTIC_UUID,
+ properties =
+ CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
+ permission = Permission.WRITE,
+ )
+ Characteristic(
+ uuid = TX_CHARACTERISTIC_UUID,
+ property = CharacteristicProperty.NOTIFY,
+ permission = Permission.READ,
+ )
+ }
+ }
+ }
+
+ centralManager.simulatePeripherals(listOf(otaPeripheral))
+
+ val transport = BleOtaTransport(centralManager, address, testDispatcher)
+ val result = transport.connect()
+
+ assertTrue("Connect should succeed: ${result.exceptionOrNull()}", result.isSuccess)
+ transport.close()
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 710c9fda8..daa0a459c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -58,7 +58,7 @@ spotless = "8.3.0"
wire = "6.0.0-alpha03"
vico = "3.0.2"
dependency-guard = "0.5.0"
-nordic-ble = "2.0.0-alpha15"
+nordic-ble = "2.0.0-alpha16"
nordic-common = "2.9.2"
From 63984f07230ccced338c5dbefc26fbd7a5f3ff8c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 13:00:30 -0600
Subject: [PATCH 012/407] fix(widget): ensure local stats widget gets updates
(#4722)
---
.../geeksville/mesh/widget/LocalStatsWidgetState.kt | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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 1f28a65f7..d11868e7a 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -26,11 +26,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.LocalStats
@@ -83,6 +85,7 @@ class LocalStatsWidgetStateProvider
constructor(
nodeRepository: NodeRepository,
serviceRepository: ServiceRepository,
+ appWidgetUpdater: AppWidgetUpdater,
) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -105,11 +108,8 @@ constructor(
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
}
.distinctUntilChanged()
- .stateIn(
- scope = scope,
- started = SharingStarted.WhileSubscribed(5000),
- initialValue = LocalStatsWidgetUiState(),
- )
+ .onEach { appWidgetUpdater.updateAll() }
+ .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState())
private data class StateInput(
val connectionState: ConnectionState,
From 43f9aa0b500f14fb6e2a426673d1160cb3adb78e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 13:13:14 -0600
Subject: [PATCH 013/407] ci: Remove environment from github-release job
Removed the environment specification for the github-release job.
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.github/workflows/release.yml | 1 -
1 file changed, 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3f69ded64..84b7b70a3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -277,7 +277,6 @@ jobs:
github-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, release-google, release-fdroid]
- environment: Release
permissions:
contents: write
id-token: write
From 2e13b1ab17500c44f7b0c59bca6b9ed34f3db0ce Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 13:47:09 -0600
Subject: [PATCH 014/407] ci: release flow tweaks (#4723)
---
.../actions/calculate-version-code/action.yml | 19 ---
.../workflows/create-or-promote-release.yml | 122 +++---------------
.github/workflows/release.yml | 28 +---
.github/workflows/reusable-check.yml | 10 +-
4 files changed, 20 insertions(+), 159 deletions(-)
delete mode 100644 .github/actions/calculate-version-code/action.yml
diff --git a/.github/actions/calculate-version-code/action.yml b/.github/actions/calculate-version-code/action.yml
deleted file mode 100644
index 3af727e6f..000000000
--- a/.github/actions/calculate-version-code/action.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-name: 'Calculate Version Code'
-description: 'Calculates the Android versionCode based on the Git commit count plus an offset.'
-outputs:
- versionCode:
- description: "The calculated version code"
- value: ${{ steps.calculate_version.outputs.VERSION_CODE }}
-runs:
- using: 'composite'
- steps:
- - name: Calculate Version Code
- id: calculate_version
- shell: bash
- run: |
- # This action assumes that the repo has been checked out with `fetch-depth: 0`
- GIT_COMMIT_COUNT=$(git rev-list --count HEAD)
- OFFSET=30630
- VERSION_CODE=$((GIT_COMMIT_COUNT + OFFSET))
- echo "Calculated versionCode: $VERSION_CODE (from $GIT_COMMIT_COUNT commits + $OFFSET offset)"
- echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml
index 7b1365186..89d6254db 100644
--- a/.github/workflows/create-or-promote-release.yml
+++ b/.github/workflows/create-or-promote-release.yml
@@ -106,112 +106,6 @@ jobs:
fi
shell: bash
- - name: Update External Assets (Firmware, Hardware, Protos)
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- run: |
- # Update Submodules (Protobufs)
- echo "Updating core/proto submodule..."
- git submodule update --init --remote core/proto
-
- # Update Firmware List
- firmware_file_path="app/src/main/assets/firmware_releases.json"
- temp_firmware_file="/tmp/new_firmware_releases.json"
-
- echo "Fetching latest firmware releases..."
- curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file"
-
- if ! jq empty "$temp_firmware_file" 2>/dev/null; then
- echo "::error::Firmware API returned invalid JSON data. Aborting."
- exit 1
- else
- if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then
- echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path."
- cp "$temp_firmware_file" "$firmware_file_path"
- else
- echo "No changes detected in firmware list."
- fi
- fi
-
- # Update Hardware List
- hardware_file_path="app/src/main/assets/device_hardware.json"
- temp_hardware_file="/tmp/new_device_hardware.json"
-
- echo "Fetching latest device hardware data..."
- curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file"
-
- if ! jq empty "$temp_hardware_file" 2>/dev/null; then
- echo "::error::Hardware API returned invalid JSON data. Aborting."
- exit 1
- else
- if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then
- echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path."
- cp "$temp_hardware_file" "$hardware_file_path"
- else
- echo "No changes detected in hardware list."
- fi
- fi
-
- - name: Sync with Crowdin
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- uses: crowdin/github-action@v2
- with:
- base_url: 'https://meshtastic.crowdin.com/api/v2'
- config: 'crowdin.yml'
- crowdin_branch_name: 'main'
- upload_sources: true
- upload_sources_args: '--preserve-hierarchy'
- upload_translations: false
- download_translations: true
- download_translations_args: '--preserve-hierarchy'
- create_pull_request: false
- push_translations: false
- push_sources: false
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
- CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-
- - name: Commit Release Assets (Translations, Data, Config)
- if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
- env:
- FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }}
- run: |
- # Calculate Version Code
- OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
- COMMIT_COUNT=$(git rev-list --count HEAD)
- # +1 because we are about to add a commit
- VERSION_CODE=$((COMMIT_COUNT + OFFSET + 1))
-
- echo "Calculated Version Code: $VERSION_CODE"
-
- # Update VERSION_NAME_BASE in config.properties
- sed -i "s/^VERSION_NAME_BASE=.*/VERSION_NAME_BASE=${{ inputs.base_version }}/" config.properties
-
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- # Add updated data files
- git add config.properties
- git add app/src/main/assets/firmware_releases.json || true
- git add app/src/main/assets/device_hardware.json || true
- git add core/proto || true
-
- # Add updated translations (fastlane metadata and strings)
- git add fastlane/metadata/android || true
- git add "**/strings.xml" || true
-
- # Only commit if there are changes
- if ! git diff --cached --quiet; then
- git commit -m "chore(release): prepare $FINAL_TAG [skip ci]
-
- - Bump base version to ${{ inputs.base_version }}
- - Sync translations and assets"
- git push origin HEAD:${{ github.ref_name }}
- else
- echo "No changes to commit."
- fi
- shell: bash
-
- name: Create and Push Release Tag
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
@@ -244,3 +138,19 @@ jobs:
base_version: ${{ inputs.base_version }}
from_channel: ${{ needs.determine-tags.outputs.from_channel }}
secrets: inherit
+
+ cleanup-on-failure:
+ needs: [determine-tags, call-release-workflow]
+ if: ${{ failure() && !inputs.dry_run && inputs.channel == 'internal' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Delete Failed Tag
+ env:
+ FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
+ run: |
+ echo "Release workflow failed. Deleting tag $FINAL_TAG to allow a clean retry..."
+ git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84b7b70a3..41e5c1954 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,15 +60,6 @@ permissions:
attestations: write
jobs:
- run-lint:
- uses: ./.github/workflows/reusable-check.yml
- with:
- run_lint: true
- run_unit_tests: false
- run_instrumented_tests: false
- upload_artifacts: false
- secrets: inherit
-
prepare-build-info:
runs-on: ubuntu-latest
outputs:
@@ -85,19 +76,6 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
- with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
-
- name: Determine Version Name from Tag
id: get_version_name
run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
@@ -119,7 +97,7 @@ jobs:
release-google:
runs-on: ubuntu-latest
- needs: [prepare-build-info, run-lint]
+ needs: [prepare-build-info]
environment: Release
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
@@ -210,7 +188,7 @@ jobs:
release-fdroid:
runs-on: ubuntu-latest
- needs: [prepare-build-info, run-lint]
+ needs: [prepare-build-info]
environment: Release
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
@@ -286,7 +264,6 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
- fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v8
@@ -297,6 +274,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag_name }}
+ target_commitish: ${{ inputs.commit_sha || github.sha }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: false
files: ./artifacts/*/*
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 9805e1a17..b7df32393 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -75,16 +75,12 @@ jobs:
with:
dependency-graph: generate-and-submit
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- cache-cleanup: true
+ cache-cleanup: on-success
build-scan-publish: true
build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
build-scan-terms-of-use-agree: 'yes'
add-job-summary: always
- - name: Calculate Version Code
- id: calculate_version_code
- uses: ./.github/actions/calculate-version-code
-
- name: Determine Tasks
id: tasks
run: |
@@ -120,8 +116,6 @@ jobs:
- name: Run Flavor Check (with Emulator)
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
- env:
- VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
with:
api-level: ${{ matrix.api_level }}
arch: x86_64
@@ -132,8 +126,6 @@ jobs:
- name: Run Flavor Check (no Emulator)
if: inputs.run_instrumented_tests == false
- env:
- VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --parallel --configuration-cache --continue --scan
- name: Upload coverage results to Codecov
From c9005432ea63ed783414e9125453146984978c89 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:04:37 -0600
Subject: [PATCH 015/407] ci: improve release cleanup and optimize build tasks
(#4724)
---
.github/workflows/create-or-promote-release.yml | 12 ++++++++----
fastlane/Fastfile | 4 ++--
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml
index 89d6254db..053174c00 100644
--- a/.github/workflows/create-or-promote-release.yml
+++ b/.github/workflows/create-or-promote-release.yml
@@ -141,16 +141,20 @@ jobs:
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
- if: ${{ failure() && !inputs.dry_run && inputs.channel == 'internal' }}
+ if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: Delete Failed Tag
+ - name: Delete Failed or Cancelled Tag
env:
FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
run: |
- echo "Release workflow failed. Deleting tag $FINAL_TAG to allow a clean retry..."
- git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
+ if [ -n "$FINAL_TAG" ]; then
+ echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
+ git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
+ else
+ echo "No tag was created to delete."
+ fi
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index b817d9cd8..e4b607871 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -35,7 +35,7 @@ platform :android do
desc "Build the F-Droid release"
lane :fdroid_build do
gradle(
- task: "clean assembleFdroidRelease",
+ task: "assembleFdroidRelease",
properties: {
"android.injected.version.name" => ENV['VERSION_NAME'],
"android.injected.version.code" => ENV['VERSION_CODE']
@@ -46,7 +46,7 @@ platform :android do
desc "Build the Google Release"
private_lane :build_google_release do
gradle(
- task: "clean bundleGoogleRelease assembleGoogleRelease",
+ task: "bundleGoogleRelease assembleGoogleRelease",
print_command: false,
properties: {
"android.injected.version.name" => ENV['VERSION_NAME'],
From 79a4a3671f4de22daf4528239d59583834f867fe Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:26:57 -0600
Subject: [PATCH 016/407] ci: fix internal builds release failing the workflow
when secrets are missing (#4725)
---
.github/workflows/release.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 41e5c1954..7da796e6b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -282,6 +282,8 @@ jobs:
prerelease: true
- name: Create or Update internal GitHub Release
+ continue-on-error: true
+ if: ${{ secrets.INTERNAL_BUILDS_HOST != '' }}
uses: softprops/action-gh-release@v2
with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
From 9d9f95961d9a5122cc5318f137eabe7aa9aeff35 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:29:56 -0600
Subject: [PATCH 017/407] ci: fix secrets context not allowed in if conditional
(#4726)
---
.github/workflows/release.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7da796e6b..18f230a2d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -255,6 +255,8 @@ jobs:
github-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, release-google, release-fdroid]
+ env:
+ INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
permissions:
contents: write
id-token: write
@@ -283,7 +285,7 @@ jobs:
- name: Create or Update internal GitHub Release
continue-on-error: true
- if: ${{ secrets.INTERNAL_BUILDS_HOST != '' }}
+ if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
uses: softprops/action-gh-release@v2
with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
From a854c839e4f229c8dc050f155e82afbde210de6b Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:51:47 -0600
Subject: [PATCH 018/407] ci: Refine APK artifact paths and enable automatic
release notes generation (#4727)
---
.github/workflows/release.yml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 18f230a2d..18aa1d68e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -171,7 +171,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: google-apk
- path: app/build/outputs/apk/**/*.apk
+ path: app/build/outputs/apk/google/release/*.apk
retention-days: 1
- name: Attest Google AAB provenance
@@ -184,7 +184,7 @@ jobs:
if: always()
uses: actions/attest-build-provenance@v4
with:
- subject-path: app/build/outputs/apk/**/*.apk
+ subject-path: app/build/outputs/apk/google/release/*.apk
release-fdroid:
runs-on: ubuntu-latest
@@ -243,14 +243,14 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
- path: app/build/outputs/apk/**/*.apk
+ path: app/build/outputs/apk/fdroid/release/*.apk
retention-days: 1
- name: Attest F-Droid APK provenance
if: always()
uses: actions/attest-build-provenance@v4
with:
- subject-path: app/build/outputs/apk/**/*.apk
+ subject-path: app/build/outputs/apk/fdroid/release/*.apk
github-release:
runs-on: ubuntu-latest
@@ -278,7 +278,7 @@ jobs:
tag_name: ${{ inputs.tag_name }}
target_commitish: ${{ inputs.commit_sha || github.sha }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
- generate_release_notes: false
+ generate_release_notes: true
files: ./artifacts/*/*
draft: true
prerelease: true
From dfab02bfb43535c54c09dec0d4b31161f1b15162 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 14:56:49 -0600
Subject: [PATCH 019/407] refactor(ble): increase default timeout for BLE
profiling (#4728)
---
.../src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 e31ef96ef..5472eb704 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
@@ -157,7 +157,7 @@ class BleConnection(
@Suppress("TooGenericExceptionCaught")
suspend fun profile(
serviceUuid: Uuid,
- timeout: kotlin.time.Duration = 10.seconds,
+ timeout: kotlin.time.Duration = 30.seconds,
setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
): T {
val p = peripheralFlow.first { it != null }!!
From 87fdaa26ff0389f96d62736920ea11c3554ae9cc Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 16:06:21 -0600
Subject: [PATCH 020/407] refactor: enhance handshake stall guard and extend
coverage to Stage 2 (#4730)
---
.../data/manager/MeshConnectionManagerImpl.kt | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index a420793df..be2dd74c4 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -186,14 +186,16 @@ constructor(
Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
startConfigOnly()
+ }
- // Guard against handshake stalls
+ private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
+ handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
- Logger.w { "Handshake stall detected! Retrying Stage 1." }
- startConfigOnly()
+ Logger.w { "Handshake stall detected! Retrying Stage $stage." }
+ action()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
@@ -254,11 +256,15 @@ constructor(
}
override fun startConfigOnly() {
- packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
+ val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) }
+ startHandshakeStallGuard(1, action)
+ action()
}
override fun startNodeInfoOnly() {
- packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
+ val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
+ startHandshakeStallGuard(2, action)
+ action()
}
override fun onRadioConfigLoaded() {
@@ -340,7 +346,7 @@ constructor(
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
- private val HANDSHAKE_TIMEOUT = 10.seconds
+ private val HANDSHAKE_TIMEOUT = 30.seconds
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
From b9b68d2779236464d89d12b29bf55b159b3df453 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 20:37:35 -0600
Subject: [PATCH 021/407] refactor: migrate preferences to DataStore and
decouple core:domain for KMP (#4731)
---
.../filter/MessageFilterIntegrationTest.kt | 6 +-
.../geeksville/mesh/MeshUtilApplication.kt | 6 +-
.../com/geeksville/mesh/model/UIViewModel.kt | 2 +-
.../radio/AndroidRadioInterfaceService.kt | 8 +-
.../ui/connections/ConnectionsViewModel.kt | 6 +-
.../mesh/worker/MeshLogCleanupWorker.kt | 12 +-
core/analytics/build.gradle.kts | 1 +
.../platform/GooglePlatformAnalytics.kt | 9 +-
.../core/common/database}/DatabaseManager.kt | 2 +-
core/data/build.gradle.kts | 2 +
.../CustomTileProviderRepository.kt | 9 +-
.../core/data}/di/DataStoreModule.kt | 37 +++-
.../meshtastic/core/data/di/DatabaseModule.kt | 8 +-
.../core/data/di/RepositoryModule.kt | 6 +
.../core/data/manager/HistoryManagerImpl.kt | 8 +-
.../data/manager/MeshActionHandlerImpl.kt | 10 +-
.../data/manager/MeshConnectionManagerImpl.kt | 2 +-
.../data/manager/MeshMessageProcessorImpl.kt | 2 +-
.../core/data/manager/MessageFilterImpl.kt | 6 +-
.../core/data/manager/PacketHandlerImpl.kt | 2 +-
...Repository.kt => MeshLogRepositoryImpl.kt} | 48 ++---
.../manager/MeshConnectionManagerImplTest.kt | 2 +-
.../data/manager/MessageFilterImplTest.kt | 19 +-
.../data/manager/PacketHandlerImplTest.kt | 2 +-
.../data/repository/MeshLogRepositoryTest.kt | 8 +-
core/database/build.gradle.kts | 2 +-
.../core/database/DatabaseManager.kt | 2 +-
core/datastore/build.gradle.kts | 49 ++---
.../datastore/BootloaderWarningDataSource.kt | 3 +-
.../core/datastore/ChannelSetDataSource.kt | 0
.../core/datastore/LocalConfigDataSource.kt | 0
.../core/datastore/LocalStatsDataSource.kt | 0
.../core/datastore/ModuleConfigDataSource.kt | 0
.../datastore/RecentAddressesDataSource.kt | 3 +-
.../core/datastore/UiPreferencesDataSource.kt | 19 +-
.../core/datastore/model/RecentAddress.kt | 3 +-
.../serializer/ChannelSetSerializer.kt | 17 +-
.../serializer/LocalConfigSerializer.kt | 17 +-
.../serializer/LocalStatsSerializer.kt | 17 +-
.../serializer/ModuleConfigSerializer.kt | 18 +-
core/domain/build.gradle.kts | 2 -
.../usecase/settings/ExportDataUseCase.kt | 2 +-
.../usecase/settings/IsOtaCapableUseCase.kt | 8 +-
.../settings/SetDatabaseCacheLimitUseCase.kt | 2 +-
.../settings/SetMeshLogSettingsUseCase.kt | 10 +-
.../settings/SetProvideLocationUseCase.kt | 2 +-
.../settings/ToggleAnalyticsUseCase.kt | 4 +-
.../ToggleHomoglyphEncodingUseCase.kt | 4 +-
.../domain/usecase/SendMessageUseCaseTest.kt | 8 +-
.../usecase/settings/ExportDataUseCaseTest.kt | 2 +-
.../settings/IsOtaCapableUseCaseTest.kt | 8 +-
.../SetDatabaseCacheLimitUseCaseTest.kt | 2 +-
.../settings/SetMeshLogSettingsUseCaseTest.kt | 12 +-
.../settings/SetProvideLocationUseCaseTest.kt | 2 +-
.../settings/ToggleAnalyticsUseCaseTest.kt | 10 +-
.../ToggleHomoglyphEncodingUseCaseTest.kt | 10 +-
core/prefs/build.gradle.kts | 5 +
.../core/prefs/di/GoogleMapsModule.kt | 28 ++-
.../core/prefs/map/GoogleMapsPrefs.kt | 181 ++++++++++++++---
.../core/prefs/DoublePrefDelegate.kt | 39 ----
.../core/prefs/FloatPrefDelegate.kt | 34 ----
.../core/prefs/NullableStringPrefDelegate.kt | 48 -----
.../org/meshtastic/core/prefs/PrefDelegate.kt | 61 ------
.../core/prefs/StringSetPrefDelegate.kt | 35 ----
.../core/prefs/analytics/AnalyticsPrefs.kt | 82 --------
.../prefs/analytics/AnalyticsPrefsImpl.kt | 78 ++++++++
.../meshtastic/core/prefs/di/PrefsModule.kt | 188 +++++++++++-------
.../core/prefs/emoji/CustomEmojiPrefs.kt | 34 ----
.../core/prefs/emoji/CustomEmojiPrefsImpl.kt | 64 ++++++
.../core/prefs/filter/FilterPrefs.kt | 50 -----
.../core/prefs/filter/FilterPrefsImpl.kt | 70 +++++++
.../core/prefs/homoglyph/HomoglyphPrefs.kt | 68 -------
.../prefs/homoglyph/HomoglyphPrefsImpl.kt | 56 ++++++
.../core/prefs/map/MapConsentPrefs.kt | 40 ----
.../core/prefs/map/MapConsentPrefsImpl.kt | 56 ++++++
.../org/meshtastic/core/prefs/map/MapPrefs.kt | 44 ----
.../meshtastic/core/prefs/map/MapPrefsImpl.kt | 97 +++++++++
.../core/prefs/map/MapTileProviderPrefs.kt | 34 ----
.../prefs/map/MapTileProviderPrefsImpl.kt | 64 ++++++
.../meshtastic/core/prefs/mesh/MeshPrefs.kt | 77 -------
.../core/prefs/mesh/MeshPrefsImpl.kt | 114 +++++++++++
.../core/prefs/meshlog/MeshLogPrefs.kt | 56 ------
.../core/prefs/meshlog/MeshLogPrefsImpl.kt | 73 +++++++
.../meshtastic/core/prefs/radio/RadioPrefs.kt | 43 ----
.../core/prefs/radio/RadioPrefsImpl.kt | 63 ++++++
.../org/meshtastic/core/prefs/ui/UiPrefs.kt | 77 -------
.../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 81 ++++++++
.../core/prefs/filter/FilterPrefsTest.kt | 70 ++++---
core/repository/build.gradle.kts | 1 +
.../core/repository/AppPreferences.kt | 173 ++++++++++++++++
.../core/repository/HomoglyphPrefs.kt | 21 --
.../core/repository/MeshLogRepository.kt | 82 ++++++++
.../repository/usecase/SendMessageUseCase.kt | 2 +-
.../core/ui/emoji/EmojiPickerViewModel.kt | 9 +-
.../feature/firmware/FirmwareUpdateManager.kt | 10 +-
.../firmware/FirmwareUpdateViewModel.kt | 10 +-
.../meshtastic/feature/map/MapViewModel.kt | 6 +-
.../meshtastic/feature/map/MapViewModel.kt | 63 +++---
.../feature/map/BaseMapViewModel.kt | 23 ++-
.../feature/map/node/NodeMapViewModel.kt | 6 +-
.../feature/map/MapViewModelTest.kt | 18 +-
.../feature/messaging/MessageViewModel.kt | 14 +-
.../domain/usecase/GetNodeDetailsUseCase.kt | 2 +-
.../feature/node/metrics/MetricsViewModel.kt | 10 +-
.../feature/settings/SettingsViewModel.kt | 10 +-
.../settings/debugging/DebugViewModel.kt | 14 +-
.../filter/FilterSettingsViewModel.kt | 16 +-
.../settings/radio/RadioConfigViewModel.kt | 10 +-
.../radio/component/MQTTConfigItemList.kt | 3 +-
.../feature/settings/SettingsViewModelTest.kt | 6 +-
.../settings/debugging/DebugViewModelTest.kt | 12 +-
.../filter/FilterSettingsViewModelTest.kt | 12 +-
.../radio/RadioConfigViewModelTest.kt | 6 +-
113 files changed, 1790 insertions(+), 1320 deletions(-)
rename core/{repository/src/commonMain/kotlin/org/meshtastic/core/repository => common/src/commonMain/kotlin/org/meshtastic/core/common/database}/DatabaseManager.kt (96%)
rename core/{datastore/src/main/kotlin/org/meshtastic/core/datastore => data/src/main/kotlin/org/meshtastic/core/data}/di/DataStoreModule.kt (83%)
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{MeshLogRepository.kt => MeshLogRepositoryImpl.kt} (80%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt (98%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt (100%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt (100%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt (100%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt (100%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt (99%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt (90%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt (95%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt (72%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt (72%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt (72%)
rename core/datastore/src/{main => commonMain}/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt (71%)
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/NullableStringPrefDelegate.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/PrefDelegate.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/StringSetPrefDelegate.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt
delete mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt
create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
delete 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/MeshLogRepository.kt
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 2c327a7af..efa229881 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
+++ b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
@@ -25,7 +25,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@@ -46,8 +46,8 @@ class MessageFilterIntegrationTest {
@Test
fun filterPrefsIntegration() = runTest {
- filterPrefs.filterEnabled = true
- filterPrefs.filterWords = setOf("test", "spam")
+ filterPrefs.setFilterEnabled(true)
+ filterPrefs.setFilterWords(setOf("test", "spam"))
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))
diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
index 9843c49f9..6e1573f2d 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
@@ -44,8 +44,8 @@ import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.prefs.mesh.MeshPrefs
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshPrefs
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
@@ -114,7 +114,7 @@ open class MeshUtilApplication :
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
- applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
+ applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress.value) }
}
override fun onTerminate() {
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 a3511ca74..77a6cde1f 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
@@ -42,7 +42,6 @@ import org.jetbrains.compose.resources.StringResource
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.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.MeshActivity
@@ -52,6 +51,7 @@ 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.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
index 47230a08a..bab2fc843 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
@@ -51,8 +51,8 @@ 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.core.repository.RadioPrefs
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -92,7 +92,7 @@ constructor(
val connectionError: SharedFlow = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
- private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
+ private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
private val logSends = false
@@ -192,7 +192,7 @@ constructor(
*/
override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
- var address = radioPrefs.devAddr
+ var address = radioPrefs.devAddr.value
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the
// user
@@ -352,7 +352,7 @@ constructor(
Logger.d { "Setting bonded device to ${address.anonymize}" }
// Stores the address if non-null, otherwise removes the pref
- radioPrefs.devAddr = address
+ radioPrefs.setDevAddr(address)
_currentDeviceAddressFlow.value = address
// Force the service to reconnect
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 b17281ff6..e7a363725 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
@@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
-import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@@ -50,11 +50,11 @@ constructor(
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
- private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
+ private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow()
fun suppressNoPairedWarning() {
_hasShownNotPairedWarning.value = true
- uiPrefs.hasShownNotPairedWarning = true
+ uiPrefs.setHasShownNotPairedWarning(true)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt
index 72f11ce87..d84e961bd 100644
--- a/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt
+++ b/app/src/main/java/com/geeksville/mesh/worker/MeshLogCleanupWorker.kt
@@ -27,8 +27,8 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
@HiltWorker
class MeshLogCleanupWorker
@@ -53,14 +53,14 @@ constructor(
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = try {
- val retentionDays = meshLogPrefs.retentionDays
- if (!meshLogPrefs.loggingEnabled) {
+ val retentionDays = meshLogPrefs.retentionDays.value
+ if (!meshLogPrefs.loggingEnabled.value) {
logger.i { "Skipping cleanup because mesh log storage is disabled" }
- } else if (retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) {
+ } else if (retentionDays == 0) {
logger.i { "Skipping cleanup because retention is set to never delete" }
} else {
val retentionLabel =
- if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) {
+ if (retentionDays == -1) {
"1 hour"
} else {
"$retentionDays days"
diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts
index efb532fcf..5ee46fe82 100644
--- a/core/analytics/build.gradle.kts
+++ b/core/analytics/build.gradle.kts
@@ -27,6 +27,7 @@ plugins {
dependencies {
implementation(projects.core.prefs)
+ implementation(projects.core.repository)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.lifecycle.process)
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
index 7bd13f840..c3133b8f4 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
+++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
@@ -58,7 +58,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.BuildConfig
import org.meshtastic.core.analytics.DataPair
-import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
import co.touchlab.kermit.Logger as KermitLogger
@@ -109,11 +109,10 @@ constructor(
KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info)
// Initial consent state
- updateAnalyticsConsent(analyticsPrefs.analyticsAllowed)
+ updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value)
// Subscribe to analytics preference changes
- analyticsPrefs
- .getAnalyticsAllowedChangesFlow()
+ analyticsPrefs.analyticsAllowed
.onEach { allowed -> updateAnalyticsConsent(allowed) }
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
@@ -122,7 +121,7 @@ constructor(
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
- if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
+ if (!analyticsPrefs.analyticsAllowed.value || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
similarity index 96%
rename from core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
index 675092382..86cc549b0 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.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 org.meshtastic.core.repository
+package org.meshtastic.core.common.database
import kotlinx.coroutines.flow.StateFlow
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 1279e4a5c..6da9b686c 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -31,6 +31,8 @@ dependencies {
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
+ implementation(libs.androidx.datastore)
+ implementation(libs.androidx.datastore.preferences)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.network)
diff --git a/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt b/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt
index 9ce615f53..5fbe32d92 100644
--- a/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt
+++ b/core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.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.repository
import co.touchlab.kermit.Logger
@@ -26,7 +25,7 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.prefs.map.MapTileProviderPrefs
+import org.meshtastic.core.repository.MapTileProviderPrefs
import javax.inject.Inject
import javax.inject.Singleton
@@ -82,7 +81,7 @@ constructor(
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
- val jsonString = mapTileProviderPrefs.customTileProviders
+ val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString>(jsonString)
@@ -99,7 +98,7 @@ constructor(
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
- mapTileProviderPrefs.customTileProviders = jsonString
+ mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt
similarity index 83%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt
index 079be59b7..b34e2f52c 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt
@@ -14,12 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.datastore.di
+package org.meshtastic.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
+import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
@@ -34,6 +35,8 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import okio.FileSystem
+import okio.Path.Companion.toOkioPath
import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED
import org.meshtastic.core.datastore.KEY_INCLUDE_UNKNOWN
import org.meshtastic.core.datastore.KEY_NODE_SORT
@@ -102,8 +105,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
- serializer = LocalConfigSerializer,
- produceFile = { appContext.dataStoreFile("local_config.pb") },
+ storage =
+ OkioStorage(
+ fileSystem = FileSystem.SYSTEM,
+ serializer = LocalConfigSerializer,
+ producePath = { appContext.dataStoreFile("local_config.pb").toOkioPath() },
+ ),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
)
@@ -114,8 +121,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
- serializer = ModuleConfigSerializer,
- produceFile = { appContext.dataStoreFile("module_config.pb") },
+ storage =
+ OkioStorage(
+ fileSystem = FileSystem.SYSTEM,
+ serializer = ModuleConfigSerializer,
+ producePath = { appContext.dataStoreFile("module_config.pb").toOkioPath() },
+ ),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
)
@@ -126,8 +137,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
- serializer = ChannelSetSerializer,
- produceFile = { appContext.dataStoreFile("channel_set.pb") },
+ storage =
+ OkioStorage(
+ fileSystem = FileSystem.SYSTEM,
+ serializer = ChannelSetSerializer,
+ producePath = { appContext.dataStoreFile("channel_set.pb").toOkioPath() },
+ ),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
@@ -138,8 +153,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
- serializer = LocalStatsSerializer,
- produceFile = { appContext.dataStoreFile("local_stats.pb") },
+ storage =
+ OkioStorage(
+ fileSystem = FileSystem.SYSTEM,
+ serializer = LocalStatsSerializer,
+ producePath = { appContext.dataStoreFile("local_stats.pb").toOkioPath() },
+ ),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt
index c21be1920..6660fb87d 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt
@@ -20,13 +20,15 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.database.DatabaseManager
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
interface DatabaseModule {
- @Binds @Singleton
- fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
+ @Binds
+ @Singleton
+ fun bindDatabaseManager(
+ impl: org.meshtastic.core.database.DatabaseManager,
+ ): org.meshtastic.core.common.database.DatabaseManager
}
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
index 333398c10..5c48a3745 100644
--- 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
@@ -38,6 +38,7 @@ 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.MeshLogRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
@@ -51,6 +52,7 @@ 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.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MessageFilter
@@ -85,6 +87,10 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
+ @Binds
+ @Singleton
+ abstract fun bindMeshLogRepository(meshLogRepositoryImpl: MeshLogRepositoryImpl): MeshLogRepository
+
@Binds @Singleton
abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index a2df3d73a..085966a2b 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -18,8 +18,8 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
-import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
@@ -71,7 +71,7 @@ constructor(
}
private fun activeDeviceAddress(): String? =
- meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
+ meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
override fun requestHistoryReplay(
trigger: String,
@@ -86,7 +86,7 @@ constructor(
return
}
- val lastRequest = meshPrefs.getStoreForwardLastRequest(address)
+ val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value
val (window, max) =
resolveHistoryRequestParameters(
storeForwardConfig?.history_return_window ?: 0,
@@ -116,7 +116,7 @@ constructor(
override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
if (lastRequest <= 0) return
val address = activeDeviceAddress() ?: return
- val current = meshPrefs.getStoreForwardLastRequest(address)
+ val current = meshPrefs.getStoreForwardLastRequest(address).value
if (lastRequest != current) {
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
historyLog(
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index 15b3e8b90..07b30c0a7 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -23,6 +23,7 @@ import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
@@ -32,12 +33,11 @@ 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.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.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
@@ -197,7 +197,7 @@ constructor(
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
- val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
+ val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
@@ -343,9 +343,9 @@ constructor(
}
override fun handleUpdateLastAddress(deviceAddr: String?) {
- val currentAddr = meshPrefs.deviceAddress
+ val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
- meshPrefs.deviceAddress = deviceAddr
+ meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()
messageProcessor.get().clearEarlyPackets()
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index be2dd74c4..fbd87000c 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -35,7 +35,6 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
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
@@ -52,6 +51,7 @@ 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.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 1c19c8f31..cda802c89 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -27,11 +27,11 @@ import kotlinx.coroutines.flow.onEach
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.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.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
index 906e615ae..a907c9a9f 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
@@ -17,7 +17,7 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
-import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
@@ -33,7 +33,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
}
override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
- if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
+ if (!filterPrefs.filterEnabled.value || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
val textToCheck = message.take(MAX_CHECK_LENGTH)
@@ -42,7 +42,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
override fun rebuildPatterns() {
compiledPatterns =
- filterPrefs.filterWords.mapNotNull { word ->
+ filterPrefs.filterWords.value.mapNotNull { word ->
try {
if (word.startsWith(REGEX_PREFIX)) {
Regex(word.removePrefix(REGEX_PREFIX), RegexOption.IGNORE_CASE)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index a29cfed98..a42e77810 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -28,7 +28,6 @@ import kotlinx.coroutines.withTimeout
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.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@@ -36,6 +35,7 @@ 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.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
similarity index 80%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
index 24a1cc825..7c09f1582 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
@@ -30,7 +30,9 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
+import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
@@ -39,48 +41,48 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
- * Repository for managing and retrieving logs from the local database.
+ * Repository implementation for managing and retrieving logs from the local database.
*
* This repository provides methods for inserting, deleting, and querying logs, including specialized methods for
* telemetry and traceroute data.
*/
@Suppress("TooManyFunctions")
@Singleton
-class MeshLogRepository
+class MeshLogRepositoryImpl
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
private val meshLogPrefs: MeshLogPrefs,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
-) {
+) : MeshLogRepository {
/** Retrieves all [MeshLog]s in the database, up to [maxItem]. */
- fun getAllLogs(maxItem: Int = MAX_MESH_PACKETS): Flow> =
+ override fun getAllLogs(maxItem: Int): Flow> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItem) }.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database in the order they were received. */
- fun getAllLogsInReceiveOrder(maxItem: Int = MAX_MESH_PACKETS): Flow> =
+ override fun getAllLogsInReceiveOrder(maxItem: Int): Flow> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database without any limit. */
- fun getAllLogsUnbounded(): Flow> = getAllLogs(Int.MAX_VALUE)
+ override fun getAllLogsUnbounded(): Flow> = getAllLogs(Int.MAX_VALUE)
/** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
- fun getLogsFrom(nodeNum: Int, portNum: Int): Flow> = dbManager.currentDb
- .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS) }
+ override fun getLogsFrom(nodeNum: Int, portNum: Int): Flow> = dbManager.currentDb
+ .flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, DEFAULT_MAX_LOGS) }
.distinctUntilChanged()
.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s containing [MeshPacket]s for a specific [nodeNum]. */
- fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = -1): Flow> =
+ override fun getMeshPacketsFrom(nodeNum: Int, portNum: Int): Flow> =
getLogsFrom(nodeNum, portNum).map { list -> list.mapNotNull { it.fromRadio.packet } }.flowOn(dispatchers.io)
/** Retrieves telemetry history for a specific node, automatically handling local node redirection. */
- fun getTelemetryFrom(nodeNum: Int): Flow> = effectiveLogId(nodeNum)
+ override fun getTelemetryFrom(nodeNum: Int): Flow> = effectiveLogId(nodeNum)
.flatMapLatest { logId ->
dbManager.currentDb
- .flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) }
+ .flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, DEFAULT_MAX_LOGS) }
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
}
@@ -91,8 +93,8 @@ constructor(
*
* A request log is defined as an outgoing packet (`fromNum = 0`) where `want_response` is true.
*/
- fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow> = dbManager.currentDb
- .flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, MAX_MESH_PACKETS) }
+ override fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow> = dbManager.currentDb
+ .flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, DEFAULT_MAX_LOGS) }
.map { list ->
list.filter { log ->
val packet = log.fromRadio.packet ?: return@filter false
@@ -140,26 +142,27 @@ constructor(
.distinctUntilChanged()
/** Returns the cached [MyNodeInfo] from the system logs. */
- fun getMyNodeInfo(): Flow = dbManager.currentDb
- .flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, MAX_MESH_PACKETS) }
+ override fun getMyNodeInfo(): Flow = dbManager.currentDb
+ .flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, DEFAULT_MAX_LOGS) }
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)
/** Persists a new log entry to the database if logging is enabled in preferences. */
- suspend fun insert(log: MeshLog) = withContext(dispatchers.io) {
- if (!meshLogPrefs.loggingEnabled) return@withContext
+ override suspend fun insert(log: MeshLog) = withContext(dispatchers.io) {
+ if (!meshLogPrefs.loggingEnabled.value) return@withContext
dbManager.currentDb.value.meshLogDao().insert(log)
}
/** Clears all logs from the database. */
- suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() }
+ override suspend fun deleteAll() =
+ withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() }
/** Deletes a specific log entry by its [uuid]. */
- suspend fun deleteLog(uuid: String) =
+ override suspend fun deleteLog(uuid: String) =
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLog(uuid) }
/** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
- suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) {
+ override suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) {
val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum
val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum
dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum)
@@ -167,13 +170,12 @@ constructor(
/** Prunes the log database based on the configured [retentionDays]. */
@Suppress("MagicNumber")
- suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
+ override suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
val cutoffTime = nowMillis - (retentionDays.toLong() * 24 * 60 * 60 * 1000)
dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTime)
}
companion object {
- private const val MAX_MESH_PACKETS = 5000
private const val MILLIS_PER_SEC = 1000L
}
}
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index c21b43c69..258756e9c 100644
--- a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -36,7 +36,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
-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
@@ -52,6 +51,7 @@ 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.repository.UiPrefs
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt
index 65c77ec7e..d7e7c565d 100644
--- a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt
@@ -18,34 +18,39 @@ package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.FilterPrefs
class MessageFilterImplTest {
private lateinit var filterPrefs: FilterPrefs
+ private lateinit var filterEnabledFlow: MutableStateFlow
+ private lateinit var filterWordsFlow: MutableStateFlow>
private lateinit var filterService: MessageFilterImpl
@Before
fun setup() {
+ filterEnabledFlow = MutableStateFlow(true)
+ filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
filterPrefs = mockk {
- every { filterEnabled } returns true
- every { filterWords } returns setOf("spam", "bad")
+ every { filterEnabled } returns filterEnabledFlow
+ every { filterWords } returns filterWordsFlow
}
filterService = MessageFilterImpl(filterPrefs)
}
@Test
fun `shouldFilter returns false when filter is disabled`() {
- every { filterPrefs.filterEnabled } returns false
+ filterEnabledFlow.value = false
assertFalse(filterService.shouldFilter("spam message"))
}
@Test
fun `shouldFilter returns false when filter words is empty`() {
- every { filterPrefs.filterWords } returns emptySet()
+ filterWordsFlow.value = emptySet()
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@@ -70,7 +75,7 @@ class MessageFilterImplTest {
@Test
fun `shouldFilter supports regex patterns`() {
- every { filterPrefs.filterWords } returns setOf("regex:test\\d+")
+ filterWordsFlow.value = setOf("regex:test\\d+")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is test123"))
assertFalse(filterService.shouldFilter("this is test"))
@@ -78,7 +83,7 @@ class MessageFilterImplTest {
@Test
fun `shouldFilter handles invalid regex gracefully`() {
- every { filterPrefs.filterWords } returns setOf("regex:[invalid")
+ filterWordsFlow.value = setOf("regex:[invalid")
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
index 4447ec440..2486922ac 100644
--- a/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
@@ -26,9 +26,9 @@ 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.model.ConnectionState
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
index 78c56d8c1..06afd655e 100644
--- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt
@@ -36,7 +36,7 @@ import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
@@ -55,7 +55,7 @@ class MeshLogRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
- private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
+ private val repository = MeshLogRepositoryImpl(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
init {
every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
@@ -81,7 +81,7 @@ class MeshLogRepositoryTest {
)
// Using reflection to test private method parseTelemetryLog
- val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
+ val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
@@ -107,7 +107,7 @@ class MeshLogRepositoryTest {
fromRadio = FromRadio(packet = meshPacket),
)
- val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
+ val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index e97a8d3ed..026a9b410 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -32,7 +32,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.androidx.sqlite.bundled)
- implementation(projects.core.repository)
+
api(projects.core.common)
implementation(projects.core.di)
api(projects.core.model)
diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 1a6181f92..e5c96cd41 100644
--- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -40,7 +40,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
-import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager
+import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index 40c3a389d..874153009 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -14,38 +14,29 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-import com.android.build.api.dsl.LibraryExtension
-
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
plugins {
- alias(libs.plugins.meshtastic.android.library)
- alias(libs.plugins.meshtastic.hilt)
+ alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
+ alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.devtools.ksp)
}
-configure { namespace = "org.meshtastic.core.datastore" }
+kotlin {
+ android { namespace = "org.meshtastic.core.datastore" }
-dependencies {
- implementation(projects.core.proto)
-
- implementation(libs.androidx.datastore)
- implementation(libs.androidx.datastore.preferences)
- implementation(libs.kotlinx.serialization.json)
- implementation(libs.kermit)
+ sourceSets {
+ commonMain.dependencies {
+ implementation(projects.core.proto)
+ implementation(libs.androidx.datastore)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kermit)
+ }
+ androidMain.dependencies {
+ implementation(libs.hilt.android)
+ implementation(libs.javax.inject)
+ }
+ }
}
+
+dependencies { "kspAndroid"(libs.hilt.compiler) }
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
similarity index 98%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
index f90176671..5eda0ca4c 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.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.datastore
import androidx.datastore.core.DataStore
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt
similarity index 100%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ChannelSetDataSource.kt
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt
similarity index 100%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/LocalConfigDataSource.kt
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
similarity index 100%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
similarity index 100%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/ModuleConfigDataSource.kt
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
similarity index 99%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
index 63501dc91..0d3c4c123 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.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.datastore
import androidx.datastore.core.DataStore
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
similarity index 90%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
index 69a49a521..02634293e 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.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.datastore
import androidx.datastore.core.DataStore
@@ -33,16 +32,16 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
-internal const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
-internal const val KEY_THEME = "theme"
+const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
+const val KEY_THEME = "theme"
// Node list filters/sort
-internal const val KEY_NODE_SORT = "node-sort-option"
-internal const val KEY_INCLUDE_UNKNOWN = "include-unknown"
-internal const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
-internal const val KEY_ONLY_ONLINE = "only-online"
-internal const val KEY_ONLY_DIRECT = "only-direct"
-internal const val KEY_SHOW_IGNORED = "show-ignored"
+const val KEY_NODE_SORT = "node-sort-option"
+const val KEY_INCLUDE_UNKNOWN = "include-unknown"
+const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
+const val KEY_ONLY_ONLINE = "only-online"
+const val KEY_ONLY_DIRECT = "only-direct"
+const val KEY_SHOW_IGNORED = "show-ignored"
@Singleton
class UiPreferencesDataSource @Inject constructor(private val dataStore: DataStore) {
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt
similarity index 95%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt
index 4cbb90320..f3a087f04 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/model/RecentAddress.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/model/RecentAddress.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.datastore.model
import kotlinx.serialization.Serializable
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
similarity index 72%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
index 800b099f2..a46b2f4f7 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
-import androidx.datastore.core.Serializer
+import androidx.datastore.core.okio.OkioSerializer
+import okio.BufferedSink
+import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.ChannelSet
-import java.io.InputStream
-import java.io.OutputStream
/** Serializer for the [ChannelSet] object defined in apponly.proto. */
-@Suppress("BlockingMethodInNonBlockingContext")
-object ChannelSetSerializer : Serializer {
+object ChannelSetSerializer : OkioSerializer {
override val defaultValue: ChannelSet = ChannelSet()
- override suspend fun readFrom(input: InputStream): ChannelSet {
+ override suspend fun readFrom(source: BufferedSource): ChannelSet {
try {
- return ChannelSet.ADAPTER.decode(input)
+ return ChannelSet.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
- override suspend fun writeTo(t: ChannelSet, output: OutputStream) = ChannelSet.ADAPTER.encode(output, t)
+ override suspend fun writeTo(t: ChannelSet, sink: BufferedSink) {
+ ChannelSet.ADAPTER.encode(sink, t)
+ }
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
similarity index 72%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
index f356aa158..14988d461 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
-import androidx.datastore.core.Serializer
+import androidx.datastore.core.okio.OkioSerializer
+import okio.BufferedSink
+import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalConfig
-import java.io.InputStream
-import java.io.OutputStream
/** Serializer for the [LocalConfig] object defined in localonly.proto. */
-@Suppress("BlockingMethodInNonBlockingContext")
-object LocalConfigSerializer : Serializer {
+object LocalConfigSerializer : OkioSerializer {
override val defaultValue: LocalConfig = LocalConfig()
- override suspend fun readFrom(input: InputStream): LocalConfig {
+ override suspend fun readFrom(source: BufferedSource): LocalConfig {
try {
- return LocalConfig.ADAPTER.decode(input)
+ return LocalConfig.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
- override suspend fun writeTo(t: LocalConfig, output: OutputStream) = LocalConfig.ADAPTER.encode(output, t)
+ override suspend fun writeTo(t: LocalConfig, sink: BufferedSink) {
+ LocalConfig.ADAPTER.encode(sink, t)
+ }
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
similarity index 72%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
index 8f1e2d68f..83b9f5481 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
-import androidx.datastore.core.Serializer
+import androidx.datastore.core.okio.OkioSerializer
+import okio.BufferedSink
+import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalStats
-import java.io.InputStream
-import java.io.OutputStream
/** Serializer for the [LocalStats] object defined in telemetry.proto. */
-@Suppress("BlockingMethodInNonBlockingContext")
-object LocalStatsSerializer : Serializer {
+object LocalStatsSerializer : OkioSerializer {
override val defaultValue: LocalStats = LocalStats()
- override suspend fun readFrom(input: InputStream): LocalStats {
+ override suspend fun readFrom(source: BufferedSource): LocalStats {
try {
- return LocalStats.ADAPTER.decode(input)
+ return LocalStats.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
- override suspend fun writeTo(t: LocalStats, output: OutputStream) = LocalStats.ADAPTER.encode(output, t)
+ override suspend fun writeTo(t: LocalStats, sink: BufferedSink) {
+ LocalStats.ADAPTER.encode(sink, t)
+ }
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
similarity index 71%
rename from core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
rename to core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
index 14087b4fd..419ca6970 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
@@ -17,25 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
-import androidx.datastore.core.Serializer
+import androidx.datastore.core.okio.OkioSerializer
+import okio.BufferedSink
+import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalModuleConfig
-import java.io.InputStream
-import java.io.OutputStream
/** Serializer for the [LocalModuleConfig] object defined in localonly.proto. */
-@Suppress("BlockingMethodInNonBlockingContext")
-object ModuleConfigSerializer : Serializer {
+object ModuleConfigSerializer : OkioSerializer {
override val defaultValue: LocalModuleConfig = LocalModuleConfig()
- override suspend fun readFrom(input: InputStream): LocalModuleConfig {
+ override suspend fun readFrom(source: BufferedSource): LocalModuleConfig {
try {
- return LocalModuleConfig.ADAPTER.decode(input)
+ return LocalModuleConfig.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
- override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) =
- LocalModuleConfig.ADAPTER.encode(output, t)
+ override suspend fun writeTo(t: LocalModuleConfig, sink: BufferedSink) {
+ LocalModuleConfig.ADAPTER.encode(sink, t)
+ }
}
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index c368cd45d..d78eb1c6c 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -29,8 +29,6 @@ dependencies {
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)
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
index aea9301d4..ce7261863 100644
--- 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
@@ -18,9 +18,9 @@ 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.model.Position
import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
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
index f77a09345..1707a7500 100644
--- 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
@@ -23,12 +23,12 @@ import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
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 org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.repository.isBle
+import org.meshtastic.core.repository.isSerial
+import org.meshtastic.core.repository.isTcp
import javax.inject.Inject
/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
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
index 42224e849..4b46cd70c 100644
--- 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
@@ -16,8 +16,8 @@
*/
package org.meshtastic.core.domain.usecase.settings
+import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
-import org.meshtastic.core.repository.DatabaseManager
import javax.inject.Inject
/** Use case for setting the database cache limit. */
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
index cdb822dde..b18133635 100644
--- 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
@@ -16,8 +16,8 @@
*/
package org.meshtastic.core.domain.usecase.settings
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
import javax.inject.Inject
/** Use case for managing mesh log settings. */
@@ -34,7 +34,7 @@ constructor(
*/
suspend fun setRetentionDays(days: Int) {
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
- meshLogPrefs.retentionDays = clamped
+ meshLogPrefs.setRetentionDays(clamped)
meshLogRepository.deleteLogsOlderThan(clamped)
}
@@ -44,11 +44,11 @@ constructor(
* @param enabled True to enable logging, false to disable.
*/
suspend fun setLoggingEnabled(enabled: Boolean) {
- meshLogPrefs.loggingEnabled = enabled
+ meshLogPrefs.setLoggingEnabled(enabled)
if (!enabled) {
meshLogRepository.deleteAll()
} else {
- meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays)
+ meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value)
}
}
}
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
index 3a45c3e43..e66651f9c 100644
--- 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
@@ -16,7 +16,7 @@
*/
package org.meshtastic.core.domain.usecase.settings
-import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.repository.UiPrefs
import javax.inject.Inject
/** Use case for setting whether to provide the node location to the mesh. */
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
index b8e6f2d29..92aa6933c 100644
--- 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
@@ -16,12 +16,12 @@
*/
package org.meshtastic.core.domain.usecase.settings
-import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
/** Use case for toggling the analytics preference. */
open class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
operator fun invoke() {
- analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
}
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
index f42dee80b..37d693e1f 100644
--- 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
@@ -16,12 +16,12 @@
*/
package org.meshtastic.core.domain.usecase.settings
-import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
import javax.inject.Inject
/** Use case for toggling the homoglyph encoding preference. */
open class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
operator fun invoke() {
- homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
index fac5b04e4..c10045b88 100644
--- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
@@ -81,7 +81,7 @@ class SendMessageUseCaseTest {
val ourNode = mockk(relaxed = true)
every { ourNode.user.id } returns "!1234"
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
@@ -110,7 +110,7 @@ class SendMessageUseCaseTest {
every { destNode.num } returns 12345
every { nodeRepository.getNode("!dest") } returns destNode
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
every { anyConstructed().canSendVerifiedContacts } returns false
// Act
@@ -139,7 +139,7 @@ class SendMessageUseCaseTest {
every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
every { anyConstructed().canSendVerifiedContacts } returns true
// Act
@@ -158,7 +158,7 @@ class SendMessageUseCaseTest {
// Arrange
val ourNode = mockk(relaxed = true)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
val originalText = "\u0410pple" // Cyrillic A
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
index 5e3a05cab..f97ffe525 100644
--- 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
@@ -26,9 +26,9 @@ 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.database.entity.MeshLog
import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
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
index 8e6b21077..dc17b7cd2 100644
--- 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
@@ -29,9 +29,9 @@ import org.junit.Test
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioPrefs
class IsOtaCapableUseCaseTest {
@@ -82,7 +82,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
- every { radioPrefs.devAddr } returns "m123" // Mock
+ every { radioPrefs.devAddr } returns MutableStateFlow("m123") // Mock
useCase().test {
assertFalse(awaitItem())
@@ -95,7 +95,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
- every { radioPrefs.devAddr } returns "x123" // BLE
+ every { radioPrefs.devAddr } returns MutableStateFlow("x123") // BLE
val hw = mockk { every { requiresDfu } returns true }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
@@ -111,7 +111,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
- every { radioPrefs.devAddr } returns "x123" // BLE
+ every { radioPrefs.devAddr } returns MutableStateFlow("x123") // BLE
val hw = mockk { every { requiresDfu } returns false }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
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
index 78a22de2f..8a31155ad 100644
--- 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
@@ -20,8 +20,8 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
+import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
-import org.meshtastic.core.repository.DatabaseManager
class SetDatabaseCacheLimitUseCaseTest {
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
index 748587b6a..cac857b69 100644
--- 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
@@ -23,8 +23,8 @@ 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
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
class SetMeshLogSettingsUseCaseTest {
@@ -45,20 +45,20 @@ class SetMeshLogSettingsUseCaseTest {
useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
// Assert
- verify { meshLogPrefs.retentionDays = MeshLogPrefs.MIN_RETENTION_DAYS }
+ verify { meshLogPrefs.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS) }
coVerify { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
}
@Test
fun `setLoggingEnabled true triggers cleanup`() = runTest {
// Arrange
- every { meshLogPrefs.retentionDays } returns 30
+ every { meshLogPrefs.retentionDays.value } returns 30
// Act
useCase.setLoggingEnabled(true)
// Assert
- verify { meshLogPrefs.loggingEnabled = true }
+ verify { meshLogPrefs.setLoggingEnabled(true) }
coVerify { meshLogRepository.deleteLogsOlderThan(30) }
}
@@ -68,7 +68,7 @@ class SetMeshLogSettingsUseCaseTest {
useCase.setLoggingEnabled(false)
// Assert
- verify { meshLogPrefs.loggingEnabled = false }
+ verify { meshLogPrefs.setLoggingEnabled(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
index 240b07876..5877cbf1e 100644
--- 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
@@ -20,7 +20,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.repository.UiPrefs
class SetProvideLocationUseCaseTest {
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
index 63fbf2b2a..3dea1fd20 100644
--- 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
@@ -21,7 +21,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.repository.AnalyticsPrefs
class ToggleAnalyticsUseCaseTest {
@@ -37,24 +37,24 @@ class ToggleAnalyticsUseCaseTest {
@Test
fun `invoke toggles analytics from false to true`() {
// Arrange
- every { analyticsPrefs.analyticsAllowed } returns false
+ every { analyticsPrefs.analyticsAllowed.value } returns false
// Act
useCase()
// Assert
- verify { analyticsPrefs.analyticsAllowed = true }
+ verify { analyticsPrefs.setAnalyticsAllowed(true) }
}
@Test
fun `invoke toggles analytics from true to false`() {
// Arrange
- every { analyticsPrefs.analyticsAllowed } returns true
+ every { analyticsPrefs.analyticsAllowed.value } returns true
// Act
useCase()
// Assert
- verify { analyticsPrefs.analyticsAllowed = false }
+ verify { analyticsPrefs.setAnalyticsAllowed(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
index f8cf978af..9789ad703 100644
--- 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
@@ -21,7 +21,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
-import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
class ToggleHomoglyphEncodingUseCaseTest {
@@ -37,24 +37,24 @@ class ToggleHomoglyphEncodingUseCaseTest {
@Test
fun `invoke toggles homoglyph encoding from false to true`() {
// Arrange
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
useCase()
// Assert
- verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = true }
+ verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
}
@Test
fun `invoke toggles homoglyph encoding from true to false`() {
// Arrange
- every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
// Act
useCase()
// Assert
- verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = false }
+ verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) }
}
}
diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts
index 227428272..844495e6b 100644
--- a/core/prefs/build.gradle.kts
+++ b/core/prefs/build.gradle.kts
@@ -26,8 +26,13 @@ configure { namespace = "org.meshtastic.core.prefs" }
dependencies {
implementation(projects.core.repository)
+ implementation(projects.core.common)
+ implementation(projects.core.di)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.coroutines.core)
googleImplementation(libs.maps.compose)
testImplementation(libs.junit)
testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/di/GoogleMapsModule.kt b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/di/GoogleMapsModule.kt
index 79d0eb3ff..d195087f7 100644
--- a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/di/GoogleMapsModule.kt
+++ b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/di/GoogleMapsModule.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,28 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.prefs.di
import android.content.Context
-import android.content.SharedPreferences
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.SharedPreferencesMigration
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.GoogleMapsPrefsImpl
import javax.inject.Qualifier
import javax.inject.Singleton
-// Pref store qualifiers are internal to prevent prefs stores from being injected directly.
-// Consuming code should always inject one of the prefs repositories.
-
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class GoogleMapsSharedPreferences
+internal annotation class GoogleMapsDataStore
@InstallIn(SingletonComponent::class)
@Module
@@ -44,11 +47,16 @@ interface GoogleMapsModule {
@Binds fun bindGoogleMapsPrefs(googleMapsPrefsImpl: GoogleMapsPrefsImpl): GoogleMapsPrefs
companion object {
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Provides
@Singleton
- @GoogleMapsSharedPreferences
- fun provideGoogleMapsSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("google_maps_prefs", Context.MODE_PRIVATE)
+ @GoogleMapsDataStore
+ fun provideGoogleMapsDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
}
}
diff --git a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
index 73942c308..a8873201d 100644
--- a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
+++ b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
@@ -16,39 +16,168 @@
*/
package org.meshtastic.core.prefs.map
-import android.content.SharedPreferences
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.doublePreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.maps.android.compose.MapType
-import org.meshtastic.core.prefs.DoublePrefDelegate
-import org.meshtastic.core.prefs.FloatPrefDelegate
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.StringSetPrefDelegate
-import org.meshtastic.core.prefs.di.GoogleMapsSharedPreferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.GoogleMapsDataStore
import javax.inject.Inject
import javax.inject.Singleton
/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
interface GoogleMapsPrefs {
- var selectedGoogleMapType: String?
- var selectedCustomTileUrl: String?
- var hiddenLayerUrls: Set
- var cameraTargetLat: Double
- var cameraTargetLng: Double
- var cameraZoom: Float
- var cameraTilt: Float
- var cameraBearing: Float
- var networkMapLayers: Set
+ val selectedGoogleMapType: StateFlow
+
+ fun setSelectedGoogleMapType(value: String?)
+
+ val selectedCustomTileUrl: StateFlow
+
+ fun setSelectedCustomTileUrl(value: String?)
+
+ val hiddenLayerUrls: StateFlow>
+
+ fun setHiddenLayerUrls(value: Set)
+
+ val cameraTargetLat: StateFlow
+
+ fun setCameraTargetLat(value: Double)
+
+ val cameraTargetLng: StateFlow
+
+ fun setCameraTargetLng(value: Double)
+
+ val cameraZoom: StateFlow
+
+ fun setCameraZoom(value: Float)
+
+ val cameraTilt: StateFlow
+
+ fun setCameraTilt(value: Float)
+
+ val cameraBearing: StateFlow
+
+ fun setCameraBearing(value: Float)
+
+ val networkMapLayers: StateFlow>
+
+ fun setNetworkMapLayers(value: Set)
}
@Singleton
-class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs: SharedPreferences) : GoogleMapsPrefs {
- override var selectedGoogleMapType: String? by
- NullableStringPrefDelegate(prefs, "selected_google_map_type", MapType.NORMAL.name)
- override var selectedCustomTileUrl: String? by NullableStringPrefDelegate(prefs, "selected_custom_tile_url", null)
- override var hiddenLayerUrls: Set by StringSetPrefDelegate(prefs, "hidden_layer_urls", emptySet())
- override var cameraTargetLat: Double by DoublePrefDelegate(prefs, "camera_target_lat", 0.0)
- override var cameraTargetLng: Double by DoublePrefDelegate(prefs, "camera_target_lng", 0.0)
- override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f)
- override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f)
- override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f)
- override var networkMapLayers: Set by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
+class GoogleMapsPrefsImpl
+@Inject
+constructor(
+ @GoogleMapsDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : GoogleMapsPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val selectedGoogleMapType: StateFlow =
+ dataStore.data
+ .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
+ .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
+
+ override fun setSelectedGoogleMapType(value: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (value == null) {
+ prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
+ } else {
+ prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
+ }
+ }
+ }
+ }
+
+ override val selectedCustomTileUrl: StateFlow =
+ dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override fun setSelectedCustomTileUrl(value: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (value == null) {
+ prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
+ } else {
+ prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
+ }
+ }
+ }
+ }
+
+ override val hiddenLayerUrls: StateFlow> =
+ dataStore.data
+ .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
+ }
+
+ override val cameraTargetLat: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
+
+ override fun setCameraTargetLat(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
+ }
+
+ override val cameraTargetLng: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
+
+ override fun setCameraTargetLng(value: Double) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
+ }
+
+ override val cameraZoom: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
+
+ override fun setCameraZoom(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
+ }
+
+ override val cameraTilt: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
+
+ override fun setCameraTilt(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
+ }
+
+ override val cameraBearing: StateFlow =
+ dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
+
+ override fun setCameraBearing(value: Float) {
+ scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
+ }
+
+ override val networkMapLayers: StateFlow> =
+ dataStore.data
+ .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
+ }
+
+ companion object {
+ val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
+ val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
+ val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
+ val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
+ val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
+ val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
+ val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
+ val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
+ val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
+ }
}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt
deleted file mode 100644
index 0ecbb818e..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/DoublePrefDelegate.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs
-
-import android.content.SharedPreferences
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-class DoublePrefDelegate(
- private val preferences: SharedPreferences,
- private val key: String,
- private val defaultValue: Double,
-) : ReadWriteProperty {
- override fun getValue(thisRef: Any?, property: KProperty<*>): Double = preferences
- .getFloat(key, defaultValue.toFloat())
- .toDouble() // SharedPreferences doesn't have putDouble, so convert to float
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: Double) {
- preferences
- .edit()
- .putFloat(key, value.toFloat())
- .apply() // SharedPreferences doesn't have putDouble, so convert to float
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt
deleted file mode 100644
index a2b12fcce..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/FloatPrefDelegate.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs
-
-import android.content.SharedPreferences
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-class FloatPrefDelegate(
- private val preferences: SharedPreferences,
- private val key: String,
- private val defaultValue: Float,
-) : ReadWriteProperty {
- override fun getValue(thisRef: Any?, property: KProperty<*>): Float = preferences.getFloat(key, defaultValue)
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: Float) {
- preferences.edit().putFloat(key, value).apply()
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/NullableStringPrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/NullableStringPrefDelegate.kt
deleted file mode 100644
index f8fbd059f..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/NullableStringPrefDelegate.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-/**
- * A [ReadWriteProperty] delegate that provides concise, type-safe access to [SharedPreferences] for nullable strings.
- *
- * @param prefs The [SharedPreferences] instance to back the property.
- * @param key The key used to store and retrieve the value.
- * @param defaultValue The default value to return if no value is found.
- */
-internal class NullableStringPrefDelegate(
- private val prefs: SharedPreferences,
- private val key: String,
- private val defaultValue: String?,
-) : ReadWriteProperty {
-
- override fun getValue(thisRef: Any?, property: KProperty<*>): String? = prefs.getString(key, defaultValue)
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) {
- prefs.edit {
- when (value) {
- null -> remove(key)
- else -> putString(key, value)
- }
- }
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/PrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/PrefDelegate.kt
deleted file mode 100644
index 28ce21b65..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/PrefDelegate.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-/**
- * A generic [ReadWriteProperty] delegate that provides concise, type-safe access to [SharedPreferences].
- *
- * @param prefs The [SharedPreferences] instance to back the property.
- * @param key The key used to store and retrieve the value.
- * @param defaultValue The default value to return if no value is found.
- * @throws IllegalArgumentException if the type is not supported.
- */
-internal class PrefDelegate(
- private val prefs: SharedPreferences,
- private val key: String,
- private val defaultValue: T,
-) : ReadWriteProperty {
-
- @Suppress("UNCHECKED_CAST")
- override fun getValue(thisRef: Any?, property: KProperty<*>): T = when (defaultValue) {
- is String -> (prefs.getString(key, defaultValue) ?: defaultValue) as T
- is Int -> prefs.getInt(key, defaultValue) as T
- is Boolean -> prefs.getBoolean(key, defaultValue) as T
- is Float -> prefs.getFloat(key, defaultValue) as T
- is Long -> prefs.getLong(key, defaultValue) as T
- else -> error("Unsupported type for key '$key': $defaultValue")
- }
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
- prefs.edit {
- when (value) {
- is String -> putString(key, value)
- is Int -> putInt(key, value)
- is Boolean -> putBoolean(key, value)
- is Float -> putFloat(key, value)
- is Long -> putLong(key, value)
- else -> error("Unsupported type for key '$key': $value")
- }
- }
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/StringSetPrefDelegate.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/StringSetPrefDelegate.kt
deleted file mode 100644
index 4cae1b099..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/StringSetPrefDelegate.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-internal class StringSetPrefDelegate(
- private val prefs: SharedPreferences,
- private val key: String,
- private val defaultValue: Set,
-) : ReadWriteProperty> {
- override fun getValue(thisRef: Any?, property: KProperty<*>): Set =
- prefs.getStringSet(key, defaultValue) ?: emptySet()
-
- override fun setValue(thisRef: Any?, property: KProperty<*>, value: Set) =
- prefs.edit { putStringSet(key, value) }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
deleted file mode 100644
index bb7592a1e..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
+++ /dev/null
@@ -1,82 +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.prefs.analytics
-
-import android.content.SharedPreferences
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.di.AnalyticsSharedPreferences
-import org.meshtastic.core.prefs.di.AppSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.uuid.Uuid
-
-/** Interface for managing analytics-related preferences. */
-interface AnalyticsPrefs {
- /** Preference for whether analytics collection is allowed by the user. */
- var analyticsAllowed: Boolean
-
- /**
- * Provides a [Flow] that emits the current state of [analyticsAllowed] and subsequent changes.
- *
- * @return A [Flow] of [Boolean] indicating if analytics are allowed.
- */
- fun getAnalyticsAllowedChangesFlow(): Flow
-
- /** Unique installation ID for analytics purposes. */
- val installId: String
-
- companion object {
- /** Key for the analyticsAllowed preference. */
- const val KEY_ANALYTICS_ALLOWED = "allowed"
-
- /** Name of the SharedPreferences file where analytics preferences are stored. */
- const val ANALYTICS_PREFS_NAME = "analytics-prefs"
- }
-}
-
-@Singleton
-class AnalyticsPrefsImpl
-@Inject
-constructor(
- @AnalyticsSharedPreferences private val analyticsSharedPreferences: SharedPreferences,
- @AppSharedPreferences appPrefs: SharedPreferences,
-) : AnalyticsPrefs {
- override var analyticsAllowed: Boolean by
- PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false)
-
- private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)
-
- override val installId: String
- get() = _installId ?: Uuid.random().toString().also { _installId = it }
-
- override fun getAnalyticsAllowedChangesFlow(): Flow = callbackFlow {
- val listener =
- SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- if (key == AnalyticsPrefs.KEY_ANALYTICS_ALLOWED) {
- trySend(analyticsAllowed)
- }
- }
- // Emit the initial value
- trySend(analyticsAllowed)
- analyticsSharedPreferences.registerOnSharedPreferenceChangeListener(listener)
- awaitClose { analyticsSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt
new file mode 100644
index 000000000..4fe087be0
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.prefs.analytics
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.AnalyticsDataStore
+import org.meshtastic.core.prefs.di.AppDataStore
+import org.meshtastic.core.repository.AnalyticsPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.uuid.Uuid
+
+@Singleton
+class AnalyticsPrefsImpl
+@Inject
+constructor(
+ @AnalyticsDataStore private val analyticsDataStore: DataStore,
+ @AppDataStore private val appDataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : AnalyticsPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val analyticsAllowed: StateFlow =
+ analyticsDataStore.data
+ .map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: false }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setAnalyticsAllowed(allowed: Boolean) {
+ scope.launch { analyticsDataStore.edit { prefs -> prefs[KEY_ANALYTICS_ALLOWED_PREF] = allowed } }
+ }
+
+ override val installId: StateFlow =
+ appDataStore.data.map { it[KEY_INSTALL_ID_PREF] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "")
+
+ init {
+ scope.launch {
+ appDataStore.edit { prefs ->
+ if (prefs[KEY_INSTALL_ID_PREF] == null) {
+ prefs[KEY_INSTALL_ID_PREF] = Uuid.random().toString()
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val KEY_ANALYTICS_ALLOWED = "allowed"
+ const val KEY_INSTALL_ID = "appPrefs_install_id"
+
+ val KEY_ANALYTICS_ALLOWED_PREF = booleanPreferencesKey(KEY_ANALYTICS_ALLOWED)
+ val KEY_INSTALL_ID_PREF = stringPreferencesKey(KEY_INSTALL_ID)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt
index 2e5285be8..b1b8fbede 100644
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt
@@ -17,88 +17,92 @@
package org.meshtastic.core.prefs.di
import android.content.Context
-import android.content.SharedPreferences
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.SharedPreferencesMigration
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.prefs.analytics.AnalyticsPrefsImpl
-import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefsImpl
-import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.prefs.filter.FilterPrefsImpl
-import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefsImpl
-import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.prefs.map.MapConsentPrefsImpl
-import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.prefs.map.MapPrefsImpl
-import org.meshtastic.core.prefs.map.MapTileProviderPrefs
import org.meshtastic.core.prefs.map.MapTileProviderPrefsImpl
-import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.mesh.MeshPrefsImpl
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl
-import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.RadioPrefsImpl
-import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.prefs.ui.UiPrefsImpl
+import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.CustomEmojiPrefs
+import org.meshtastic.core.repository.FilterPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MapConsentPrefs
+import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.MapTileProviderPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshPrefs
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.repository.UiPrefs
import javax.inject.Qualifier
import javax.inject.Singleton
-// These pref store qualifiers are internal to prevent prefs stores from being injected directly.
-// Consuming code should always inject one of the prefs repositories.
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+internal annotation class AnalyticsDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class AnalyticsSharedPreferences
+internal annotation class HomoglyphEncodingDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class HomoglyphEncodingSharedPreferences
+internal annotation class AppDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class AppSharedPreferences
+internal annotation class CustomEmojiDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class CustomEmojiSharedPreferences
+internal annotation class MapDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class MapSharedPreferences
+internal annotation class MapConsentDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class MapConsentSharedPreferences
+internal annotation class MapTileProviderDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class MapTileProviderSharedPreferences
+internal annotation class MeshDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class MeshSharedPreferences
+internal annotation class RadioDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class RadioSharedPreferences
+internal annotation class UiDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class UiSharedPreferences
+internal annotation class MeshLogDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
-internal annotation class MeshLogSharedPreferences
-
-@Qualifier
-@Retention(AnnotationRetention.BINARY)
-internal annotation class FilterSharedPreferences
+internal annotation class FilterDataStore
@Suppress("TooManyFunctions")
@InstallIn(SingletonComponent::class)
@@ -109,11 +113,6 @@ interface PrefsModule {
@Binds fun bindHomoglyphEncodingPrefs(homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl): HomoglyphPrefs
- @Binds
- fun bindSharedHomoglyphPrefs(
- homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl,
- ): org.meshtastic.core.repository.HomoglyphPrefs
-
@Binds fun bindCustomEmojiPrefs(customEmojiPrefsImpl: CustomEmojiPrefsImpl): CustomEmojiPrefs
@Binds fun bindMapConsentPrefs(mapConsentPrefsImpl: MapConsentPrefsImpl): MapConsentPrefs
@@ -133,77 +132,126 @@ interface PrefsModule {
@Binds fun bindFilterPrefs(filterPrefsImpl: FilterPrefsImpl): FilterPrefs
companion object {
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Provides
@Singleton
- @AnalyticsSharedPreferences
- fun provideAnalyticsSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE)
+ @AnalyticsDataStore
+ fun provideAnalyticsDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("analytics_ds") },
+ )
@Provides
@Singleton
- @HomoglyphEncodingSharedPreferences
- fun provideHomoglyphEncodingSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("homoglyph-encoding-prefs", Context.MODE_PRIVATE)
+ @HomoglyphEncodingDataStore
+ fun provideHomoglyphEncodingDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") },
+ )
@Provides
@Singleton
- @AppSharedPreferences
- fun provideAppSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
+ @AppDataStore
+ fun provideAppDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("app_ds") },
+ )
@Provides
@Singleton
- @CustomEmojiSharedPreferences
- fun provideCustomEmojiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("org.geeksville.emoji.prefs", Context.MODE_PRIVATE)
+ @CustomEmojiDataStore
+ fun provideCustomEmojiDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") },
+ )
@Provides
@Singleton
- @MapSharedPreferences
- fun provideMapSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("map_prefs", Context.MODE_PRIVATE)
+ @MapDataStore
+ fun provideMapDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "map_prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("map_ds") },
+ )
@Provides
@Singleton
- @MapConsentSharedPreferences
- fun provideMapConsentSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("map_consent_preferences", Context.MODE_PRIVATE)
+ @MapConsentDataStore
+ fun provideMapConsentDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("map_consent_ds") },
+ )
@Provides
@Singleton
- @MapTileProviderSharedPreferences
- fun provideMapTileProviderSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("map_tile_provider_prefs", Context.MODE_PRIVATE)
+ @MapTileProviderDataStore
+ fun provideMapTileProviderDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") },
+ )
@Provides
@Singleton
- @MeshSharedPreferences
- fun provideMeshSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE)
+ @MeshDataStore
+ fun provideMeshDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("mesh_ds") },
+ )
@Provides
@Singleton
- @RadioSharedPreferences
- fun provideRadioSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE)
+ @RadioDataStore
+ fun provideRadioDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("radio_ds") },
+ )
@Provides
@Singleton
- @UiSharedPreferences
- fun provideUiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
+ @UiDataStore
+ fun provideUiDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("ui_ds") },
+ )
@Provides
@Singleton
- @MeshLogSharedPreferences
- fun provideMeshLogSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences("meshlog-prefs", Context.MODE_PRIVATE)
+ @MeshLogDataStore
+ fun provideMeshLogDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("meshlog_ds") },
+ )
@Provides
@Singleton
- @FilterSharedPreferences
- fun provideFilterSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
- context.getSharedPreferences(FilterPrefs.FILTER_PREFS_NAME, Context.MODE_PRIVATE)
+ @FilterDataStore
+ fun provideFilterDataStore(@ApplicationContext context: Context): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")),
+ scope = scope,
+ produceFile = { context.preferencesDataStoreFile("filter_ds") },
+ )
}
}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefs.kt
deleted file mode 100644
index 986265590..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefs.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.emoji
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.di.CustomEmojiSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface CustomEmojiPrefs {
- var customEmojiFrequency: String?
-}
-
-@Singleton
-class CustomEmojiPrefsImpl @Inject constructor(@CustomEmojiSharedPreferences prefs: SharedPreferences) :
- CustomEmojiPrefs {
- override var customEmojiFrequency: String? by NullableStringPrefDelegate(prefs, "pref_key_custom_emoji_freq", null)
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt
new file mode 100644
index 000000000..9bc7f1805
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.prefs.emoji
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.CustomEmojiDataStore
+import org.meshtastic.core.repository.CustomEmojiPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CustomEmojiPrefsImpl
+@Inject
+constructor(
+ @CustomEmojiDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : CustomEmojiPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val customEmojiFrequency: StateFlow =
+ dataStore.data.map { it[KEY_EMOJI_FREQ_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override fun setCustomEmojiFrequency(frequency: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (frequency == null) {
+ prefs.remove(KEY_EMOJI_FREQ_PREF)
+ } else {
+ prefs[KEY_EMOJI_FREQ_PREF] = frequency
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val KEY_EMOJI_FREQ = "pref_key_custom_emoji_freq"
+ val KEY_EMOJI_FREQ_PREF = stringPreferencesKey(KEY_EMOJI_FREQ)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt
deleted file mode 100644
index aa76cba8d..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt
+++ /dev/null
@@ -1,50 +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.prefs.filter
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.StringSetPrefDelegate
-import org.meshtastic.core.prefs.di.FilterSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/** Interface for managing message filter preferences. */
-interface FilterPrefs {
- /** Whether message filtering is enabled. */
- var filterEnabled: Boolean
-
- /** Set of words to filter messages on. */
- var filterWords: Set
-
- companion object {
- /** Key for the filterEnabled preference. */
- const val KEY_FILTER_ENABLED = "filter_enabled"
-
- /** Key for the filterWords preference. */
- const val KEY_FILTER_WORDS = "filter_words"
-
- /** Name of the SharedPreferences file where filter preferences are stored. */
- const val FILTER_PREFS_NAME = "filter-prefs"
- }
-}
-
-@Singleton
-class FilterPrefsImpl @Inject constructor(@FilterSharedPreferences private val prefs: SharedPreferences) : FilterPrefs {
- override var filterEnabled: Boolean by PrefDelegate(prefs, FilterPrefs.KEY_FILTER_ENABLED, false)
- override var filterWords: Set by StringSetPrefDelegate(prefs, FilterPrefs.KEY_FILTER_WORDS, emptySet())
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt
new file mode 100644
index 000000000..6ea9e24dd
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.prefs.filter
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.FilterDataStore
+import org.meshtastic.core.repository.FilterPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class FilterPrefsImpl
+@Inject
+constructor(
+ @FilterDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : FilterPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val filterEnabled: StateFlow =
+ dataStore.data.map { it[KEY_FILTER_ENABLED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setFilterEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[KEY_FILTER_ENABLED_PREF] = enabled } }
+ }
+
+ override val filterWords: StateFlow> =
+ dataStore.data
+ .map { it[KEY_FILTER_WORDS_PREF] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setFilterWords(words: Set) {
+ scope.launch { dataStore.edit { prefs -> prefs[KEY_FILTER_WORDS_PREF] = words } }
+ }
+
+ companion object {
+ const val KEY_FILTER_ENABLED = "filter_enabled"
+ const val KEY_FILTER_WORDS = "filter_words"
+ const val FILTER_PREFS_NAME = "filter-prefs"
+
+ val KEY_FILTER_ENABLED_PREF = booleanPreferencesKey(KEY_FILTER_ENABLED)
+ val KEY_FILTER_WORDS_PREF = stringSetPreferencesKey(KEY_FILTER_WORDS)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt
deleted file mode 100644
index b77b6fa97..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.prefs.homoglyph
-
-import android.content.SharedPreferences
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.di.HomoglyphEncodingSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.meshtastic.core.repository.HomoglyphPrefs as SharedHomoglyphPrefs
-
-interface HomoglyphPrefs : SharedHomoglyphPrefs {
-
- /** Preference for whether homoglyph encoding is enabled by the user. */
- override var homoglyphEncodingEnabled: Boolean
-
- /**
- * Provides a [Flow] that emits the current state of [homoglyphEncodingEnabled] and subsequent changes.
- *
- * @return A [Flow] of [Boolean] indicating if homoglyph encoding is enabled.
- */
- fun getHomoglyphEncodingEnabledChangesFlow(): Flow
-
- companion object {
- /** Key for the homoglyphEncodingEnabled preference. */
- const val KEY_HOMOGLYPH_ENCODING_ENABLED = "enabled"
- }
-}
-
-@Singleton
-class HomoglyphPrefsImpl
-@Inject
-constructor(
- @HomoglyphEncodingSharedPreferences private val homoglyphEncodingSharedPreferences: SharedPreferences,
-) : HomoglyphPrefs {
- override var homoglyphEncodingEnabled: Boolean by
- PrefDelegate(homoglyphEncodingSharedPreferences, HomoglyphPrefs.KEY_HOMOGLYPH_ENCODING_ENABLED, false)
-
- override fun getHomoglyphEncodingEnabledChangesFlow(): Flow = callbackFlow {
- val listener =
- SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- if (key == HomoglyphPrefs.KEY_HOMOGLYPH_ENCODING_ENABLED) {
- trySend(homoglyphEncodingEnabled)
- }
- }
- // Emit the initial value
- trySend(homoglyphEncodingEnabled)
- homoglyphEncodingSharedPreferences.registerOnSharedPreferenceChangeListener(listener)
- awaitClose { homoglyphEncodingSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt
new file mode 100644
index 000000000..42b4f8faa
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.prefs.homoglyph
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
+import org.meshtastic.core.repository.HomoglyphPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class HomoglyphPrefsImpl
+@Inject
+constructor(
+ @HomoglyphEncodingDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : HomoglyphPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val homoglyphEncodingEnabled: StateFlow =
+ dataStore.data.map { it[KEY_ENABLED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setHomoglyphEncodingEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[KEY_ENABLED_PREF] = enabled } }
+ }
+
+ companion object {
+ const val KEY_ENABLED = "enabled"
+ val KEY_ENABLED_PREF = booleanPreferencesKey(KEY_ENABLED)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefs.kt
deleted file mode 100644
index ae1a76890..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefs.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.map
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import org.meshtastic.core.prefs.di.MapConsentSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface MapConsentPrefs {
- fun shouldReportLocation(nodeNum: Int?): Boolean
-
- fun setShouldReportLocation(nodeNum: Int?, value: Boolean)
-}
-
-@Singleton
-class MapConsentPrefsImpl @Inject constructor(@MapConsentSharedPreferences private val prefs: SharedPreferences) :
- MapConsentPrefs {
- override fun shouldReportLocation(nodeNum: Int?) = prefs.getBoolean(nodeNum.toString(), false)
-
- override fun setShouldReportLocation(nodeNum: Int?, value: Boolean) {
- prefs.edit { putBoolean(nodeNum.toString(), value) }
- }
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt
new file mode 100644
index 000000000..bf22eb27d
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.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.core.prefs.map
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MapConsentDataStore
+import org.meshtastic.core.repository.MapConsentPrefs
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MapConsentPrefsImpl
+@Inject
+constructor(
+ @MapConsentDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MapConsentPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ private val consentFlows = ConcurrentHashMap>()
+
+ override fun shouldReportLocation(nodeNum: Int?): StateFlow = consentFlows.getOrPut(nodeNum) {
+ val key = booleanPreferencesKey(nodeNum.toString())
+ dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+ }
+
+ override fun setShouldReportLocation(nodeNum: Int?, report: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(nodeNum.toString())] = report } }
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt
deleted file mode 100644
index 6edabbc0c..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.map
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.di.MapSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/** Interface for general map prefs. For Google-specific prefs, see GoogleMapsPrefs. */
-interface MapPrefs {
- var mapStyle: Int
- var showOnlyFavorites: Boolean
- var showWaypointsOnMap: Boolean
- var showPrecisionCircleOnMap: Boolean
- var lastHeardFilter: Long
- var lastHeardTrackFilter: Long
-}
-
-@Singleton
-class MapPrefsImpl @Inject constructor(@MapSharedPreferences prefs: SharedPreferences) : MapPrefs {
- override var mapStyle: Int by PrefDelegate(prefs, "map_style_id", 0)
- override var showOnlyFavorites: Boolean by PrefDelegate(prefs, "show_only_favorites", false)
- override var showWaypointsOnMap: Boolean by PrefDelegate(prefs, "show_waypoints", true)
- override var showPrecisionCircleOnMap: Boolean by PrefDelegate(prefs, "show_precision_circle", true)
- override var lastHeardFilter: Long by PrefDelegate(prefs, "last_heard_filter", 0L)
- override var lastHeardTrackFilter: Long by PrefDelegate(prefs, "last_heard_track_filter", 0L)
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt
new file mode 100644
index 000000000..52167812f
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.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.core.prefs.map
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MapDataStore
+import org.meshtastic.core.repository.MapPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MapPrefsImpl
+@Inject
+constructor(
+ @MapDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MapPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val mapStyle: StateFlow =
+ dataStore.data.map { it[KEY_MAP_STYLE_PREF] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
+
+ override fun setMapStyle(value: Int) {
+ scope.launch { dataStore.edit { it[KEY_MAP_STYLE_PREF] = value } }
+ }
+
+ override val showOnlyFavorites: StateFlow =
+ dataStore.data.map { it[KEY_SHOW_ONLY_FAVORITES_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setShowOnlyFavorites(value: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_SHOW_ONLY_FAVORITES_PREF] = value } }
+ }
+
+ override val showWaypointsOnMap: StateFlow =
+ dataStore.data.map { it[KEY_SHOW_WAYPOINTS_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShowWaypointsOnMap(value: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_SHOW_WAYPOINTS_PREF] = value } }
+ }
+
+ override val showPrecisionCircleOnMap: StateFlow =
+ dataStore.data.map { it[KEY_SHOW_PRECISION_CIRCLE_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setShowPrecisionCircleOnMap(value: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_SHOW_PRECISION_CIRCLE_PREF] = value } }
+ }
+
+ override val lastHeardFilter: StateFlow =
+ dataStore.data.map { it[KEY_LAST_HEARD_FILTER_PREF] ?: 0L }.stateIn(scope, SharingStarted.Eagerly, 0L)
+
+ override fun setLastHeardFilter(value: Long) {
+ scope.launch { dataStore.edit { it[KEY_LAST_HEARD_FILTER_PREF] = value } }
+ }
+
+ override val lastHeardTrackFilter: StateFlow =
+ dataStore.data.map { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] ?: 0L }.stateIn(scope, SharingStarted.Eagerly, 0L)
+
+ override fun setLastHeardTrackFilter(value: Long) {
+ scope.launch { dataStore.edit { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] = value } }
+ }
+
+ companion object {
+ val KEY_MAP_STYLE_PREF = intPreferencesKey("map_style_id")
+ val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPreferencesKey("show_only_favorites")
+ val KEY_SHOW_WAYPOINTS_PREF = booleanPreferencesKey("show_waypoints")
+ val KEY_SHOW_PRECISION_CIRCLE_PREF = booleanPreferencesKey("show_precision_circle")
+ val KEY_LAST_HEARD_FILTER_PREF = longPreferencesKey("last_heard_filter")
+ val KEY_LAST_HEARD_TRACK_FILTER_PREF = longPreferencesKey("last_heard_track_filter")
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefs.kt
deleted file mode 100644
index 9c86a4b13..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefs.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.map
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.di.MapTileProviderSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface MapTileProviderPrefs {
- var customTileProviders: String?
-}
-
-@Singleton
-class MapTileProviderPrefsImpl @Inject constructor(@MapTileProviderSharedPreferences prefs: SharedPreferences) :
- MapTileProviderPrefs {
- override var customTileProviders: String? by NullableStringPrefDelegate(prefs, "custom_tile_providers", null)
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
new file mode 100644
index 000000000..c3a686e97
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.prefs.map
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MapTileProviderDataStore
+import org.meshtastic.core.repository.MapTileProviderPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MapTileProviderPrefsImpl
+@Inject
+constructor(
+ @MapTileProviderDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MapTileProviderPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val customTileProviders: StateFlow =
+ dataStore.data.map { it[KEY_CUSTOM_PROVIDERS_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override fun setCustomTileProviders(providers: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (providers == null) {
+ prefs.remove(KEY_CUSTOM_PROVIDERS_PREF)
+ } else {
+ prefs[KEY_CUSTOM_PROVIDERS_PREF] = providers
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val KEY_CUSTOM_PROVIDERS = "custom_tile_providers"
+ val KEY_CUSTOM_PROVIDERS_PREF = stringPreferencesKey(KEY_CUSTOM_PROVIDERS)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt
deleted file mode 100644
index fb121a692..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.mesh
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.di.MeshSharedPreferences
-import java.util.Locale
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface MeshPrefs {
- var deviceAddress: String?
-
- fun shouldProvideNodeLocation(nodeNum: Int?): Boolean
-
- fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean)
-
- fun getStoreForwardLastRequest(address: String?): Int
-
- fun setStoreForwardLastRequest(address: String?, value: Int)
-}
-
-@Singleton
-class MeshPrefsImpl @Inject constructor(@MeshSharedPreferences private val prefs: SharedPreferences) : MeshPrefs {
- override var deviceAddress: String? by NullableStringPrefDelegate(prefs, "device_address", NO_DEVICE_SELECTED)
-
- override fun shouldProvideNodeLocation(nodeNum: Int?): Boolean =
- prefs.getBoolean(provideLocationKey(nodeNum), false)
-
- override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
- prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
- }
-
- override fun getStoreForwardLastRequest(address: String?): Int = prefs.getInt(storeForwardKey(address), 0)
-
- override fun setStoreForwardLastRequest(address: String?, value: Int) {
- prefs.edit {
- if (value <= 0) {
- remove(storeForwardKey(address))
- } else {
- putInt(storeForwardKey(address), value)
- }
- }
- }
-
- private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
-
- private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
-
- private fun normalizeAddress(address: String?): String {
- val raw = address?.trim()?.takeIf { it.isNotEmpty() }
- return when {
- raw == null -> "DEFAULT"
- raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
- else -> raw.uppercase(Locale.US).replace(":", "")
- }
- }
-}
-
-private const val NO_DEVICE_SELECTED = "n"
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
new file mode 100644
index 000000000..c247788f2
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.prefs.mesh
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MeshDataStore
+import org.meshtastic.core.repository.MeshPrefs
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshPrefsImpl
+@Inject
+constructor(
+ @MeshDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MeshPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ private val locationFlows = ConcurrentHashMap>()
+ private val storeForwardFlows = ConcurrentHashMap>()
+
+ override val deviceAddress: StateFlow =
+ dataStore.data
+ .map { it[KEY_DEVICE_ADDRESS_PREF] ?: NO_DEVICE_SELECTED }
+ .stateIn(scope, SharingStarted.Eagerly, NO_DEVICE_SELECTED)
+
+ override fun setDeviceAddress(address: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (address == null) {
+ prefs.remove(KEY_DEVICE_ADDRESS_PREF)
+ } else {
+ prefs[KEY_DEVICE_ADDRESS_PREF] = address
+ }
+ }
+ }
+ }
+
+ override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = locationFlows.getOrPut(nodeNum) {
+ val key = booleanPreferencesKey(provideLocationKey(nodeNum))
+ dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+ }
+
+ override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = value } }
+ }
+
+ override fun getStoreForwardLastRequest(address: String?): StateFlow = storeForwardFlows.getOrPut(address) {
+ val key = intPreferencesKey(storeForwardKey(address))
+ dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
+ }
+
+ override fun setStoreForwardLastRequest(address: String?, value: Int) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ val key = intPreferencesKey(storeForwardKey(address))
+ if (value <= 0) {
+ prefs.remove(key)
+ } else {
+ prefs[key] = value
+ }
+ }
+ }
+ }
+
+ private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
+
+ private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
+
+ private fun normalizeAddress(address: String?): String {
+ val raw = address?.trim()?.takeIf { it.isNotEmpty() }
+ return when {
+ raw == null -> "DEFAULT"
+ raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
+ else -> raw.uppercase(Locale.US).replace(":", "")
+ }
+ }
+
+ companion object {
+ val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
+ }
+}
+
+private const val NO_DEVICE_SELECTED = "n"
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt
deleted file mode 100644
index f110cf6aa..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefs.kt
+++ /dev/null
@@ -1,56 +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.prefs.meshlog
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.di.MeshLogSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface MeshLogPrefs {
- var retentionDays: Int
- var loggingEnabled: Boolean
-
- companion object {
- const val RETENTION_DAYS_KEY = "meshlog_retention_days"
- const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled"
- const val DEFAULT_RETENTION_DAYS = 30
- const val DEFAULT_LOGGING_ENABLED = true
- const val MIN_RETENTION_DAYS = -1 // -1 == keep last hour
- const val MAX_RETENTION_DAYS = 365
- const val NEVER_CLEAR_RETENTION_DAYS = 0
- const val ONE_HOUR_RETENTION_DAYS = -1
- }
-}
-
-@Singleton
-class MeshLogPrefsImpl @Inject constructor(@MeshLogSharedPreferences private val prefs: SharedPreferences) :
- MeshLogPrefs {
- override var retentionDays: Int by
- PrefDelegate(
- prefs = prefs,
- key = MeshLogPrefs.RETENTION_DAYS_KEY,
- defaultValue = MeshLogPrefs.DEFAULT_RETENTION_DAYS,
- )
- override var loggingEnabled: Boolean by
- PrefDelegate(
- prefs = prefs,
- key = MeshLogPrefs.LOGGING_ENABLED_KEY,
- defaultValue = MeshLogPrefs.DEFAULT_LOGGING_ENABLED,
- )
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt
new file mode 100644
index 000000000..a10c27da8
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.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.prefs.meshlog
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.MeshLogDataStore
+import org.meshtastic.core.repository.MeshLogPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class MeshLogPrefsImpl
+@Inject
+constructor(
+ @MeshLogDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : MeshLogPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val retentionDays: StateFlow =
+ dataStore.data
+ .map { it[KEY_RETENTION_DAYS_PREF] ?: DEFAULT_RETENTION_DAYS }
+ .stateIn(scope, SharingStarted.Eagerly, DEFAULT_RETENTION_DAYS)
+
+ override fun setRetentionDays(days: Int) {
+ scope.launch { dataStore.edit { it[KEY_RETENTION_DAYS_PREF] = days } }
+ }
+
+ override val loggingEnabled: StateFlow =
+ dataStore.data
+ .map { it[KEY_LOGGING_ENABLED_PREF] ?: DEFAULT_LOGGING_ENABLED }
+ .stateIn(scope, SharingStarted.Eagerly, DEFAULT_LOGGING_ENABLED)
+
+ override fun setLoggingEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_LOGGING_ENABLED_PREF] = enabled } }
+ }
+
+ companion object {
+ const val RETENTION_DAYS_KEY = "meshlog_retention_days"
+ const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled"
+ const val DEFAULT_RETENTION_DAYS = 30
+ const val DEFAULT_LOGGING_ENABLED = true
+
+ val KEY_RETENTION_DAYS_PREF = intPreferencesKey(RETENTION_DAYS_KEY)
+ val KEY_LOGGING_ENABLED_PREF = booleanPreferencesKey(LOGGING_ENABLED_KEY)
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefs.kt
deleted file mode 100644
index baa049ff6..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefs.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.radio
-
-import android.content.SharedPreferences
-import org.meshtastic.core.prefs.NullableStringPrefDelegate
-import org.meshtastic.core.prefs.di.RadioSharedPreferences
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface RadioPrefs {
- var devAddr: String?
-}
-
-fun RadioPrefs.isBle() = devAddr?.startsWith("x") == true
-
-fun RadioPrefs.isSerial() = devAddr?.startsWith("s") == true
-
-fun RadioPrefs.isMock() = devAddr?.startsWith("m") == true
-
-fun RadioPrefs.isTcp() = devAddr?.startsWith("t") == true
-
-fun RadioPrefs.isNoop() = devAddr?.startsWith("n") == true
-
-@Singleton
-class RadioPrefsImpl @Inject constructor(@RadioSharedPreferences prefs: SharedPreferences) : RadioPrefs {
- override var devAddr: String? by NullableStringPrefDelegate(prefs, "devAddr2", null)
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt
new file mode 100644
index 000000000..916bb892c
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.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.prefs.radio
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.RadioDataStore
+import org.meshtastic.core.repository.RadioPrefs
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RadioPrefsImpl
+@Inject
+constructor(
+ @RadioDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : RadioPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val devAddr: StateFlow =
+ dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override fun setDevAddr(address: String?) {
+ scope.launch {
+ dataStore.edit { prefs ->
+ if (address == null) {
+ prefs.remove(KEY_DEV_ADDR_PREF)
+ } else {
+ prefs[KEY_DEV_ADDR_PREF] = address
+ }
+ }
+ }
+ }
+
+ companion object {
+ val KEY_DEV_ADDR_PREF = stringPreferencesKey("devAddr2")
+ }
+}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt
deleted file mode 100644
index 138a4afa5..000000000
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.meshtastic.core.prefs.ui
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import org.meshtastic.core.prefs.PrefDelegate
-import org.meshtastic.core.prefs.di.UiSharedPreferences
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-import javax.inject.Singleton
-
-interface UiPrefs {
- var hasShownNotPairedWarning: Boolean
- var showQuickChat: Boolean
-
- fun shouldProvideNodeLocation(nodeNum: Int): StateFlow
-
- fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean)
-}
-
-@Singleton
-class UiPrefsImpl @Inject constructor(@UiSharedPreferences private val prefs: SharedPreferences) : UiPrefs {
-
- // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
- private val provideNodeLocationFlows = ConcurrentHashMap>()
-
- private val sharedPreferencesListener =
- SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
- when (key) {
- // Check if the changed key is one of our node location keys
- else ->
- provideNodeLocationFlows.keys.forEach { nodeNum ->
- if (key == provideLocationKey(nodeNum)) {
- val newValue = sharedPreferences.getBoolean(key, false)
- provideNodeLocationFlows[nodeNum]?.tryEmit(newValue)
- }
- }
- }
- }
-
- init {
- prefs.registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
- }
-
- override var hasShownNotPairedWarning: Boolean by PrefDelegate(prefs, "has_shown_not_paired_warning", false)
- override var showQuickChat: Boolean by PrefDelegate(prefs, "show-quick-chat", false)
-
- override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = provideNodeLocationFlows
- .getOrPut(nodeNum) { MutableStateFlow(prefs.getBoolean(provideLocationKey(nodeNum), false)) }
- .asStateFlow()
-
- override fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean) {
- prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
- provideNodeLocationFlows[nodeNum]?.tryEmit(value)
- }
-
- private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum"
-}
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
new file mode 100644
index 000000000..13c8ed336
--- /dev/null
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.prefs.ui
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.prefs.di.UiDataStore
+import org.meshtastic.core.repository.UiPrefs
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UiPrefsImpl
+@Inject
+constructor(
+ @UiDataStore private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : UiPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
+ private val provideNodeLocationFlows = ConcurrentHashMap>()
+
+ override val hasShownNotPairedWarning: StateFlow =
+ dataStore.data
+ .map { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] ?: false }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setHasShownNotPairedWarning(value: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] = value } }
+ }
+
+ override val showQuickChat: StateFlow =
+ dataStore.data.map { it[KEY_SHOW_QUICK_CHAT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setShowQuickChat(value: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = value } }
+ }
+
+ override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow =
+ provideNodeLocationFlows.getOrPut(nodeNum) {
+ val key = booleanPreferencesKey(provideLocationKey(nodeNum))
+ dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+ }
+
+ override fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean) {
+ scope.launch { dataStore.edit { it[booleanPreferencesKey(provideLocationKey(nodeNum))] = value } }
+ }
+
+ private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum"
+
+ companion object {
+ val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning")
+ val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat")
+ }
+}
diff --git a/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt
index 37db3f2ef..efe1dacd8 100644
--- a/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt
+++ b/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt
@@ -16,51 +16,61 @@
*/
package org.meshtastic.core.prefs.filter
-import android.content.SharedPreferences
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
-import io.mockk.verify
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.FilterPrefs
class FilterPrefsTest {
- private lateinit var sharedPreferences: SharedPreferences
- private lateinit var editor: SharedPreferences.Editor
+ @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
+
+ private lateinit var dataStore: DataStore
private lateinit var filterPrefs: FilterPrefs
+ private lateinit var dispatchers: CoroutineDispatchers
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
@Before
fun setup() {
- editor = mockk(relaxed = true)
- sharedPreferences = mockk {
- every { getBoolean(FilterPrefs.KEY_FILTER_ENABLED, false) } returns false
- every { getStringSet(FilterPrefs.KEY_FILTER_WORDS, emptySet()) } returns emptySet()
- every { edit() } returns editor
- }
- filterPrefs = FilterPrefsImpl(sharedPreferences)
+ dataStore =
+ PreferenceDataStoreFactory.create(
+ scope = testScope,
+ produceFile = { tmpFolder.newFile("test.preferences_pb") },
+ )
+ dispatchers = mockk { every { default } returns testDispatcher }
+ filterPrefs = FilterPrefsImpl(dataStore, dispatchers)
+ }
+
+ @Test fun `filterEnabled defaults to false`() = testScope.runTest { assertFalse(filterPrefs.filterEnabled.value) }
+
+ @Test
+ fun `filterWords defaults to empty set`() =
+ testScope.runTest { assertTrue(filterPrefs.filterWords.value.isEmpty()) }
+
+ @Test
+ fun `setting filterEnabled updates preference`() = testScope.runTest {
+ filterPrefs.setFilterEnabled(true)
+ assertTrue(filterPrefs.filterEnabled.value)
}
@Test
- fun `filterEnabled defaults to false`() {
- assertFalse(filterPrefs.filterEnabled)
- }
-
- @Test
- fun `filterWords defaults to empty set`() {
- assertTrue(filterPrefs.filterWords.isEmpty())
- }
-
- @Test
- fun `setting filterEnabled updates preference`() {
- filterPrefs.filterEnabled = true
- verify { editor.putBoolean(FilterPrefs.KEY_FILTER_ENABLED, true) }
- }
-
- @Test
- fun `setting filterWords updates preference`() {
+ fun `setting filterWords updates preference`() = testScope.runTest {
val words = setOf("test", "word")
- filterPrefs.filterWords = words
- verify { editor.putStringSet(FilterPrefs.KEY_FILTER_WORDS, words) }
+ filterPrefs.setFilterWords(words)
+ assertEquals(words, filterPrefs.filterWords.value)
}
}
diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts
index 778dde947..44e49f491 100644
--- a/core/repository/build.gradle.kts
+++ b/core/repository/build.gradle.kts
@@ -26,6 +26,7 @@ kotlin {
api(projects.core.model)
api(projects.core.proto)
implementation(projects.core.common)
+ implementation(projects.core.database)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
new file mode 100644
index 000000000..82f7ff86b
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -0,0 +1,173 @@
+/*
+ * 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.repository
+
+import kotlinx.coroutines.flow.StateFlow
+
+/** Reactive interface for analytics-related preferences. */
+interface AnalyticsPrefs {
+ val analyticsAllowed: StateFlow
+
+ fun setAnalyticsAllowed(allowed: Boolean)
+
+ val installId: StateFlow
+}
+
+/** Reactive interface for homoglyph encoding preferences. */
+interface HomoglyphPrefs {
+ val homoglyphEncodingEnabled: StateFlow
+
+ fun setHomoglyphEncodingEnabled(enabled: Boolean)
+}
+
+/** Reactive interface for message filtering preferences. */
+interface FilterPrefs {
+ val filterEnabled: StateFlow
+
+ fun setFilterEnabled(enabled: Boolean)
+
+ val filterWords: StateFlow>
+
+ fun setFilterWords(words: Set)
+}
+
+/** Reactive interface for mesh log preferences. */
+interface MeshLogPrefs {
+ val retentionDays: StateFlow
+
+ fun setRetentionDays(days: Int)
+
+ val loggingEnabled: StateFlow
+
+ fun setLoggingEnabled(enabled: Boolean)
+
+ companion object {
+ const val DEFAULT_RETENTION_DAYS = 30
+ const val MIN_RETENTION_DAYS = -1
+ const val MAX_RETENTION_DAYS = 365
+ }
+}
+
+/** Reactive interface for emoji preferences. */
+interface CustomEmojiPrefs {
+ val customEmojiFrequency: StateFlow
+
+ fun setCustomEmojiFrequency(frequency: String?)
+}
+
+/** Reactive interface for general UI preferences. */
+interface UiPrefs {
+ val hasShownNotPairedWarning: StateFlow
+
+ fun setHasShownNotPairedWarning(shown: Boolean)
+
+ val showQuickChat: StateFlow
+
+ fun setShowQuickChat(show: Boolean)
+
+ fun shouldProvideNodeLocation(nodeNum: Int): StateFlow
+
+ fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
+}
+
+/** Reactive interface for general map preferences. */
+interface MapPrefs {
+ val mapStyle: StateFlow
+
+ fun setMapStyle(style: Int)
+
+ val showOnlyFavorites: StateFlow
+
+ fun setShowOnlyFavorites(show: Boolean)
+
+ val showWaypointsOnMap: StateFlow
+
+ fun setShowWaypointsOnMap(show: Boolean)
+
+ val showPrecisionCircleOnMap: StateFlow
+
+ fun setShowPrecisionCircleOnMap(show: Boolean)
+
+ val lastHeardFilter: StateFlow
+
+ fun setLastHeardFilter(seconds: Long)
+
+ val lastHeardTrackFilter: StateFlow
+
+ fun setLastHeardTrackFilter(seconds: Long)
+}
+
+/** Reactive interface for map consent. */
+interface MapConsentPrefs {
+ fun shouldReportLocation(nodeNum: Int?): StateFlow
+
+ fun setShouldReportLocation(nodeNum: Int?, report: Boolean)
+}
+
+/** Reactive interface for map tile provider settings. */
+interface MapTileProviderPrefs {
+ val customTileProviders: StateFlow
+
+ fun setCustomTileProviders(providers: String?)
+}
+
+/** Reactive interface for radio settings. */
+interface RadioPrefs {
+ val devAddr: StateFlow
+
+ fun setDevAddr(address: String?)
+}
+
+fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true
+
+fun RadioPrefs.isSerial() = devAddr.value?.startsWith("s") == true
+
+fun RadioPrefs.isMock() = devAddr.value?.startsWith("m") == true
+
+fun RadioPrefs.isTcp() = devAddr.value?.startsWith("t") == true
+
+fun RadioPrefs.isNoop() = devAddr.value?.startsWith("n") == true
+
+/** Reactive interface for mesh connection settings. */
+interface MeshPrefs {
+ val deviceAddress: StateFlow
+
+ fun setDeviceAddress(address: String?)
+
+ fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow
+
+ fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean)
+
+ fun getStoreForwardLastRequest(address: String?): StateFlow
+
+ fun setStoreForwardLastRequest(address: String?, timestamp: Int)
+}
+
+/** Consolidated interface for all application preferences. */
+interface AppPreferences {
+ val analytics: AnalyticsPrefs
+ val homoglyph: HomoglyphPrefs
+ val filter: FilterPrefs
+ val meshLog: MeshLogPrefs
+ val emoji: CustomEmojiPrefs
+ val ui: UiPrefs
+ val map: MapPrefs
+ val mapConsent: MapConsentPrefs
+ val mapTileProvider: MapTileProviderPrefs
+ val radio: RadioPrefs
+ val mesh: MeshPrefs
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt
deleted file mode 100644
index 4c497af0b..000000000
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt
+++ /dev/null
@@ -1,21 +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.repository
-
-interface HomoglyphPrefs {
- val homoglyphEncodingEnabled: Boolean
-}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.kt
new file mode 100644
index 000000000..94f750032
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLogRepository.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.core.repository
+
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.MyNodeInfo
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Telemetry
+
+/**
+ * Repository interface for managing and retrieving logs from the database.
+ *
+ * This component provides access to the application's message log, telemetry history, and debug records. It supports
+ * reactive queries for packets, telemetry data, and node-specific logs.
+ *
+ * This interface is shared across platforms via Kotlin Multiplatform (KMP).
+ */
+@Suppress("TooManyFunctions")
+interface MeshLogRepository {
+ /** Retrieves all [MeshLog]s in the database, up to [maxItem]. */
+ fun getAllLogs(maxItem: Int = DEFAULT_MAX_LOGS): Flow>
+
+ /** Retrieves all [MeshLog]s in the database in the order they were received. */
+ fun getAllLogsInReceiveOrder(maxItem: Int = DEFAULT_MAX_LOGS): Flow>
+
+ /** Retrieves all [MeshLog]s in the database without any limit. */
+ fun getAllLogsUnbounded(): Flow>
+
+ /** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
+ fun getLogsFrom(nodeNum: Int, portNum: Int): Flow>
+
+ /** Retrieves all [MeshLog]s containing [MeshPacket]s for a specific [nodeNum]. */
+ fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = -1): Flow>
+
+ /** Retrieves telemetry history for a specific node, automatically handling local node redirection. */
+ fun getTelemetryFrom(nodeNum: Int): Flow>
+
+ /**
+ * Retrieves all outgoing request logs for a specific [targetNodeNum] and [portNum].
+ *
+ * A request log is defined as an outgoing packet where `want_response` is true.
+ */
+ fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow>
+
+ /** Returns the cached [MyNodeInfo] from the system logs. */
+ fun getMyNodeInfo(): Flow
+
+ /** Persists a new log entry to the database. */
+ suspend fun insert(log: MeshLog)
+
+ /** Clears all logs from the database. */
+ suspend fun deleteAll()
+
+ /** Deletes a specific log entry by its [uuid]. */
+ suspend fun deleteLog(uuid: String)
+
+ /** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
+ suspend fun deleteLogs(nodeNum: Int, portNum: Int)
+
+ /** Prunes the log database based on the configured [retentionDays]. */
+ suspend fun deleteLogsOlderThan(retentionDays: Int)
+
+ companion object {
+ const val DEFAULT_MAX_LOGS = 5000
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index 6aff09473..714179729 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -89,7 +89,7 @@ class SendMessageUseCase(
// Apply homoglyph encoding
val finalMessageText =
- if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
+ if (homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
} else {
text
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModel.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModel.kt
index 27c727612..8a30006d8 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModel.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerViewModel.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,20 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.ui.emoji
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
-import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
+import org.meshtastic.core.repository.CustomEmojiPrefs
import javax.inject.Inject
@HiltViewModel
class EmojiPickerViewModel @Inject constructor(private val customEmojiPrefs: CustomEmojiPrefs) : ViewModel() {
var customEmojiFrequency: String?
- get() = customEmojiPrefs.customEmojiFrequency
+ get() = customEmojiPrefs.customEmojiFrequency.value
set(value) {
- customEmojiPrefs.customEmojiFrequency = value
+ customEmojiPrefs.setCustomEmojiFrequency(value)
}
}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt
index 70a25a5e2..16c5f5cfb 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt
@@ -20,10 +20,10 @@ import android.net.Uri
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
-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.repository.RadioPrefs
+import org.meshtastic.core.repository.isBle
+import org.meshtastic.core.repository.isSerial
+import org.meshtastic.core.repository.isTcp
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
import java.io.File
import javax.inject.Inject
@@ -90,7 +90,7 @@ constructor(
private fun getTarget(address: String): String = when {
radioPrefs.isSerial() -> ""
radioPrefs.isBle() -> address
- radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr) ?: ""
+ radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr.value) ?: ""
else -> ""
}
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 92d70fe4e..2f3b9e449 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
@@ -45,12 +45,12 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MyNodeInfo
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 org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.repository.isBle
+import org.meshtastic.core.repository.isSerial
+import org.meshtastic.core.repository.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_battery_low
import org.meshtastic.core.resources.firmware_update_copying
@@ -157,7 +157,7 @@ constructor(
_state.value = FirmwareUpdateState.Checking
runCatching {
val ourNode = nodeRepository.myNodeInfo.value
- val address = radioPrefs.devAddr?.drop(1)
+ val address = radioPrefs.devAddr.value?.drop(1)
if (address == null || ourNode == null) {
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
return@launch
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt
index 66b2e3b0c..1f3d5c21c 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -26,7 +26,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
-import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -52,9 +52,9 @@ constructor(
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
var mapStyleId: Int
- get() = mapPrefs.mapStyle
+ get() = mapPrefs.mapStyle.value
set(value) {
- mapPrefs.mapStyle = value
+ mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
index d47db4035..8c88f99e1 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -49,7 +49,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
-import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -96,9 +96,9 @@ constructor(
val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow()
private val targetLatLng =
- googleMapsPrefs.cameraTargetLat
+ googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }
- ?.let { lat -> googleMapsPrefs.cameraTargetLng.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
+ ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?: ourNodeInfo.value?.position?.toLatLng()
?: LatLng(0.0, 0.0)
@@ -107,9 +107,9 @@ constructor(
position =
CameraPosition(
targetLatLng,
- googleMapsPrefs.cameraZoom,
- googleMapsPrefs.cameraTilt,
- googleMapsPrefs.cameraBearing,
+ googleMapsPrefs.cameraZoom.value,
+ googleMapsPrefs.cameraTilt.value,
+ googleMapsPrefs.cameraBearing.value,
),
)
@@ -222,7 +222,7 @@ constructor(
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
}
if (configToRemove.localUri != null) {
@@ -238,28 +238,28 @@ constructor(
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
return
}
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
- googleMapsPrefs.selectedCustomTileUrl = selectedUrl
- googleMapsPrefs.selectedGoogleMapType = null
+ googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
+ googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
- googleMapsPrefs.selectedCustomTileUrl = null
- googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
+ googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
- googleMapsPrefs.selectedGoogleMapType = mapType.name
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
@@ -354,16 +354,16 @@ constructor(
fun saveCameraPosition(cameraPosition: CameraPosition) {
viewModelScope.launch {
- googleMapsPrefs.cameraTargetLat = cameraPosition.target.latitude
- googleMapsPrefs.cameraTargetLng = cameraPosition.target.longitude
- googleMapsPrefs.cameraZoom = cameraPosition.zoom
- googleMapsPrefs.cameraTilt = cameraPosition.tilt
- googleMapsPrefs.cameraBearing = cameraPosition.bearing
+ googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
+ googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
+ googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
+ googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
+ googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
}
}
private fun loadPersistedMapType() {
- val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl
+ val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
@@ -375,18 +375,18 @@ constructor(
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
- googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
- val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType
+ val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
- googleMapsPrefs.selectedGoogleMapType = null
+ googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
}
@@ -399,7 +399,7 @@ constructor(
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
- val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls
+ val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
@@ -429,7 +429,7 @@ constructor(
}
val networkItems =
- googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
+ googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
@@ -532,7 +532,7 @@ constructor(
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
- googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
+ googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
@@ -572,9 +572,9 @@ constructor(
toggledLayer?.let {
if (it.isVisible) {
- googleMapsPrefs.hiddenLayerUrls -= it.uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
- googleMapsPrefs.hiddenLayerUrls += it.uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
@@ -584,12 +584,13 @@ constructor(
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
- googleMapsPrefs.networkMapLayers =
- googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
+ googleMapsPrefs.setNetworkMapLayers(
+ googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
+ )
} else {
deleteFileToInternalStorage(uri)
}
- googleMapsPrefs.hiddenLayerUrls -= uri.toString()
+ googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index d37715e47..06037e880 100644
--- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -30,7 +30,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.resources.Res
@@ -90,47 +90,48 @@ abstract class BaseMapViewModel(
}
.stateInWhileSubscribed(initialValue = emptyMap())
- private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites)
+ private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
val showOnlyFavoritesOnMap = showOnlyFavorites
fun toggleOnlyFavorites() {
val newValue = !showOnlyFavorites.value
showOnlyFavorites.value = newValue
- mapPrefs.showOnlyFavorites = newValue
+ mapPrefs.setShowOnlyFavorites(newValue)
}
- private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap)
+ private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
val showWaypointsOnMap = showWaypoints
fun toggleShowWaypointsOnMap() {
val newValue = !showWaypoints.value
showWaypoints.value = newValue
- mapPrefs.showWaypointsOnMap = newValue
+ mapPrefs.setShowWaypointsOnMap(newValue)
}
- private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap)
+ private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
val showPrecisionCircleOnMap = showPrecisionCircle
fun toggleShowPrecisionCircleOnMap() {
val newValue = !showPrecisionCircle.value
showPrecisionCircle.value = newValue
- mapPrefs.showPrecisionCircleOnMap = newValue
+ mapPrefs.setShowPrecisionCircleOnMap(newValue)
}
- private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
+ private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
val lastHeardFilter = lastHeardFilterValue
fun setLastHeardFilter(filter: LastHeardFilter) {
lastHeardFilterValue.value = filter
- mapPrefs.lastHeardFilter = filter.seconds
+ mapPrefs.setLastHeardFilter(filter.seconds)
}
- private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
+ private val lastHeardTrackFilterValue =
+ MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
val lastHeardTrackFilter = lastHeardTrackFilterValue
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter
- mapPrefs.lastHeardTrackFilter = filter.seconds
+ mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
abstract fun getUser(userId: String?): org.meshtastic.proto.User
diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
index 535c87227..7619a3246 100644
--- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
+++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
@@ -28,10 +28,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
-import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@@ -81,5 +81,5 @@ constructor(
.stateInWhileSubscribed(initialValue = emptyList())
val tileSource
- get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
+ get() = CustomTileSource.getTileSource(mapPrefs.mapStyle.value)
}
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 cbf7a8443..a66a3a255 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,7 +44,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
-import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -72,6 +72,22 @@ class MapViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
+ every { mapPrefs.mapStyle } returns MutableStateFlow(0)
+ every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
+ every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true)
+ every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true)
+ every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
+ every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
+
+ every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0)
+ every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0)
+ every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f)
+ every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f)
+ every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f)
+ every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
+ every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
+ every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet())
+
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mockk(relaxed = true))
every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)
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 a767eaee0..a991d1061 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
@@ -38,14 +38,14 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
-import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
-import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
-import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.repository.CustomEmojiPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
@@ -79,7 +79,7 @@ constructor(
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(ChannelSet())
- private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
+ private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat.value)
val showQuickChat: StateFlow = _showQuickChat
private val _showFiltered = MutableStateFlow(false)
@@ -109,7 +109,7 @@ constructor(
val frequentEmojis: List
get() =
- customEmojiPrefs.customEmojiFrequency
+ customEmojiPrefs.customEmojiFrequency.value
?.split(",")
?.associate { entry ->
entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0)
@@ -119,7 +119,7 @@ constructor(
?.map { it.first }
?.take(6) ?: listOf("👍", "👎", "😂", "🔥", "❤️", "😮")
- val homoglyphEncodingEnabled = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
+ val homoglyphEncodingEnabled = homoglyphEncodingPrefs.homoglyphEncodingEnabled
val firstUnreadMessageUuid: StateFlow =
contactKeyForPagedMessages
@@ -163,7 +163,7 @@ constructor(
return pagedMessagesForContactKey
}
- fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
+ fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.setShowQuickChat(it) }
fun toggleShowFiltered() {
_showFiltered.update { !it }
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt
index 53b753da5..16614f012 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt
@@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.MyNodeInfo
@@ -32,6 +31,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 7e1002c40..29d948898 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -45,7 +45,6 @@ import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
@@ -54,6 +53,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.navigation.NodesRoutes
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
@@ -134,7 +134,7 @@ constructor(
val availableTimeFrames: StateFlow> =
combine(state, environmentState) { currentState, envState ->
val stateOldest = currentState.oldestTimestampSeconds()
- val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
+ val envOldest = envState.environmentMetrics.minOfOrNull { it.time.toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
@@ -148,7 +148,7 @@ constructor(
val filteredEnvironmentMetrics: StateFlow> =
combine(environmentState, _timeFrame, state) { envState, timeFrame, currentState ->
val threshold = timeFrame.timeThreshold()
- val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
+ val data = envState.environmentMetrics.filter { it.time.toLong() >= threshold }
if (currentState.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
@@ -341,7 +341,7 @@ constructor(
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
- val rxDateTime = dateFormat.format(((position.time ?: 0).toLong() * 1000L).toInstant().toDate())
+ val rxDateTime = dateFormat.format((position.time.toLong() * 1000L).toInstant().toDate())
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude
@@ -377,7 +377,7 @@ constructor(
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
- if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax
+ if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
}
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
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 db8aceff7..6c48316b4 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
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.common.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
@@ -43,11 +44,10 @@ import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
-import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.repository.DatabaseManager
+import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import java.io.BufferedWriter
@@ -126,10 +126,10 @@ constructor(
}
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
- private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays)
+ private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value)
val meshLogRetentionDays: StateFlow = _meshLogRetentionDays.asStateFlow()
- private val _meshLogLoggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled)
+ private val _meshLogLoggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled.value)
val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow()
fun setMeshLogRetentionDays(days: Int) {
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
index c58f34232..deccdc951 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
@@ -36,13 +36,13 @@ import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toReadableString
-import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_clear
@@ -230,10 +230,10 @@ constructor(
.mapLatest { logs -> withContext(Dispatchers.Default) { toUiState(logs) } }
.stateInWhileSubscribed(initialValue = persistentListOf())
- private val _retentionDays = MutableStateFlow(meshLogPrefs.retentionDays)
+ private val _retentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value)
val retentionDays: StateFlow = _retentionDays.asStateFlow()
- private val _loggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled)
+ private val _loggingEnabled = MutableStateFlow(meshLogPrefs.loggingEnabled.value)
val loggingEnabled: StateFlow = _loggingEnabled.asStateFlow()
// --- Managers ---
@@ -265,18 +265,18 @@ constructor(
fun setRetentionDays(days: Int) {
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
- meshLogPrefs.retentionDays = clamped
+ meshLogPrefs.setRetentionDays(clamped)
_retentionDays.value = clamped
viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
}
fun setLoggingEnabled(enabled: Boolean) {
- meshLogPrefs.loggingEnabled = enabled
+ meshLogPrefs.setLoggingEnabled(enabled)
_loggingEnabled.value = enabled
if (!enabled) {
viewModelScope.launch { meshLogRepository.deleteAll() }
} else {
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
+ viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt
index cc263bfe1..e851b4880 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt
@@ -21,7 +21,7 @@ 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.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@@ -33,32 +33,32 @@ constructor(
private val messageFilter: MessageFilter,
) : ViewModel() {
- private val _filterEnabled = MutableStateFlow(filterPrefs.filterEnabled)
+ private val _filterEnabled = MutableStateFlow(filterPrefs.filterEnabled.value)
val filterEnabled: StateFlow = _filterEnabled.asStateFlow()
- private val _filterWords = MutableStateFlow(filterPrefs.filterWords.toList().sorted())
+ private val _filterWords = MutableStateFlow(filterPrefs.filterWords.value.toList().sorted())
val filterWords: StateFlow> = _filterWords.asStateFlow()
fun setFilterEnabled(enabled: Boolean) {
- filterPrefs.filterEnabled = enabled
+ filterPrefs.setFilterEnabled(enabled)
_filterEnabled.value = enabled
}
fun addFilterWord(word: String) {
if (word.isBlank()) return
val trimmed = word.trim()
- val current = filterPrefs.filterWords.toMutableSet()
+ val current = filterPrefs.filterWords.value.toMutableSet()
if (current.add(trimmed)) {
- filterPrefs.filterWords = current
+ filterPrefs.setFilterWords(current)
_filterWords.value = current.toList().sorted()
messageFilter.rebuildPatterns()
}
}
fun removeFilterWord(word: String) {
- val current = filterPrefs.filterWords.toMutableSet()
+ val current = filterPrefs.filterWords.value.toMutableSet()
if (current.remove(word)) {
- filterPrefs.filterWords = current
+ filterPrefs.setFilterWords(current)
_filterWords.value = current.toList().sorted()
messageFilter.rebuildPatterns()
}
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 54b04c295..839e8d0e0 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
@@ -58,9 +58,9 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
-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.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -131,13 +131,13 @@ constructor(
private val adminActionsUseCase: AdminActionsUseCase,
private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
) : ViewModel() {
- var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
+ var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {
toggleAnalyticsUseCase()
}
- val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
+ val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.homoglyphEncodingEnabled
fun toggleHomoglyphCharactersEncodingEnabled() {
toggleHomoglyphEncodingUseCase()
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt
index 4451e9f67..47c98eaf8 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt
@@ -61,7 +61,8 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
val currentMapReportSettings = formState.value.map_report_settings ?: ModuleConfig.MapReportSettings()
if (!(currentMapReportSettings.should_report_location ?: false)) {
- val settings = currentMapReportSettings.copy(should_report_location = viewModel.shouldReportLocation(destNum))
+ val settings =
+ currentMapReportSettings.copy(should_report_location = viewModel.shouldReportLocation(destNum).value)
formState.value = formState.value.copy(map_report_settings = settings)
}
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
index 7e628b85b..9af1f1c0d 100644
--- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -30,6 +30,7 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.common.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
@@ -39,11 +40,10 @@ 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
-import org.meshtastic.core.repository.DatabaseManager
+import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.UiPrefs
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
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
index 101cce4fe..582327179 100644
--- 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
@@ -32,8 +32,8 @@ 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.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogPrefs
+import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.AlertManager
@@ -56,8 +56,8 @@ class DebugViewModelTest {
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
+ every { meshLogPrefs.retentionDays.value } returns 7
+ every { meshLogPrefs.loggingEnabled.value } returns true
viewModel =
DebugViewModel(
@@ -77,7 +77,7 @@ class DebugViewModelTest {
fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
viewModel.setRetentionDays(14)
- verify { meshLogPrefs.retentionDays = 14 }
+ verify { meshLogPrefs.setRetentionDays(14) }
coVerify { meshLogRepository.deleteLogsOlderThan(14) }
assertEquals(14, viewModel.retentionDays.value)
}
@@ -86,7 +86,7 @@ class DebugViewModelTest {
fun `setLoggingEnabled false deletes all logs`() = runTest {
viewModel.setLoggingEnabled(false)
- verify { meshLogPrefs.loggingEnabled = false }
+ verify { meshLogPrefs.setLoggingEnabled(false) }
coVerify { meshLogRepository.deleteAll() }
assertEquals(false, viewModel.loggingEnabled.value)
}
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
index 40bb475eb..eae08f319 100644
--- 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
@@ -22,7 +22,7 @@ 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.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
class FilterSettingsViewModelTest {
@@ -34,8 +34,8 @@ class FilterSettingsViewModelTest {
@Before
fun setUp() {
- every { filterPrefs.filterEnabled } returns true
- every { filterPrefs.filterWords } returns setOf("apple", "banana")
+ every { filterPrefs.filterEnabled.value } returns true
+ every { filterPrefs.filterWords.value } returns setOf("apple", "banana")
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
}
@@ -43,7 +43,7 @@ class FilterSettingsViewModelTest {
@Test
fun `setFilterEnabled updates prefs and state`() {
viewModel.setFilterEnabled(false)
- verify { filterPrefs.filterEnabled = false }
+ verify { filterPrefs.setFilterEnabled(false) }
assertEquals(false, viewModel.filterEnabled.value)
}
@@ -51,7 +51,7 @@ class FilterSettingsViewModelTest {
fun `addFilterWord updates prefs and rebuilds patterns`() {
viewModel.addFilterWord("cherry")
- verify { filterPrefs.filterWords = any() }
+ verify { filterPrefs.setFilterWords(any()) }
verify { messageFilter.rebuildPatterns() }
assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
}
@@ -60,7 +60,7 @@ class FilterSettingsViewModelTest {
fun `removeFilterWord updates prefs and rebuilds patterns`() {
viewModel.removeFilterWord("apple")
- verify { filterPrefs.filterWords = any() }
+ verify { filterPrefs.setFilterWords(any()) }
verify { messageFilter.rebuildPatterns() }
assertEquals(listOf("banana"), viewModel.filterWords.value)
}
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
index adf6dd9ac..b2067fbf2 100644
--- 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
@@ -45,9 +45,9 @@ 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.Node
-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.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
From 27e7dec69e2215dc4c02c31c984c457ed1c1e836 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 5 Mar 2026 20:37:58 -0600
Subject: [PATCH 022/407] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4729)
---
app/src/main/assets/firmware_releases.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 9074bfdbc..7c86c7b35 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -196,7 +196,7 @@
},
{
"id": "9798",
- "title": "Attempt to fix issue 9713",
+ "title": "Fix: Traceroute through MQTT misses uplink node if MQTT is encrypted",
"page_url": "https://github.com/meshtastic/firmware/pull/9798",
"zip_url": "https://discord.com/invite/meshtastic"
},
From f3775a601c27fbfddb48b86e2ab8d11603aff372 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 6 Mar 2026 07:36:26 -0600
Subject: [PATCH 023/407] chore(deps): update datadog to v3.7.1 (#4734)
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 daa0a459c..c0e8ae0c2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,7 +47,7 @@ ktor = "3.4.1"
# Other
aboutlibraries = "13.2.1"
coil = "3.4.0"
-dd-sdk-android = "3.7.0"
+dd-sdk-android = "3.7.1"
detekt = "1.23.8"
dokka = "2.2.0-Beta"
devtools-ksp = "2.3.6"
From cffbd0880690bfff94d7f78a85dbaa8eb8dc6ce0 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 6 Mar 2026 16:06:50 -0600
Subject: [PATCH 024/407] =?UTF-8?q?refactor:=20migrate=20core=20modules=20?=
=?UTF-8?q?to=20Kotlin=20Multiplatform=20and=20consolidat=E2=80=A6=20(#473?=
=?UTF-8?q?5)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/build.gradle.kts | 19 +++-
app/detekt-baseline.xml | 69 +------------
.../org/meshtastic/app}/TestRunner.kt | 10 +-
.../filter/MessageFilterIntegrationTest.kt | 4 +-
.../app/analytics}/FdroidPlatformAnalytics.kt | 28 ++----
.../meshtastic/app}/di/FDroidNetworkModule.kt | 11 +--
.../app}/di/FdroidPlatformAnalyticsModule.kt | 9 +-
.../app/analytics}/GooglePlatformAnalytics.kt | 46 +++------
.../meshtastic/app}/di/GoogleNetworkModule.kt | 11 ++-
.../app}/di/GooglePlatformAnalyticsModule.kt | 9 +-
app/src/main/AndroidManifest.xml | 16 ++--
.../org/meshtastic/app}/ApplicationModule.kt | 14 +--
.../org/meshtastic/app}/MainActivity.kt | 6 +-
.../org/meshtastic/app}/MeshServiceClient.kt | 6 +-
.../meshtastic/app}/MeshUtilApplication.kt | 8 +-
.../org/meshtastic/app}/di/AppModule.kt | 3 +-
.../org/meshtastic/app}/di/DataModule.kt | 5 +-
.../org/meshtastic/app/di/DataSourceModule.kt | 47 +++++++++
.../org/meshtastic/app}/di/DataStoreModule.kt | 2 +-
.../org/meshtastic/app}/di/DatabaseModule.kt | 2 +-
.../org/meshtastic/app/di/NetworkModule.kt | 96 +++++++++++++++++++
.../app}/di/NodeDataSourceModule.kt | 5 +-
.../org/meshtastic/app}/di/PrefsModule.kt | 14 ++-
.../meshtastic/app}/di/RepositoryModule.kt | 8 +-
.../org/meshtastic/app}/di/UseCaseModule.kt | 2 +-
.../usecase/GetDiscoveredDevicesUseCase.kt | 12 +--
.../meshtastic/app}/model/DeviceListEntry.kt | 2 +-
.../org/meshtastic/app}/model/UIViewModel.kt | 22 +----
.../app}/navigation/ChannelsNavigation.kt | 7 +-
.../app}/navigation/ConnectionsNavigation.kt | 7 +-
.../app}/navigation/ContactsNavigation.kt | 4 +-
.../app}/navigation/FirmwareNavigation.kt | 5 +-
.../app}/navigation/MapNavigation.kt | 5 +-
.../app}/navigation/NodesNavigation.kt | 4 +-
.../app}/navigation/SettingsNavigation.kt | 2 +-
.../repository/network/ConnectivityManager.kt | 46 ++++-----
.../repository/network/NetworkRepository.kt | 2 +-
.../network/NetworkRepositoryModule.kt | 15 ++-
.../app}/repository/network/NsdManager.kt | 2 +-
.../radio/AndroidRadioInterfaceService.kt | 59 ++++--------
.../app}/repository/radio/IRadioInterface.kt | 5 +-
.../app}/repository/radio/InterfaceFactory.kt | 2 +-
.../repository/radio/InterfaceFactorySpi.kt | 15 ++-
.../app}/repository/radio/InterfaceMapKey.kt | 2 +-
.../app}/repository/radio/InterfaceSpec.kt | 11 +--
.../radio/MeshtasticRadioProfile.kt | 2 +-
.../radio/MeshtasticRadioServiceImpl.kt | 2 +-
.../app}/repository/radio/MockInterface.kt | 2 +-
.../repository/radio/MockInterfaceFactory.kt | 11 +--
.../repository/radio/MockInterfaceSpec.kt | 17 +---
.../app}/repository/radio/NopInterface.kt | 10 +-
.../repository/radio/NopInterfaceFactory.kt | 11 +--
.../app}/repository/radio/NopInterfaceSpec.kt | 17 +---
.../repository/radio/NordicBleInterface.kt | 2 +-
.../radio/NordicBleInterfaceFactory.kt | 5 +-
.../radio/NordicBleInterfaceSpec.kt | 2 +-
.../repository/radio/RadioRepositoryModule.kt | 2 +-
.../app}/repository/radio/SerialInterface.kt | 8 +-
.../radio/SerialInterfaceFactory.kt | 11 +--
.../repository/radio/SerialInterfaceSpec.kt | 4 +-
.../app}/repository/radio/StreamInterface.kt | 2 +-
.../app}/repository/radio/TCPInterface.kt | 4 +-
.../repository/radio/TCPInterfaceFactory.kt | 11 +--
.../app}/repository/radio/TCPInterfaceSpec.kt | 17 +---
.../app}/repository/usb/ProbeTableProvider.kt | 24 ++---
.../meshtastic/app}/repository/usb/README.md | 0
.../app}/repository/usb/SerialConnection.kt | 19 ++--
.../repository/usb/SerialConnectionImpl.kt | 2 +-
.../usb/SerialConnectionListener.kt | 28 ++----
.../repository/usb/UsbBroadcastReceiver.kt | 2 +-
.../app}/repository/usb/UsbManager.kt | 2 +-
.../app}/repository/usb/UsbRepository.kt | 2 +-
.../repository/usb/UsbRepositoryModule.kt | 13 +--
.../app}/service/AndroidAppWidgetUpdater.kt | 4 +-
.../service/AndroidMeshLocationManager.kt | 4 +-
.../app}/service/AndroidMeshWorkerManager.kt | 2 +-
.../app}/service/BootCompleteReceiver.kt | 5 +-
.../org/meshtastic/app}/service/Constants.kt | 2 +-
.../app}/service/MarkAsReadReceiver.kt | 2 +-
.../meshtastic/app}/service/MeshService.kt | 6 +-
.../service/MeshServiceNotificationsImpl.kt | 16 ++--
.../app}/service/MeshServiceStarter.kt | 6 +-
.../app}/service/ReactionReceiver.kt | 4 +-
.../meshtastic/app}/service/ReplyReceiver.kt | 4 +-
.../app}/service/ServiceBroadcasts.kt | 2 +-
.../org/meshtastic/app}/ui/Main.kt | 31 +++---
.../app}/ui/connections/ConnectionsScreen.kt | 18 ++--
.../ui/connections/ConnectionsViewModel.kt | 2 +-
.../app}/ui/connections/DeviceType.kt | 2 +-
.../app}/ui/connections/ScannerViewModel.kt | 8 +-
.../ui/connections/components/BLEDevices.kt | 6 +-
.../components/ConnectingDeviceInfo.kt | 2 +-
.../components/ConnectionsNavIcon.kt | 4 +-
.../components/ConnectionsSegmentedBar.kt | 4 +-
.../components/CurrentlyConnectedInfo.kt | 4 +-
.../connections/components/DeviceListItem.kt | 4 +-
.../components/DeviceListSection.kt | 4 +-
.../components/EmptyStateContent.kt | 2 +-
.../connections/components/NetworkDevices.kt | 8 +-
.../ui/connections/components/UsbDevices.kt | 6 +-
.../app}/ui/node/AdaptiveNodeListScreen.kt | 2 +-
.../org/meshtastic/app}/ui/sharing/Channel.kt | 2 +-
.../app}/ui/sharing/ChannelViewModel.kt | 6 +-
.../app}/widget/LocalStatsWidget.kt | 10 +-
.../app}/widget/LocalStatsWidgetReceiver.kt | 2 +-
.../app}/widget/LocalStatsWidgetState.kt | 2 +-
.../app}/widget/RefreshLocalStatsAction.kt | 2 +-
.../app}/worker/MeshLogCleanupWorker.kt | 2 +-
.../app}/worker/ServiceKeepAliveWorker.kt | 8 +-
.../meshtastic/app}/MeshTestApplication.kt | 2 +-
.../radio/NordicBleInterfaceRetryTest.kt | 2 +-
.../radio/NordicBleInterfaceTest.kt | 2 +-
.../repository/radio/StreamInterfaceTest.kt | 2 +-
.../app}/repository/radio/TCPInterfaceTest.kt | 4 +-
.../org/meshtastic/app}/service/Fakes.kt | 2 +-
.../app}/service/ServiceBroadcastsTest.kt | 4 +-
.../org/meshtastic/app}/ui/UIUnitTest.kt | 2 +-
.../app}/ui/metrics/EnvironmentMetricsTest.kt | 2 +-
app/src/test/resources/robolectric.properties | 2 +-
core/analytics/README.md | 35 -------
core/analytics/build.gradle.kts | 56 -----------
core/common/build.gradle.kts | 1 +
core/data/build.gradle.kts | 77 +++++++++------
core/data/detekt-baseline.xml | 6 +-
.../BootloaderOtaQuirksJsonDataSourceImpl.kt} | 8 +-
.../DeviceHardwareJsonDataSourceImpl.kt} | 8 +-
.../FirmwareReleaseJsonDataSourceImpl.kt} | 8 +-
.../repository/LocationRepositoryImpl.kt} | 41 ++++----
.../BootloaderOtaQuirksJsonDataSource.kt | 23 +++++
.../DeviceHardwareJsonDataSource.kt | 23 +++++
.../DeviceHardwareLocalDataSource.kt | 0
.../FirmwareReleaseJsonDataSource.kt | 23 +++++
.../FirmwareReleaseLocalDataSource.kt | 0
.../data/datasource/NodeInfoReadDataSource.kt | 3 +-
.../datasource/NodeInfoWriteDataSource.kt | 0
.../SwitchingNodeInfoReadDataSource.kt | 0
.../SwitchingNodeInfoWriteDataSource.kt | 0
.../core/data/manager/CommandSenderImpl.kt | 19 ++--
.../manager/FromRadioPacketHandlerImpl.kt | 0
.../core/data/manager/HistoryManagerImpl.kt | 0
.../data/manager/MeshActionHandlerImpl.kt | 4 +-
.../data/manager/MeshConfigFlowManagerImpl.kt | 4 +-
.../data/manager/MeshConfigHandlerImpl.kt | 0
.../data/manager/MeshConnectionManagerImpl.kt | 4 +-
.../core/data/manager/MeshDataHandlerImpl.kt | 53 ++++++----
.../data/manager/MeshMessageProcessorImpl.kt | 76 +++++++++------
.../core/data/manager/MeshRouterImpl.kt | 0
.../core/data/manager/MessageFilterImpl.kt | 3 +-
.../core/data/manager/MqttManagerImpl.kt | 0
.../data/manager/NeighborInfoHandlerImpl.kt | 0
.../core/data/manager/NodeManagerImpl.kt | 84 +++++++++-------
.../core/data/manager/PacketHandlerImpl.kt | 82 ++++++++++------
.../data/manager/TracerouteHandlerImpl.kt | 0
.../DeviceHardwareRepositoryImpl.kt | 0
.../repository/FirmwareReleaseRepository.kt | 0
.../data/repository/MeshLogRepositoryImpl.kt | 0
.../data/repository/NodeRepositoryImpl.kt | 0
.../data/repository/PacketRepositoryImpl.kt | 0
.../repository/QuickChatActionRepository.kt | 3 +-
.../repository/RadioConfigRepositoryImpl.kt | 0
.../TracerouteSnapshotRepository.kt | 0
.../data/manager/CommandSenderHopLimitTest.kt | 0
.../data/manager/CommandSenderImplTest.kt | 0
.../manager/FromRadioPacketHandlerImplTest.kt | 0
.../data/manager/HistoryManagerImplTest.kt | 0
.../manager/MeshConnectionManagerImplTest.kt | 2 +-
.../core/data/manager/MeshDataHandlerTest.kt | 2 +-
.../data/manager/MessageFilterImplTest.kt | 0
.../core/data/manager/NodeManagerImplTest.kt | 0
.../data/manager/PacketHandlerImplTest.kt | 0
.../DeviceHardwareRepositoryTest.kt | 0
.../data/repository/MeshLogRepositoryTest.kt | 0
.../data/repository/NodeRepositoryTest.kt | 0
.../core/data/di/GoogleDataModule.kt | 42 --------
core/database/detekt-baseline.xml | 5 +-
core/datastore/build.gradle.kts | 10 +-
core/di/build.gradle.kts | 38 +++-----
.../core/di/CoroutineDispatchers.kt | 3 +-
.../meshtastic/core/di/ProcessLifecycle.kt | 3 +-
core/domain/build.gradle.kts | 54 +++++++----
.../usecase/settings/AdminActionsUseCase.kt | 0
.../settings/CleanNodeDatabaseUseCase.kt | 0
.../usecase/settings/ExportDataUseCase.kt | 28 +++---
.../usecase/settings/ExportProfileUseCase.kt | 11 ++-
.../settings/ExportSecurityConfigUseCase.kt | 41 ++++----
.../usecase/settings/ImportProfileUseCase.kt | 10 +-
.../usecase/settings/InstallProfileUseCase.kt | 0
.../usecase/settings/IsOtaCapableUseCase.kt | 0
.../usecase/settings/MeshLocationUseCase.kt | 0
.../settings/ProcessRadioResponseUseCase.kt | 0
.../usecase/settings/RadioConfigUseCase.kt | 0
.../settings/SetAppIntroCompletedUseCase.kt | 0
.../settings/SetDatabaseCacheLimitUseCase.kt | 0
.../settings/SetMeshLogSettingsUseCase.kt | 0
.../settings/SetProvideLocationUseCase.kt | 0
.../usecase/settings/SetThemeUseCase.kt | 0
.../settings/ToggleAnalyticsUseCase.kt | 0
.../ToggleHomoglyphEncodingUseCase.kt | 0
.../core/domain/FakeRadioController.kt | 0
.../domain/usecase/SendMessageUseCaseTest.kt | 14 +--
.../settings/AdminActionsUseCaseTest.kt | 8 +-
.../settings/CleanNodeDatabaseUseCaseTest.kt | 8 +-
.../usecase/settings/ExportDataUseCaseTest.kt | 28 +++---
.../settings/ExportProfileUseCaseTest.kt | 18 ++--
.../ExportSecurityConfigUseCaseTest.kt | 35 ++++---
.../settings/ImportProfileUseCaseTest.kt | 20 ++--
.../settings/InstallProfileUseCaseTest.kt | 6 +-
.../settings/IsOtaCapableUseCaseTest.kt | 10 +-
.../settings/MeshLocationUseCaseTest.kt | 6 +-
.../ProcessRadioResponseUseCaseTest.kt | 10 +-
.../settings/RadioConfigUseCaseTest.kt | 8 +-
.../SetAppIntroCompletedUseCaseTest.kt | 6 +-
.../SetDatabaseCacheLimitUseCaseTest.kt | 6 +-
.../settings/SetMeshLogSettingsUseCaseTest.kt | 6 +-
.../settings/SetProvideLocationUseCaseTest.kt | 6 +-
.../usecase/settings/SetThemeUseCaseTest.kt | 6 +-
.../settings/ToggleAnalyticsUseCaseTest.kt | 6 +-
.../ToggleHomoglyphEncodingUseCaseTest.kt | 6 +-
core/model/detekt-baseline.xml | 5 -
core/network/build.gradle.kts | 66 ++++++++-----
.../network/repository/MQTTRepositoryImpl.kt} | 10 +-
.../repository/TrustAllX509TrustManager.kt | 0
.../network/DeviceHardwareRemoteDataSource.kt | 0
.../FirmwareReleaseRemoteDataSource.kt | 0
.../core/network/repository/MQTTRepository.kt | 41 ++++++++
.../core/network/service/ApiService.kt | 3 +-
core/network/src/main/AndroidManifest.xml | 4 -
.../core/network/di/NetworkModule.kt | 81 ----------------
core/prefs/build.gradle.kts | 42 +++++---
.../core/prefs/filter/FilterPrefsTest.kt | 0
.../prefs/analytics/AnalyticsPrefsImpl.kt | 0
.../meshtastic/core/prefs/di/Qualifiers.kt | 67 +++++++++++++
.../core/prefs/emoji/CustomEmojiPrefsImpl.kt | 0
.../core/prefs/filter/FilterPrefsImpl.kt | 0
.../prefs/homoglyph/HomoglyphPrefsImpl.kt | 0
.../core/prefs/map/MapConsentPrefsImpl.kt | 0
.../meshtastic/core/prefs/map/MapPrefsImpl.kt | 0
.../prefs/map/MapTileProviderPrefsImpl.kt | 0
.../core/prefs/mesh/MeshPrefsImpl.kt | 0
.../core/prefs/meshlog/MeshLogPrefsImpl.kt | 0
.../core/prefs/radio/RadioPrefsImpl.kt | 0
.../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 0
.../meshtastic/core/repository/Location.kt | 20 ++++
.../meshtastic/core/repository}/DataPair.kt | 5 +-
.../core/repository/LocationRepository.kt | 31 ++++++
.../core/repository}/PlatformAnalytics.kt | 16 +---
.../service/AndroidRadioControllerImpl.kt | 2 +-
feature/map/build.gradle.kts | 3 +
.../meshtastic/feature/map/MapViewModel.kt | 6 +-
.../CustomTileProviderManagerSheet.kt | 2 +-
.../map}/model/CustomTileProviderConfig.kt | 2 +-
.../feature/map}/prefs/di/GoogleMapsModule.kt | 14 ++-
.../feature/map}/prefs/map/GoogleMapsPrefs.kt | 4 +-
.../CustomTileProviderRepository.kt | 4 +-
.../feature/map/MapViewModelTest.kt | 6 +-
feature/messaging/build.gradle.kts | 1 -
.../meshtastic/feature/messaging/Message.kt | 5 +-
feature/node/detekt-baseline.xml | 4 -
feature/settings/detekt-baseline.xml | 11 ---
.../feature/settings/SettingsViewModel.kt | 12 ++-
.../settings/radio/RadioConfigViewModel.kt | 11 ++-
.../radio/RadioConfigViewModelTest.kt | 2 +-
gradle/libs.versions.toml | 1 +
.../meshserviceexample/MainActivity.kt | 2 +-
settings.gradle.kts | 1 -
265 files changed, 1383 insertions(+), 1340 deletions(-)
rename app/src/androidTest/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/TestRunner.kt (83%)
rename app/src/androidTest/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/filter/MessageFilterIntegrationTest.kt (92%)
rename {core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform => app/src/fdroid/kotlin/org/meshtastic/app/analytics}/FdroidPlatformAnalytics.kt (67%)
rename {core/network/src/fdroid/kotlin/org/meshtastic/core/network => app/src/fdroid/kotlin/org/meshtastic/app}/di/FDroidNetworkModule.kt (86%)
rename {core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics => app/src/fdroid/kotlin/org/meshtastic/app}/di/FdroidPlatformAnalyticsModule.kt (83%)
rename {core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform => app/src/google/kotlin/org/meshtastic/app/analytics}/GooglePlatformAnalytics.kt (88%)
rename {core/network/src/google/kotlin/org/meshtastic/core/network => app/src/google/kotlin/org/meshtastic/app}/di/GoogleNetworkModule.kt (87%)
rename {core/analytics/src/google/kotlin/org/meshtastic/core/analytics => app/src/google/kotlin/org/meshtastic/app}/di/GooglePlatformAnalyticsModule.kt (83%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ApplicationModule.kt (87%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/MainActivity.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/MeshServiceClient.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/MeshUtilApplication.kt (96%)
rename {core/di/src/main/kotlin/org/meshtastic/core => app/src/main/kotlin/org/meshtastic/app}/di/AppModule.kt (94%)
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/DataModule.kt (94%)
create mode 100644 app/src/main/kotlin/org/meshtastic/app/di/DataSourceModule.kt
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/DataStoreModule.kt (99%)
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/DatabaseModule.kt (96%)
create mode 100644 app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/NodeDataSourceModule.kt (95%)
rename {core/prefs/src/main/kotlin/org/meshtastic/core/prefs => app/src/main/kotlin/org/meshtastic/app}/di/PrefsModule.kt (93%)
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/RepositoryModule.kt (95%)
rename {core/data/src/main/kotlin/org/meshtastic/core/data => app/src/main/kotlin/org/meshtastic/app}/di/UseCaseModule.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/domain/usecase/GetDiscoveredDevicesUseCase.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/model/DeviceListEntry.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/model/UIViewModel.kt (92%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/ChannelsNavigation.kt (94%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/ConnectionsNavigation.kt (95%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/ContactsNavigation.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/FirmwareNavigation.kt (93%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/MapNavigation.kt (95%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/NodesNavigation.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/navigation/SettingsNavigation.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/network/ConnectivityManager.kt (63%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/network/NetworkRepository.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/network/NetworkRepositoryModule.kt (78%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/network/NsdManager.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/AndroidRadioInterfaceService.kt (89%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/IRadioInterface.kt (92%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/InterfaceFactory.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/InterfaceFactorySpi.kt (65%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/InterfaceMapKey.kt (95%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/InterfaceSpec.kt (82%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/MeshtasticRadioProfile.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/MeshtasticRadioServiceImpl.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/MockInterface.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/MockInterfaceFactory.kt (84%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/MockInterfaceSpec.kt (69%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NopInterface.kt (88%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NopInterfaceFactory.kt (84%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NopInterfaceSpec.kt (65%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NordicBleInterface.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NordicBleInterfaceFactory.kt (90%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NordicBleInterfaceSpec.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/RadioRepositoryModule.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/SerialInterface.kt (95%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/SerialInterfaceFactory.kt (84%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/SerialInterfaceSpec.kt (94%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/StreamInterface.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/TCPInterface.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/TCPInterfaceFactory.kt (84%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/TCPInterfaceSpec.kt (65%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/ProbeTableProvider.kt (63%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/README.md (100%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/SerialConnection.kt (74%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/SerialConnectionImpl.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/SerialConnectionListener.kt (63%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/UsbBroadcastReceiver.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/UsbManager.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/UsbRepository.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/usb/UsbRepositoryModule.kt (80%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/AndroidAppWidgetUpdater.kt (94%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/AndroidMeshLocationManager.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/AndroidMeshWorkerManager.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/BootCompleteReceiver.kt (94%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/Constants.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/MarkAsReadReceiver.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/MeshService.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/MeshServiceNotificationsImpl.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/MeshServiceStarter.kt (94%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/ReactionReceiver.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/ReplyReceiver.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/ServiceBroadcasts.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/Main.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/ConnectionsScreen.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/ConnectionsViewModel.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/DeviceType.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/ScannerViewModel.kt (97%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/BLEDevices.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/ConnectingDeviceInfo.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/ConnectionsNavIcon.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/ConnectionsSegmentedBar.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/CurrentlyConnectedInfo.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/DeviceListItem.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/DeviceListSection.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/EmptyStateContent.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/NetworkDevices.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/connections/components/UsbDevices.kt (92%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/node/AdaptiveNodeListScreen.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/sharing/Channel.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/sharing/ChannelViewModel.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/widget/LocalStatsWidget.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/widget/LocalStatsWidgetReceiver.kt (96%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/widget/LocalStatsWidgetState.kt (99%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/widget/RefreshLocalStatsAction.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/worker/MeshLogCleanupWorker.kt (98%)
rename app/src/main/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/worker/ServiceKeepAliveWorker.kt (95%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/MeshTestApplication.kt (98%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NordicBleInterfaceRetryTest.kt (99%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/NordicBleInterfaceTest.kt (99%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/StreamInterfaceTest.kt (98%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/repository/radio/TCPInterfaceTest.kt (95%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/Fakes.kt (98%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/service/ServiceBroadcastsTest.kt (96%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/UIUnitTest.kt (98%)
rename app/src/test/{java/com/geeksville/mesh => kotlin/org/meshtastic/app}/ui/metrics/EnvironmentMetricsTest.kt (98%)
delete mode 100644 core/analytics/README.md
delete mode 100644 core/analytics/build.gradle.kts
rename core/data/src/{main/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt => androidMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSourceImpl.kt} (83%)
rename core/data/src/{main/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt => androidMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSourceImpl.kt} (85%)
rename core/data/src/{main/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt => androidMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSourceImpl.kt} (85%)
rename core/data/src/{main/kotlin/org/meshtastic/core/data/repository/LocationRepository.kt => androidMain/kotlin/org/meshtastic/core/data/repository/LocationRepositoryImpl.kt} (77%)
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt (100%)
create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt (97%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt (96%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt (99%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt (98%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt (99%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt (94%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt (81%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt (95%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt (82%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt (70%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepository.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepository.kt (97%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt (100%)
rename core/data/src/{main => commonMain}/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt (99%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt (99%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt (100%)
rename core/data/src/{test => commonTest}/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt (100%)
delete mode 100644 core/data/src/google/kotlin/org/meshtastic/core/data/di/GoogleDataModule.kt
rename core/di/src/{main => commonMain}/kotlin/org/meshtastic/core/di/CoroutineDispatchers.kt (95%)
rename core/di/src/{main => commonMain}/kotlin/org/meshtastic/core/di/ProcessLifecycle.kt (95%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt (85%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt (77%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt (53%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt (79%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt (100%)
rename core/domain/src/{main => commonMain}/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt (100%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/FakeRadioController.kt (100%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt (97%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt (96%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt (96%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt (79%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt (77%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt (66%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt (78%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt (97%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt (97%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt (95%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt (96%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt (98%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt (95%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt (95%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt (97%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt (94%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt (95%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt (96%)
rename core/domain/src/{test => commonTest}/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt (96%)
rename core/network/src/{main/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt => androidMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt} (96%)
rename core/network/src/{main => androidMain}/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt (100%)
rename core/network/src/{main => commonMain}/kotlin/org/meshtastic/core/network/DeviceHardwareRemoteDataSource.kt (100%)
rename core/network/src/{main => commonMain}/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt (100%)
create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt
rename core/network/src/{main => commonMain}/kotlin/org/meshtastic/core/network/service/ApiService.kt (97%)
delete mode 100644 core/network/src/main/AndroidManifest.xml
delete mode 100644 core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt
rename core/prefs/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt (100%)
create mode 100644 core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/di/Qualifiers.kt
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt (100%)
rename core/prefs/src/{main => commonMain}/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt (100%)
create mode 100644 core/repository/src/androidMain/kotlin/org/meshtastic/core/repository/Location.kt
rename core/{analytics/src/main/kotlin/org/meshtastic/core/analytics => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/DataPair.kt (92%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationRepository.kt
rename core/{analytics/src/main/kotlin/org/meshtastic/core/analytics/platform => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/PlatformAnalytics.kt (75%)
rename {core/data/src/google/kotlin/org/meshtastic/core/data => feature/map/src/google/kotlin/org/meshtastic/feature/map}/model/CustomTileProviderConfig.kt (96%)
rename {core/prefs/src/google/kotlin/org/meshtastic/core => feature/map/src/google/kotlin/org/meshtastic/feature/map}/prefs/di/GoogleMapsModule.kt (81%)
rename {core/prefs/src/google/kotlin/org/meshtastic/core => feature/map/src/google/kotlin/org/meshtastic/feature/map}/prefs/map/GoogleMapsPrefs.kt (98%)
rename {core/data/src/google/kotlin/org/meshtastic/core/data => feature/map/src/google/kotlin/org/meshtastic/feature/map}/repository/CustomTileProviderRepository.kt (97%)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2a740864b..e0d08bbf7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -44,7 +44,7 @@ if (keystorePropertiesFile.exists()) {
}
configure {
- namespace = configProperties.getProperty("APPLICATION_ID")
+ namespace = "org.meshtastic.app"
signingConfigs {
create("release") {
@@ -150,7 +150,7 @@ configure {
includeInBundle = false
}
- testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
+ testInstrumentationRunner = "org.meshtastic.app.TestRunner"
}
// Configure existing product flavors (defined by convention plugin)
@@ -210,7 +210,6 @@ project.afterEvaluate {
}
dependencies {
- implementation(projects.core.analytics)
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.data)
@@ -251,10 +250,14 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
+ implementation(libs.okhttp3.logging.interceptor)
implementation(libs.org.eclipse.paho.client.mqttv3)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
@@ -275,6 +278,16 @@ dependencies {
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
+ googleImplementation(libs.dd.sdk.android.okhttp)
+ googleImplementation(libs.dd.sdk.android.compose)
+ googleImplementation(libs.dd.sdk.android.logs)
+ googleImplementation(libs.dd.sdk.android.rum)
+ googleImplementation(libs.dd.sdk.android.timber)
+ googleImplementation(libs.dd.sdk.android.trace)
+ googleImplementation(libs.dd.sdk.android.trace.otel)
+ googleImplementation(platform(libs.firebase.bom))
+ googleImplementation(libs.firebase.analytics)
+ googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 801f6c2f2..0e08e976a 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -2,91 +2,26 @@
- CommentSpacing:Coroutines.kt$/// Wrap launch with an exception handler, FIXME, move into a utility lib
- CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError
- CyclomaticComplexMethod:MeshMessageProcessor.kt$MeshMessageProcessor$private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?)
CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController)
- EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ }
- EmptyFunctionBlock:NopInterface.kt$NopInterface${ }
- EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${}
- FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
- FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
- FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
- FinalNewline:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt
- FinalNewline:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt
- FinalNewline:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt
- FinalNewline:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt
- FinalNewline:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt
- FinalNewline:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt
- FinalNewline:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt
- FinalNewline:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt
- FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt
- FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt
- FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()
- MagicNumber:Contacts.kt$7
- MagicNumber:Contacts.kt$8
- MagicNumber:MQTTRepository.kt$MQTTRepository$512
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
- MagicNumber:ServiceClient.kt$ServiceClient$500
MagicNumber:StreamInterface.kt$StreamInterface$0xff
MagicNumber:StreamInterface.kt$StreamInterface$3
MagicNumber:StreamInterface.kt$StreamInterface$4
MagicNumber:StreamInterface.kt$StreamInterface$8
MagicNumber:TCPInterface.kt$TCPInterface$1000
- MagicNumber:UIState.kt$4
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
- MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"
- NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt
- NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt
- NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt
- NewLineAtEndOfFile:InterfaceId.kt$com.geeksville.mesh.repository.radio.InterfaceId.kt
- NewLineAtEndOfFile:InterfaceSpec.kt$com.geeksville.mesh.repository.radio.InterfaceSpec.kt
- NewLineAtEndOfFile:MockInterfaceFactory.kt$com.geeksville.mesh.repository.radio.MockInterfaceFactory.kt
- NewLineAtEndOfFile:NopInterface.kt$com.geeksville.mesh.repository.radio.NopInterface.kt
- NewLineAtEndOfFile:NopInterfaceFactory.kt$com.geeksville.mesh.repository.radio.NopInterfaceFactory.kt
- NewLineAtEndOfFile:ProbeTableProvider.kt$com.geeksville.mesh.repository.usb.ProbeTableProvider.kt
- NewLineAtEndOfFile:SerialConnection.kt$com.geeksville.mesh.repository.usb.SerialConnection.kt
- NewLineAtEndOfFile:SerialConnectionListener.kt$com.geeksville.mesh.repository.usb.SerialConnectionListener.kt
- NewLineAtEndOfFile:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt
- NewLineAtEndOfFile:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt
- NewLineAtEndOfFile:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
- NoBlankLineBeforeRbrace:DebugLogFile.kt$BinaryLogFile$
- NoBlankLineBeforeRbrace:NopInterface.kt$NopInterface$
- NoConsecutiveBlankLines:DebugLogFile.kt$
- NoEmptyClassBody:DebugLogFile.kt$BinaryLogFile${ }
- NoSemicolons:DateUtils.kt$DateUtils$;
- OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract
- RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex
- ReturnCount:MeshDataHandler.kt$MeshDataHandler$@Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket)
- ReturnCount:MeshDataHandler.kt$MeshDataHandler$private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean
- SwallowedException:Exceptions.kt$ex: Throwable
+ MaxLineLength:DataSourceModule.kt$DataSourceModule$fun
+ ParameterListWrapping:DataSourceModule.kt$DataSourceModule$(impl: BootloaderOtaQuirksJsonDataSourceImpl)
SwallowedException:NsdManager.kt$ex: IllegalArgumentException
- SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException
SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
- TooGenericExceptionCaught:Exceptions.kt$ex: Throwable
- TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception
- TooGenericExceptionCaught:MeshDataHandler.kt$MeshDataHandler$e: Exception
- TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception
TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception
- TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable
- TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable
- TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable
TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable
- TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Haven't called connect")
- TooGenericExceptionThrown:ServiceClient.kt$ServiceClient$throw Exception("Service not bound")
- TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("SyncContinuation timeout")
- TooGenericExceptionThrown:SyncContinuation.kt$SyncContinuation$throw Exception("This shouldn't happen")
TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : IRadioInterface
- TooManyFunctions:RadioInterfaceService.kt$RadioInterfaceService
- TooManyFunctions:UIState.kt$UIViewModel : ViewModel
UtilityClassWithPublicConstructor:NetworkRepositoryModule.kt$NetworkRepositoryModule
diff --git a/app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt b/app/src/androidTest/kotlin/org/meshtastic/app/TestRunner.kt
similarity index 83%
rename from app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt
rename to app/src/androidTest/kotlin/org/meshtastic/app/TestRunner.kt
index ab2d6714a..7a5f389ae 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt
+++ b/app/src/androidTest/kotlin/org/meshtastic/app/TestRunner.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,8 +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
+package org.meshtastic.app
import android.app.Application
import android.content.Context
@@ -24,7 +23,6 @@ import dagger.hilt.android.testing.HiltTestApplication
@Suppress("unused")
class TestRunner : AndroidJUnitRunner() {
- override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
- return super.newApplication(cl, HiltTestApplication::class.java.name, context)
- }
+ override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
+ super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
diff --git a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
similarity index 92%
rename from app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
rename to app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
index efa229881..a4c44e964 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
+++ b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.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.filter
+package org.meshtastic.app.filter
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
@@ -48,6 +48,8 @@ class MessageFilterIntegrationTest {
fun filterPrefsIntegration() = runTest {
filterPrefs.setFilterEnabled(true)
filterPrefs.setFilterWords(setOf("test", "spam"))
+ // Wait briefly for DataStore to process the writes and flows to emit
+ kotlinx.coroutines.delay(100)
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))
diff --git a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt
similarity index 67%
rename from core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt
index a8b4532d1..69d9648d9 100644
--- a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.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,20 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+package org.meshtastic.app.analytics
-package org.meshtastic.core.analytics.platform
-
-import androidx.compose.runtime.Composable
-import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import org.meshtastic.core.analytics.BuildConfig
-import org.meshtastic.core.analytics.DataPair
+import org.meshtastic.app.BuildConfig
+import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Inject
/**
- * F-Droid specific implementation of [org.meshtastic.analytics.platform.PlatformAnalytics]. This provides no-op
- * implementations for analytics and other platform services.
+ * F-Droid specific implementation of [PlatformAnalytics]. This provides no-op implementations for analytics and other
+ * platform services.
*/
class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
init {
@@ -36,7 +34,7 @@ class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
// release builds rely on system logging only.
if (BuildConfig.DEBUG) {
Logger.setMinSeverity(Severity.Debug)
- Logger.i { "F-Droid platform no-op analytics initialized (Debug mode }." }
+ Logger.i { "F-Droid platform no-op analytics initialized (Debug mode)." }
} else {
Logger.setMinSeverity(Severity.Info)
Logger.i { "F-Droid platform no-op analytics initialized." }
@@ -48,16 +46,6 @@ class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics {
Logger.d { "Set device attributes called: firmwareVersion=$firmwareVersion, deviceHardware=$model" }
}
- @Composable
- override fun AddNavigationTrackingEffect(navController: NavHostController) {
- // No-op for F-Droid, but we can log navigation if needed for debugging
- if (BuildConfig.DEBUG) {
- navController.addOnDestinationChangedListener { _, destination, _ ->
- Logger.d { "Navigation changed to: ${destination.route}" }
- }
- }
- }
-
override val isPlatformServicesAvailable: Boolean
get() = false
diff --git a/core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
similarity index 86%
rename from core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
index 538400edc..a2716d1e0 100644
--- a/core/network/src/fdroid/kotlin/org/meshtastic/core/network/di/FDroidNetworkModule.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.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,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.network.di
+package org.meshtastic.app.di
import dagger.Module
import dagger.Provides
@@ -23,9 +22,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
+import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
-import org.meshtastic.core.network.BuildConfig
import org.meshtastic.core.network.service.ApiService
import javax.inject.Singleton
@@ -35,11 +34,11 @@ class FDroidNetworkModule {
@Provides
@Singleton
- fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
+ fun provideOkHttpClient(buildConfigProvider: BuildConfigProvider): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(
interceptor =
HttpLoggingInterceptor().apply {
- if (BuildConfig.DEBUG) {
+ if (buildConfigProvider.isDebug) {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
},
diff --git a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/di/FdroidPlatformAnalyticsModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FdroidPlatformAnalyticsModule.kt
similarity index 83%
rename from core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/di/FdroidPlatformAnalyticsModule.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/di/FdroidPlatformAnalyticsModule.kt
index 9b0bd4492..47d3e7fd5 100644
--- a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/di/FdroidPlatformAnalyticsModule.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FdroidPlatformAnalyticsModule.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,15 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.core.analytics.di
+package org.meshtastic.app.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.analytics.platform.FdroidPlatformAnalytics
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.app.analytics.FdroidPlatformAnalytics
+import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Singleton
/** Hilt module to provide the [FdroidPlatformAnalytics] for the fdroid flavor. */
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
similarity index 88%
rename from core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
rename to app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
index c3133b8f4..30fa55730 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -14,22 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.analytics.platform
+package org.meshtastic.app.analytics
import android.app.Application
import android.content.Context
import android.os.Bundle
import android.provider.Settings
-import androidx.compose.runtime.Composable
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
-import androidx.navigation.NavHostController
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
-import com.datadog.android.compose.ExperimentalTrackingApi
-import com.datadog.android.compose.NavigationViewTrackingEffect
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -38,7 +34,6 @@ import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum
import com.datadog.android.rum.RumConfiguration
-import com.datadog.android.rum.tracking.AcceptAllNavDestinations
import com.datadog.android.trace.Trace
import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
@@ -56,9 +51,10 @@ import io.opentelemetry.api.GlobalOpenTelemetry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.analytics.BuildConfig
-import org.meshtastic.core.analytics.DataPair
+import org.meshtastic.app.BuildConfig
import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Inject
import co.touchlab.kermit.Logger as KermitLogger
@@ -174,8 +170,6 @@ constructor(
Trace.enable(traceConfig)
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
-
- // Session Replay disabled to reduce PII collection
}
private fun initCrashlytics(application: Application) {
@@ -243,18 +237,6 @@ constructor(
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
- @OptIn(ExperimentalTrackingApi::class)
- @Composable
- override fun AddNavigationTrackingEffect(navController: NavHostController) {
- if (Datadog.isInitialized()) {
- NavigationViewTrackingEffect(
- navController = navController,
- trackArguments = true,
- destinationPredicate = AcceptAllNavDestinations(),
- )
- }
- }
-
private val isGooglePlayAvailable: Boolean
get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {
@@ -308,7 +290,7 @@ constructor(
}
private fun String.extractSemanticVersion(): String {
- val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?".toRegex()
+ val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$".toRegex()
val matchResult = regex.find(this)
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this
}
@@ -317,16 +299,16 @@ constructor(
if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
- when (it.value) {
- is Double -> bundle.putDouble(it.name, it.value)
- is Int ->
- bundle.putLong(it.name, it.value.toLong()) // Firebase expects Long for integer values in bundles
- is Long -> bundle.putLong(it.name, it.value)
- is Float -> bundle.putDouble(it.name, it.value.toDouble())
- is String -> bundle.putString(it.name, it.value as String?) // Explicitly handle String
- else -> bundle.putString(it.name, it.value.toString()) // Fallback for other types
+ val value = it.value
+ when (value) {
+ is Double -> bundle.putDouble(it.name, value)
+ is Int -> bundle.putLong(it.name, value.toLong()) // Firebase expects Long for integer values in bundles
+ is Long -> bundle.putLong(it.name, value)
+ is Float -> bundle.putDouble(it.name, value.toDouble())
+ is String -> bundle.putString(it.name, value) // Explicitly handle String
+ else -> bundle.putString(it.name, value.toString()) // Fallback for other types
}
- KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : ${it.value})" }
+ KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : $value)" }
}
Firebase.analytics.logEvent(event, bundle)
}
diff --git a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
similarity index 87%
rename from core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt
rename to app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
index abeef17a0..2a0894c45 100644
--- a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.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 org.meshtastic.core.network.di
+package org.meshtastic.app.di
import android.content.Context
import com.datadog.android.okhttp.DatadogEventListener
@@ -28,7 +28,7 @@ import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
-import org.meshtastic.core.network.BuildConfig
+import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.network.service.ApiServiceImpl
import java.io.File
@@ -44,7 +44,10 @@ interface GoogleNetworkModule {
companion object {
@Provides
@Singleton
- fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
+ fun provideOkHttpClient(
+ @ApplicationContext context: Context,
+ buildConfigProvider: BuildConfigProvider,
+ ): OkHttpClient = OkHttpClient.Builder()
.cache(
cache =
Cache(
@@ -55,7 +58,7 @@ interface GoogleNetworkModule {
.addInterceptor(
interceptor =
HttpLoggingInterceptor().apply {
- if (BuildConfig.DEBUG) {
+ if (buildConfigProvider.isDebug) {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
},
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/di/GooglePlatformAnalyticsModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/GooglePlatformAnalyticsModule.kt
similarity index 83%
rename from core/analytics/src/google/kotlin/org/meshtastic/core/analytics/di/GooglePlatformAnalyticsModule.kt
rename to app/src/google/kotlin/org/meshtastic/app/di/GooglePlatformAnalyticsModule.kt
index 4281c2f0e..af63aab83 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/di/GooglePlatformAnalyticsModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/di/GooglePlatformAnalyticsModule.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,15 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.core.analytics.di
+package org.meshtastic.app.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
-import org.meshtastic.core.analytics.platform.GooglePlatformAnalytics
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
+import org.meshtastic.app.analytics.GooglePlatformAnalytics
+import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Singleton
/** Hilt module to provide the [GooglePlatformAnalytics] for the google flavor. */
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 383ee77f1..a19b6ff3c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -102,7 +102,7 @@
@@ -171,7 +171,7 @@
@@ -228,7 +228,7 @@
android:resource="@xml/device_filter" />
-
@@ -252,12 +252,12 @@
android:path="com.geeksville.mesh" /> -->
-
-
-
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/kotlin/org/meshtastic/app/ApplicationModule.kt
similarity index 87%
rename from app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/ApplicationModule.kt
index dd07d74e2..d609d38dd 100644
--- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ApplicationModule.kt
@@ -14,22 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh
+package org.meshtastic.app
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
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import org.meshtastic.app.repository.radio.AndroidRadioInterfaceService
+import org.meshtastic.app.service.AndroidAppWidgetUpdater
+import org.meshtastic.app.service.AndroidMeshLocationManager
+import org.meshtastic.app.service.AndroidMeshWorkerManager
+import org.meshtastic.app.service.MeshServiceNotificationsImpl
+import org.meshtastic.app.service.ServiceBroadcasts
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.repository.MeshServiceNotifications
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
similarity index 98%
rename from app/src/main/java/com/geeksville/mesh/MainActivity.kt
rename to app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 0fbe657ce..7de47507a 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.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
+package org.meshtastic.app
import android.app.PendingIntent
import android.app.TaskStackBuilder
@@ -43,12 +43,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.model.UIViewModel
-import com.geeksville.mesh.ui.MainScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
+import org.meshtastic.app.model.UIViewModel
+import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.resources.Res
diff --git a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt b/app/src/main/kotlin/org/meshtastic/app/MeshServiceClient.kt
similarity index 96%
rename from app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
rename to app/src/main/kotlin/org/meshtastic/app/MeshServiceClient.kt
index 74fcea5bf..b683fd380 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshServiceClient.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
+package org.meshtastic.app
import android.content.Context
import android.content.Context.BIND_ABOVE_CLIENT
@@ -23,11 +23,11 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.service.MeshService
-import com.geeksville.mesh.service.startService
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
+import org.meshtastic.app.service.MeshService
+import org.meshtastic.app.service.startService
import org.meshtastic.core.common.util.SequentialJob
import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.BindFailedException
diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
similarity index 96%
rename from app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
rename to app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index 6e1573f2d..daae4a159 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.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
+package org.meshtastic.app
import android.app.Application
import android.appwidget.AppWidgetProviderInfo
@@ -27,8 +27,6 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.widget.LocalStatsWidgetReceiver
-import com.geeksville.mesh.worker.MeshLogCleanupWorker
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -42,6 +40,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
+import org.meshtastic.app.widget.LocalStatsWidgetReceiver
+import org.meshtastic.app.worker.MeshLogCleanupWorker
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshLogPrefs
@@ -96,7 +96,7 @@ open class MeshUtilApplication :
val entryPoint =
EntryPointAccessors.fromApplication(
this@MeshUtilApplication,
- com.geeksville.mesh.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
+ org.meshtastic.app.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
)
try {
// Wait for real data for up to 30 seconds before pushing an updated preview
diff --git a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppModule.kt
similarity index 94%
rename from core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/AppModule.kt
index 0dfe5764a..ec1efc74d 100644
--- a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AppModule.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 org.meshtastic.core.di
+package org.meshtastic.app.di
import android.content.Context
import androidx.work.WorkManager
@@ -24,6 +24,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
+import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/DataModule.kt
similarity index 94%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/DataModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/DataModule.kt
index 241f70218..e20f08582 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/DataModule.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,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.data.di
+package org.meshtastic.app.di
import android.content.Context
import android.location.LocationManager
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/DataSourceModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/DataSourceModule.kt
new file mode 100644
index 000000000..55a42e183
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/di/DataSourceModule.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.app.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
+import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSourceImpl
+import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
+import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSourceImpl
+import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
+import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSourceImpl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface DataSourceModule {
+ @Binds
+ @Singleton
+ fun bindDeviceHardwareJsonDataSource(impl: DeviceHardwareJsonDataSourceImpl): DeviceHardwareJsonDataSource
+
+ @Binds
+ @Singleton
+ fun bindFirmwareReleaseJsonDataSource(impl: FirmwareReleaseJsonDataSourceImpl): FirmwareReleaseJsonDataSource
+
+ @Binds
+ @Singleton
+ fun bindBootloaderOtaQuirksJsonDataSource(
+ impl: BootloaderOtaQuirksJsonDataSourceImpl,
+ ): BootloaderOtaQuirksJsonDataSource
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/DataStoreModule.kt
similarity index 99%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/DataStoreModule.kt
index b34e2f52c..55611e300 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DataStoreModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/DataStoreModule.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 org.meshtastic.core.data.di
+package org.meshtastic.app.di
import android.content.Context
import androidx.datastore.core.DataStore
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/DatabaseModule.kt
similarity index 96%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/DatabaseModule.kt
index 6660fb87d..059330e7a 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/DatabaseModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/DatabaseModule.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 org.meshtastic.core.data.di
+package org.meshtastic.app.di
import dagger.Binds
import dagger.Module
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
new file mode 100644
index 000000000..f3dabfe13
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.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.app.di
+
+import android.content.Context
+import coil3.ImageLoader
+import coil3.disk.DiskCache
+import coil3.memory.MemoryCache
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import coil3.request.crossfade
+import coil3.svg.SvgDecoder
+import coil3.util.DebugLogger
+import coil3.util.Logger
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import org.meshtastic.core.common.BuildConfigProvider
+import javax.inject.Singleton
+
+private const val DISK_CACHE_PERCENT = 0.02
+private const val MEMORY_CACHE_PERCENT = 0.25
+
+@InstallIn(SingletonComponent::class)
+@Module
+interface NetworkModule {
+
+ @Binds
+ @Singleton
+ fun bindMqttRepository(
+ impl: org.meshtastic.core.network.repository.MQTTRepositoryImpl,
+ ): org.meshtastic.core.network.repository.MQTTRepository
+
+ companion object {
+ @Provides
+ @Singleton
+ fun provideImageLoader(
+ okHttpClient: OkHttpClient,
+ @ApplicationContext application: Context,
+ buildConfigProvider: BuildConfigProvider,
+ ): ImageLoader {
+ val sharedOkHttp = okHttpClient.newBuilder().build()
+ return ImageLoader.Builder(context = application)
+ .components {
+ add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttp }))
+ add(SvgDecoder.Factory(scaleToDensity = true))
+ }
+ .memoryCache {
+ MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build()
+ }
+ .diskCache { DiskCache.Builder().maxSizePercent(percent = DISK_CACHE_PERCENT).build() }
+ .logger(
+ logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null,
+ )
+ .crossfade(enable = true)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideJson(): Json = Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ }
+
+ @Provides
+ @Singleton
+ fun provideHttpClient(okHttpClient: OkHttpClient, json: Json): HttpClient = HttpClient(engineFactory = OkHttp) {
+ engine { preconfigured = okHttpClient }
+
+ install(plugin = ContentNegotiation) { json(json) }
+ }
+ }
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NodeDataSourceModule.kt
similarity index 95%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/NodeDataSourceModule.kt
index 42a50e980..54a91068d 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/NodeDataSourceModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NodeDataSourceModule.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,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.data.di
+package org.meshtastic.app.di
import dagger.Binds
import dagger.Module
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/PrefsModule.kt
similarity index 93%
rename from core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/PrefsModule.kt
index b1b8fbede..1d555b5b0 100644
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/PrefsModule.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 org.meshtastic.core.prefs.di
+package org.meshtastic.app.di
import android.content.Context
import androidx.datastore.core.DataStore
@@ -32,6 +32,18 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.prefs.analytics.AnalyticsPrefsImpl
+import org.meshtastic.core.prefs.di.AnalyticsDataStore
+import org.meshtastic.core.prefs.di.AppDataStore
+import org.meshtastic.core.prefs.di.CustomEmojiDataStore
+import org.meshtastic.core.prefs.di.FilterDataStore
+import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
+import org.meshtastic.core.prefs.di.MapConsentDataStore
+import org.meshtastic.core.prefs.di.MapDataStore
+import org.meshtastic.core.prefs.di.MapTileProviderDataStore
+import org.meshtastic.core.prefs.di.MeshDataStore
+import org.meshtastic.core.prefs.di.MeshLogDataStore
+import org.meshtastic.core.prefs.di.RadioDataStore
+import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefsImpl
import org.meshtastic.core.prefs.filter.FilterPrefsImpl
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefsImpl
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/RepositoryModule.kt
similarity index 95%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/RepositoryModule.kt
index 5c48a3745..98c19f5bc 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/RepositoryModule.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 org.meshtastic.core.data.di
+package org.meshtastic.app.di
import dagger.Binds
import dagger.Module
@@ -38,6 +38,7 @@ 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.LocationRepositoryImpl
import org.meshtastic.core.data.repository.MeshLogRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
@@ -47,6 +48,7 @@ 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.LocationRepository
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
@@ -78,6 +80,10 @@ abstract class RepositoryModule {
@Singleton
abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
+ @Binds
+ @Singleton
+ abstract fun bindLocationRepository(locationRepositoryImpl: LocationRepositoryImpl): LocationRepository
+
@Binds
@Singleton
abstract fun bindDeviceHardwareRepository(
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/UseCaseModule.kt
similarity index 97%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/UseCaseModule.kt
index 8093d73e9..f0b078cea 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/UseCaseModule.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 org.meshtastic.core.data.di
+package org.meshtastic.app.di
import dagger.Module
import dagger.Provides
diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/kotlin/org/meshtastic/app/domain/usecase/GetDiscoveredDevicesUseCase.kt
similarity index 96%
rename from app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
rename to app/src/main/kotlin/org/meshtastic/app/domain/usecase/GetDiscoveredDevicesUseCase.kt
index 4b7a25c50..200294e16 100644
--- a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/domain/usecase/GetDiscoveredDevicesUseCase.kt
@@ -14,19 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.domain.usecase
+package org.meshtastic.app.domain.usecase
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
-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.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.app.model.DeviceListEntry
+import org.meshtastic.app.model.getMeshtasticShortName
+import org.meshtastic.app.repository.network.NetworkRepository
+import org.meshtastic.app.repository.network.NetworkRepository.Companion.toAddressString
+import org.meshtastic.app.repository.usb.UsbRepository
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/kotlin/org/meshtastic/app/model/DeviceListEntry.kt
similarity index 99%
rename from app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
rename to app/src/main/kotlin/org/meshtastic/app/model/DeviceListEntry.kt
index d66d6fff0..8d92cd7a8 100644
--- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/model/DeviceListEntry.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.model
+package org.meshtastic.app.model
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt
similarity index 92%
rename from app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
rename to app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt
index 77a6cde1f..54b2f6f2a 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt
@@ -14,21 +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.model
+package org.meshtastic.app.model
import android.net.Uri
-import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -37,10 +33,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.shareIn
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
-import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
@@ -83,7 +77,6 @@ constructor(
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
- private val analytics: PlatformAnalytics,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
) : ViewModel() {
@@ -99,12 +92,8 @@ constructor(
meshServiceNotifications.clearClientNotification(notification)
}
- /**
- * Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
- * even if they are the same.
- */
- val meshActivity: SharedFlow =
- radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
+ /** Emits events for mesh network send/receive activity. */
+ val meshActivity: Flow = radioInterfaceService.meshActivity
private val _scrollToTopEventFlow =
MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -276,9 +265,4 @@ constructor(
fun onAppIntroCompleted() {
uiPreferencesDataSource.setAppIntroCompleted(true)
}
-
- @Composable
- fun AddNavigationTrackingEffect(navController: NavHostController) {
- analytics.AddNavigationTrackingEffect(navController)
- }
}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt
similarity index 94%
rename from app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt
index ccf513922..819d72e13 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.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,8 +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.navigation
+package org.meshtastic.app.navigation
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -24,7 +23,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
-import com.geeksville.mesh.ui.sharing.ChannelScreen
+import org.meshtastic.app.ui.sharing.ChannelScreen
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.SettingsRoutes
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt
similarity index 95%
rename from app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt
index 8c94d688e..4ece8d6a5 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ConnectionsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.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,8 +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.navigation
+package org.meshtastic.app.navigation
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -24,7 +23,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
-import com.geeksville.mesh.ui.connections.ConnectionsScreen
+import org.meshtastic.app.ui.connections.ConnectionsScreen
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt
similarity index 98%
rename from app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt
index aaf47dde6..9caec2f08 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.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.navigation
+package org.meshtastic.app.navigation
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -25,8 +25,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
-import com.geeksville.mesh.model.UIViewModel
import kotlinx.coroutines.flow.Flow
+import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.ui.component.ScrollToTopEvent
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/FirmwareNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/FirmwareNavigation.kt
similarity index 93%
rename from app/src/main/java/com/geeksville/mesh/navigation/FirmwareNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/FirmwareNavigation.kt
index 40ec2a4bc..88439d6c8 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/FirmwareNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/FirmwareNavigation.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,8 +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.navigation
+package org.meshtastic.app.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/MapNavigation.kt
similarity index 95%
rename from app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/MapNavigation.kt
index 5de1c6933..da766bd06 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/MapNavigation.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,8 +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.navigation
+package org.meshtastic.app.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt
similarity index 99%
rename from app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt
index d9fded5b4..8d628a96c 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.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.navigation
+package org.meshtastic.app.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CellTower
@@ -38,9 +38,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
-import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
similarity index 99%
rename from app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
rename to app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
index eacec7cb3..eebe1db28 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
@@ -16,7 +16,7 @@
*/
@file:Suppress("Wrapping", "SpacingAroundColon")
-package com.geeksville.mesh.navigation
+package org.meshtastic.app.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt b/app/src/main/kotlin/org/meshtastic/app/repository/network/ConnectivityManager.kt
similarity index 63%
rename from app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt
rename to app/src/main/kotlin/org/meshtastic/app/repository/network/ConnectivityManager.kt
index b7944344e..14e205845 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/repository/network/ConnectivityManager.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,8 +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.repository.network
+package org.meshtastic.app.repository.network
import android.net.ConnectivityManager
import android.net.Network
@@ -27,9 +26,8 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
-internal fun ConnectivityManager.networkAvailable(): Flow = observeNetworks()
- .map { activeNetworksList -> activeNetworksList.isNotEmpty() }
- .distinctUntilChanged()
+internal fun ConnectivityManager.networkAvailable(): Flow =
+ observeNetworks().map { activeNetworksList -> activeNetworksList.isNotEmpty() }.distinctUntilChanged()
internal fun ConnectivityManager.observeNetworks(
networkRequest: NetworkRequest = NetworkRequest.Builder().build(),
@@ -37,30 +35,26 @@ internal fun ConnectivityManager.observeNetworks(
// Keep track of the current active networks
val activeNetworks = mutableSetOf()
- val callback = object : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: Network) {
- activeNetworks.add(network)
- trySend(activeNetworks.toList())
- }
-
- override fun onLost(network: Network) {
- activeNetworks.remove(network)
- trySend(activeNetworks.toList())
- }
-
- override fun onCapabilitiesChanged(
- network: Network,
- networkCapabilities: NetworkCapabilities
- ) {
- if (activeNetworks.contains(network)) {
+ val callback =
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ activeNetworks.add(network)
trySend(activeNetworks.toList())
}
+
+ override fun onLost(network: Network) {
+ activeNetworks.remove(network)
+ trySend(activeNetworks.toList())
+ }
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ if (activeNetworks.contains(network)) {
+ trySend(activeNetworks.toList())
+ }
+ }
}
- }
registerNetworkCallback(networkRequest, callback)
- awaitClose {
- unregisterNetworkCallback(callback)
- }
+ awaitClose { unregisterNetworkCallback(callback) }
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt b/app/src/main/kotlin/org/meshtastic/app/repository/network/NetworkRepository.kt
similarity index 98%
rename from app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt
rename to app/src/main/kotlin/org/meshtastic/app/repository/network/NetworkRepository.kt
index 2266cdc4f..eeda06b17 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/repository/network/NetworkRepository.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see