mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-13 08:25:07 -04:00
feat(car): Android Car App Library integration (#5633)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,12 +23,45 @@
|
||||
- Fix (NodeClusterMarkers.kt ONLY): icons baked in-scope via `rememberComposeBitmapDescriptor(node){ PulsingNodeChip }` into a snapshot stateMap; custom `private class NodeClusterRenderer : DefaultClusterRenderer` assigns them in onBeforeClusterItemRendered/onClusterItemUpdated (bg thread, READ-only — never composes, so the crash class is gone). Native info windows (super sets title/snippet) + onClusterItemInfoWindowClick→navigateToNodeDetails; precision circles drawn from the renderer's own `unclusteredItems` MutableState (clusterItemDecoration can't fire — `ClusterRendererItemState` is lib-internal). Strictly better than the elegant-euler Canvas branch — keeps the REAL Compose chip.
|
||||
- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
|
||||
|
||||
## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
|
||||
- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
|
||||
- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
|
||||
- Kept the deterministic retry trigger (`error("Connection pool is closed")`) and retained assertions that first attempt uses old DB and retry uses current DB.
|
||||
- Made teardown resilient with `if (::manager.isInitialized) manager.close()` so setup/early failures do not cascade into teardown crashes.
|
||||
- Verified with `:core:database:jvmTest --tests "org.meshtastic.core.database.DatabaseManagerWithDbRetryTest*"` and repeated it 5 consecutive runs without failures; `:core:database:detekt` also passed.
|
||||
## 2026-05-28 — Added comprehensive CarScreenDataBuilder unit coverage
|
||||
- Created `feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt` with 533 lines covering signal quality thresholds/boundaries, node UI mapping, node and conversation sorting, local stats fallbacks, uptime formatting, recent message limiting, contact key generation, and constants.
|
||||
- Restored the `MessageSnapshot` data class in `CarStateCoordinator.kt` and re-added `recentMessages()` plus `MAX_CONVERSATION_MESSAGES` in `CarScreenDataBuilder.kt` so the current source matched the requested pure-helper API surface for testing.
|
||||
- Verified with `./gradlew :feature:car:spotlessCheck :feature:car:detekt :feature:car:testFdroidDebugUnitTest --quiet` and the requested quiet test command (`./gradlew :feature:car:testFdroidDebugUnitTest --quiet 2>&1 | tail -20`), both successful.
|
||||
|
||||
## 2026-05-28 — Lowered car min API to 7 and removed dead conversation code
|
||||
- Changed `feature/car` manifest `androidx.car.app.minCarApiLevel` metadata from 8 to 7.
|
||||
- Guarded `HomeScreen.showEmergencyAlert()` behind `carContext.carAppApiLevel >= 8` and logged unsupported API 7 hosts with Kermit.
|
||||
- Removed unused `ConversationScreen`, `CarTtsEngine`, message snapshot/cache/read-aloud plumbing, and now-unused car reply/read-aloud strings.
|
||||
- Simplified `CarStateCoordinator` and `CarScreenDataBuilder` to match the inline `ConversationItem` flow.
|
||||
- Verified with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -30`.
|
||||
|
||||
## 2026-05-28 — Migrated car home messages tab to ConversationItem
|
||||
- Reworked `feature/car` `HomeScreen` messaging tab to build CAL `ConversationItem` entries instead of browsable `Row`s, including `Person`/`CarMessage` helpers and native reply/mark-read callbacks.
|
||||
- Removed `HomeScreen` conversation navigation so the car host owns messaging affordances; `ConversationScreen` remains on disk for later cleanup phases.
|
||||
- Added `CarStateCoordinator.markAsRead()` using `packetRepository.clearUnreadCount(...)` with Kermit error logging via `runCatching`.
|
||||
- Verified with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin` and the requested quiet compile command (`:feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -20`), both successful.
|
||||
|
||||
## 2026-05-28 — Implemented car conversation shortcuts and avatars
|
||||
- Added `feature/car/.../util/PersonIconFactory.kt` to render circular initial avatars using node-derived foreground/background colors for `Person` and shortcut icons.
|
||||
- Added `feature/car/.../service/ConversationShortcutManager.kt` to publish long-lived dynamic conversation shortcuts for favorite nodes and active channels, plus on-demand shortcut creation for notifications.
|
||||
- Wired `MeshtasticCarSession` to start/stop shortcut observation on a dedicated session coroutine scope.
|
||||
- Updated `CarNotificationManager` to ensure conversation shortcuts exist before posting and to attach both `shortcutId` and `LocusIdCompat` to messaging notifications.
|
||||
- Verified green with `./gradlew :feature:car:spotlessCheck :feature:car:detekt --quiet` and `./gradlew :feature:car:compileFdroidDebugKotlin --quiet 2>&1 | tail -20` after workspace bootstrap.
|
||||
|
||||
## 2026-05-28 — Implemented car local stats tab and extracted screen data builder
|
||||
- Added `CarLocalStats` to `feature/car` UI models and exposed `localStatsState` from `CarStateCoordinator`.
|
||||
- Wired a new HomeScreen `Status` tab with battery, channel utilization, air utilization, node counts, uptime, and packet TX/RX rows.
|
||||
- Created `feature/car/.../util/CarScreenDataBuilder.kt` to centralize pure UI-model mapping helpers for nodes, conversations, local stats, uptime formatting, contact key building, and recent message selection.
|
||||
- Added the new `ic_car_status.xml` drawable plus status strings in `feature/car/src/main/res/values/strings.xml`.
|
||||
- Cleaned up `CarReplyReceiver` detekt violations that blocked module validation.
|
||||
- Ran `python3 scripts/sort-strings.py` and verified green with `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin :feature:car:testFdroidDebugUnitTest`.
|
||||
|
||||
## 2026-05-28 — Implemented car module Phase 1 messaging wiring fixes
|
||||
- Replaced `CommandSender` usage in `feature/car` `CarStateCoordinator` with injected `SendMessageUseCase`, keeping the public `sendMessage()` API synchronous for UI callbacks while launching the use case on the coordinator scope after message-length validation.
|
||||
- Updated `CarNotificationManager` reply and mark-read notification actions with semantic action metadata and `setShowsUserInterface(false)` for automotive-friendly inline handling.
|
||||
- Reworked `CarReplyReceiver` into a `KoinComponent` that injects `SendMessageUseCase` and `PacketRepository`, then sends replies / clears unread counts asynchronously with Kermit error logging.
|
||||
- Added `android:permission="androidx.car.app.CarAppService"` to the `MeshtasticCarAppService` manifest declaration.
|
||||
- Verified with `./gradlew :feature:car:compileFdroidDebugKotlin --quiet` after required workspace bootstrap.
|
||||
|
||||
## 2026-05-21 — Upgraded Chirpy to a fully-personalized Live Diagnostic Node & Mesh Assistant
|
||||
- Integrated `NodeRepository` into `GeminiNanoDocAssistant.kt` and the Google AI Koin dependency injection module (`GoogleAiModule.kt`).
|
||||
|
||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -41,5 +41,6 @@ These are specific to the Copilot CLI environment and are not covered in AGENTS.
|
||||
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan
|
||||
shell commands, and other important information, read the current plan at
|
||||
specs/20260521-153452-car-app-library-integration/plan.md
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{"feature_directory":"specs/20260520-153412-nav-tab-labels"}
|
||||
{
|
||||
"feature_directory": "specs/20260521-153452-car-app-library-integration"
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ dependencies {
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.glance.preview)
|
||||
|
||||
googleImplementation(projects.feature.car)
|
||||
googleImplementation(libs.location.services)
|
||||
googleImplementation(libs.play.services.maps)
|
||||
googleImplementation(libs.maps.compose)
|
||||
|
||||
@@ -14,13 +14,22 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("ktlint:standard:max-line-length")
|
||||
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
|
||||
import org.meshtastic.feature.car.di.FeatureCarModule
|
||||
|
||||
@Module(
|
||||
includes =
|
||||
[GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class],
|
||||
[
|
||||
GoogleNetworkModule::class,
|
||||
GoogleMapsKoinModule::class,
|
||||
GoogleAiModule::class,
|
||||
AppFunctionsModule::class,
|
||||
FeatureCarModule::class,
|
||||
],
|
||||
)
|
||||
class FlavorModule
|
||||
|
||||
@@ -75,15 +75,7 @@ data class Node(
|
||||
internal fun isOnline(threshold: Int): Boolean = lastHeard > threshold
|
||||
|
||||
val colors: Pair<Int, Int>
|
||||
get() { // returns foreground and background @ColorInt for each 'num'
|
||||
val r = (num and 0xFF0000) shr 16
|
||||
val g = (num and 0x00FF00) shr 8
|
||||
val b = num and 0x0000FF
|
||||
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
|
||||
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
return foreground to background
|
||||
}
|
||||
get() = nodeColorsFromNum(num)
|
||||
|
||||
val isUnknownUser
|
||||
get() = user.hw_model == HardwareModel.UNSET
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
private const val RED_WEIGHT = 0.299
|
||||
private const val GREEN_WEIGHT = 0.587
|
||||
private const val BLUE_WEIGHT = 0.114
|
||||
private const val BRIGHTNESS_THRESHOLD = 0.5
|
||||
private const val MAX_CHANNEL = 255
|
||||
private const val RED_MASK = 0xFF0000
|
||||
private const val GREEN_MASK = 0x00FF00
|
||||
private const val BLUE_MASK = 0x0000FF
|
||||
private const val ALPHA_MASK = 0xFF
|
||||
private const val RED_SHIFT = 16
|
||||
private const val GREEN_SHIFT = 8
|
||||
private const val ALPHA_SHIFT = 24
|
||||
private const val BLACK = 0xFF000000.toInt()
|
||||
private const val WHITE = 0xFFFFFFFF.toInt()
|
||||
|
||||
/** Derives a unique color pair from a node number. Returns (foreground, background) as @ColorInt. */
|
||||
fun nodeColorsFromNum(nodeNum: Int): Pair<Int, Int> {
|
||||
val r = (nodeNum and RED_MASK) shr RED_SHIFT
|
||||
val g = (nodeNum and GREEN_MASK) shr GREEN_SHIFT
|
||||
val b = nodeNum and BLUE_MASK
|
||||
val brightness = ((r * RED_WEIGHT) + (g * GREEN_WEIGHT) + (b * BLUE_WEIGHT)) / MAX_CHANNEL
|
||||
val foreground = if (brightness > BRIGHTNESS_THRESHOLD) BLACK else WHITE
|
||||
val background = (ALPHA_MASK shl ALPHA_SHIFT) or (r shl RED_SHIFT) or (g shl GREEN_SHIFT) or b
|
||||
return foreground to background
|
||||
}
|
||||
57
feature/car/build.gradle.kts
Normal file
57
feature/car/build.gradle.kts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.flavors)
|
||||
id("meshtastic.koin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.car"
|
||||
|
||||
buildFeatures { buildConfig = true }
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 23
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.repository)
|
||||
|
||||
implementation(libs.androidx.car.app)
|
||||
implementation(libs.androidx.car.app.projected)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.annotations)
|
||||
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics)
|
||||
implementation(libs.kermit)
|
||||
|
||||
testImplementation(libs.androidx.car.app.testing)
|
||||
testImplementation(libs.koin.test)
|
||||
testImplementation(kotlin("test-junit"))
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
}
|
||||
9
feature/car/proguard-rules.pro
vendored
Normal file
9
feature/car/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Car App Library ProGuard/R8 rules
|
||||
|
||||
# CarAppService must not be obfuscated (resolved by android:exported="true" in manifest,
|
||||
# but keep rule ensures R8 doesn't remove it during aggressive shrinking)
|
||||
-keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; }
|
||||
|
||||
# Keep Koin-annotated classes for runtime DI resolution
|
||||
-keep @org.koin.core.annotation.Single class * { *; }
|
||||
-keep @org.koin.core.annotation.Factory class * { *; }
|
||||
23
feature/car/src/main/AndroidManifest.xml
Normal file
23
feature/car/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="org.meshtastic.feature.car.service.MeshtasticCarAppService"
|
||||
android:exported="true"
|
||||
android:permission="androidx.car.app.CarAppService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.MESSAGING" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="org.meshtastic.feature.car.service.CarReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="7" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.alerts
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.feature.car.model.EmergencyAlert
|
||||
|
||||
/**
|
||||
* Manages emergency alert state for the car display. Observes incoming packets for emergency-priority messages,
|
||||
* maintains active alert list, and triggers audio notifications.
|
||||
*/
|
||||
@Single
|
||||
class EmergencyHandler {
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private val _activeAlerts = MutableStateFlow<List<EmergencyAlert>>(emptyList())
|
||||
val activeAlerts: StateFlow<List<EmergencyAlert>> = _activeAlerts.asStateFlow()
|
||||
|
||||
private val _latestAlert = MutableStateFlow<EmergencyAlert?>(null)
|
||||
val latestAlert: StateFlow<EmergencyAlert?> = _latestAlert.asStateFlow()
|
||||
|
||||
private var toneGenerator: ToneGenerator? = null
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.e(tag = "EmergencyHandler", throwable = throwable) { "Emergency flow collection failed" }
|
||||
}
|
||||
|
||||
fun startCollecting(emergencyFlow: Flow<EmergencyAlert>) {
|
||||
scope?.cancel()
|
||||
scope =
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler).also { newScope ->
|
||||
newScope.launch {
|
||||
emergencyFlow.collect { alert ->
|
||||
addAlert(alert)
|
||||
_latestAlert.value = alert
|
||||
playEmergencyTone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCollecting() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
toneGenerator?.release()
|
||||
toneGenerator = null
|
||||
}
|
||||
|
||||
fun dismissAlert(nodeNum: Int) {
|
||||
_activeAlerts.value =
|
||||
_activeAlerts.value.map { alert -> if (alert.nodeNum == nodeNum) alert.copy(isActive = false) else alert }
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
_activeAlerts.value = emptyList()
|
||||
}
|
||||
|
||||
private fun addAlert(alert: EmergencyAlert) {
|
||||
val current = _activeAlerts.value.toMutableList()
|
||||
// Replace existing alert from same node, or add new
|
||||
val existingIndex = current.indexOfFirst { it.nodeNum == alert.nodeNum }
|
||||
if (existingIndex >= 0) {
|
||||
current[existingIndex] = alert
|
||||
} else {
|
||||
current.add(0, alert) // newest first
|
||||
}
|
||||
_activeAlerts.value = current
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught") // ToneGenerator may throw various runtime exceptions
|
||||
private fun playEmergencyTone() {
|
||||
try {
|
||||
if (toneGenerator == null) {
|
||||
toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, TONE_VOLUME)
|
||||
}
|
||||
toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP, TONE_DURATION_MS)
|
||||
} catch (e: RuntimeException) {
|
||||
Logger.w(tag = "EmergencyHandler", throwable = e) { "Emergency tone playback failed" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TONE_VOLUME = 80
|
||||
private const val TONE_DURATION_MS = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.feature.car")
|
||||
class FeatureCarModule
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.model
|
||||
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
|
||||
data class CarSessionState(
|
||||
val connectionStatus: ConnectionState,
|
||||
val onlineNodeCount: Int,
|
||||
val lastMessageTime: Long?,
|
||||
val activeEmergencies: List<EmergencyAlert>,
|
||||
val meshName: String?,
|
||||
)
|
||||
|
||||
data class MessagingUiState(
|
||||
val channels: List<ChannelUi>,
|
||||
val selectedChannelIndex: Int,
|
||||
val conversations: List<ConversationUi>,
|
||||
val emergencySpotlight: List<EmergencyAlert>?,
|
||||
)
|
||||
|
||||
data class ChannelUi(val index: Int, val name: String, val unreadCount: Int)
|
||||
|
||||
data class ConversationUi(
|
||||
val contactKey: String,
|
||||
val displayName: String,
|
||||
val lastMessage: String,
|
||||
val lastMessageTime: Long,
|
||||
val unreadCount: Int,
|
||||
val isEmergency: Boolean,
|
||||
)
|
||||
|
||||
data class NodeDashboardUiState(val nodes: List<NodeUi>, val topologyHeader: TopologyHeader)
|
||||
|
||||
data class NodeUi(
|
||||
val nodeNum: Int,
|
||||
val userId: String,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val signalQuality: SignalQuality,
|
||||
val batteryPercent: Int?,
|
||||
val isOnline: Boolean,
|
||||
val lastHeard: Long,
|
||||
val hasPosition: Boolean,
|
||||
)
|
||||
|
||||
enum class SignalQuality {
|
||||
EXCELLENT,
|
||||
GOOD,
|
||||
FAIR,
|
||||
BAD,
|
||||
NONE,
|
||||
}
|
||||
|
||||
data class TopologyHeader(val totalNodes: Int, val onlineNodes: Int, val meshName: String?)
|
||||
|
||||
data class EmergencyAlert(
|
||||
val nodeNum: Int,
|
||||
val nodeName: String,
|
||||
val message: String,
|
||||
val timestamp: Long,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
||||
data class CarLocalStats(
|
||||
val batteryLevel: Int = 0,
|
||||
val hasBattery: Boolean = false,
|
||||
val channelUtilization: Float = 0f,
|
||||
val airUtilization: Float = 0f,
|
||||
val totalNodes: Int = 0,
|
||||
val onlineNodes: Int = 0,
|
||||
val uptimeSeconds: Int = 0,
|
||||
val numPacketsTx: Int = 0,
|
||||
val numPacketsRx: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.screens
|
||||
|
||||
import androidx.car.app.AppManager
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.messaging.model.CarMessage
|
||||
import androidx.car.app.messaging.model.ConversationCallback
|
||||
import androidx.car.app.messaging.model.ConversationItem
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Alert
|
||||
import androidx.car.app.model.AlertCallback
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.ListTemplate
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.PaneTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Tab
|
||||
import androidx.car.app.model.TabContents
|
||||
import androidx.car.app.model.TabTemplate
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.nodeColorsFromNum
|
||||
import org.meshtastic.feature.car.R
|
||||
import org.meshtastic.feature.car.alerts.EmergencyHandler
|
||||
import org.meshtastic.feature.car.model.ConversationUi
|
||||
import org.meshtastic.feature.car.service.CarStateCoordinator
|
||||
import org.meshtastic.feature.car.util.CarScreenDataBuilder
|
||||
import org.meshtastic.feature.car.util.NodeSubtitleFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class HomeScreen(
|
||||
carContext: CarContext,
|
||||
private val stateCoordinator: CarStateCoordinator,
|
||||
private val emergencyHandler: EmergencyHandler,
|
||||
) : Screen(carContext) {
|
||||
|
||||
private var selectedTabId: String = TAB_ID_MESSAGES
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var previousConnectionState: ConnectionState = ConnectionState.Disconnected
|
||||
|
||||
init {
|
||||
lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
observeState()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
scope.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeState() {
|
||||
scope.launch { stateCoordinator.messagingState.collect { invalidate() } }
|
||||
scope.launch { stateCoordinator.nodeDashboardState.collect { invalidate() } }
|
||||
scope.launch { stateCoordinator.localStatsState.collect { invalidate() } }
|
||||
scope.launch {
|
||||
stateCoordinator.sessionState.collect { state ->
|
||||
val newState = state.connectionStatus
|
||||
if (previousConnectionState == ConnectionState.Disconnected && newState == ConnectionState.Connected) {
|
||||
CarToast.makeText(carContext, carContext.getString(R.string.car_reconnected), CarToast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
previousConnectionState = newState
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
emergencyHandler.latestAlert.collect { alert ->
|
||||
if (alert != null && alert.isActive) {
|
||||
showEmergencyAlert(alert.nodeNum, alert.nodeName, alert.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmergencyAlert(nodeNum: Int, nodeName: String, message: String) {
|
||||
if (carContext.carAppApiLevel < MIN_ALERT_CAR_API_LEVEL) {
|
||||
Logger.w(tag = "HomeScreen") { "Alert API unavailable on car API ${carContext.carAppApiLevel}" }
|
||||
return
|
||||
}
|
||||
|
||||
val alert =
|
||||
Alert.Builder(
|
||||
nodeNum,
|
||||
CarText.create(carContext.getString(R.string.car_emergency_from, nodeName)),
|
||||
ALERT_DURATION_MS.toLong(),
|
||||
)
|
||||
.setSubtitle(CarText.create(message))
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_dismiss))
|
||||
.setOnClickListener { emergencyHandler.dismissAlert(nodeNum) }
|
||||
.build(),
|
||||
)
|
||||
.setCallback(
|
||||
object : AlertCallback {
|
||||
override fun onCancel(reason: Int) {
|
||||
emergencyHandler.dismissAlert(nodeNum)
|
||||
}
|
||||
|
||||
override fun onDismiss() {
|
||||
emergencyHandler.dismissAlert(nodeNum)
|
||||
}
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
carContext.getCarService(AppManager::class.java).showAlert(alert)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override fun onGetTemplate(): Template {
|
||||
val connectionStatus = stateCoordinator.sessionState.value.connectionStatus
|
||||
if (connectionStatus == ConnectionState.Disconnected || connectionStatus == ConnectionState.DeviceSleep) {
|
||||
return buildDisconnectedTemplate()
|
||||
}
|
||||
val messaging = stateCoordinator.messagingState.value
|
||||
if (messaging.channels.isEmpty()) {
|
||||
return buildOnboardingTemplate()
|
||||
}
|
||||
val messagingTab =
|
||||
Tab.Builder()
|
||||
.setContentId(TAB_ID_MESSAGES)
|
||||
.setTitle(carContext.getString(R.string.car_tab_messages))
|
||||
.setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_message)).build())
|
||||
.build()
|
||||
|
||||
val nodesTab =
|
||||
Tab.Builder()
|
||||
.setContentId(TAB_ID_NODES)
|
||||
.setTitle(carContext.getString(R.string.car_tab_nodes))
|
||||
.setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_nodes)).build())
|
||||
.build()
|
||||
|
||||
val statusTab =
|
||||
Tab.Builder()
|
||||
.setContentId(TAB_ID_STATUS)
|
||||
.setTitle(carContext.getString(R.string.car_tab_status))
|
||||
.setIcon(CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_status)).build())
|
||||
.build()
|
||||
|
||||
return TabTemplate.Builder(
|
||||
object : TabTemplate.TabCallback {
|
||||
override fun onTabSelected(tabContentId: String) {
|
||||
selectedTabId = tabContentId
|
||||
invalidate()
|
||||
}
|
||||
},
|
||||
)
|
||||
.apply {
|
||||
setHeaderAction(Action.APP_ICON)
|
||||
addTab(messagingTab)
|
||||
addTab(nodesTab)
|
||||
addTab(statusTab)
|
||||
setTabContents(getTabContents())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getTabContents(): TabContents {
|
||||
val template =
|
||||
when (selectedTabId) {
|
||||
TAB_ID_MESSAGES -> buildMessagingList()
|
||||
TAB_ID_NODES -> buildNodeList()
|
||||
TAB_ID_STATUS -> buildStatusList()
|
||||
else -> buildMessagingList()
|
||||
}
|
||||
return TabContents.Builder(template).build()
|
||||
}
|
||||
|
||||
private fun buildMessagingList(): Template {
|
||||
val state = stateCoordinator.messagingState.value
|
||||
val listBuilder = ItemList.Builder()
|
||||
|
||||
val validConversations = state.conversations.filter { it.lastMessage.isNotEmpty() }
|
||||
if (validConversations.isEmpty()) {
|
||||
listBuilder.setNoItemsMessage(carContext.getString(R.string.car_no_messages))
|
||||
} else {
|
||||
val selfPerson = buildSelfPerson()
|
||||
validConversations.take(CarScreenDataBuilder.MAX_CONVERSATIONS).forEach { conversation ->
|
||||
listBuilder.addItem(buildConversationItem(conversation, selfPerson))
|
||||
}
|
||||
}
|
||||
|
||||
return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
|
||||
}
|
||||
|
||||
private fun buildSelfPerson(): Person {
|
||||
val myName = stateCoordinator.sessionState.value.meshName ?: "Me"
|
||||
return Person.Builder().setName(myName).setKey("self").build()
|
||||
}
|
||||
|
||||
private fun buildConversationItem(conversation: ConversationUi, selfPerson: Person): ConversationItem {
|
||||
val senderPerson = Person.Builder().setName(conversation.displayName).setKey(conversation.contactKey).build()
|
||||
|
||||
val messages = buildCarMessages(conversation, senderPerson)
|
||||
|
||||
val callback =
|
||||
object : ConversationCallback {
|
||||
override fun onMarkAsRead() {
|
||||
stateCoordinator.markAsRead(conversation.contactKey)
|
||||
}
|
||||
|
||||
override fun onTextReply(replyText: String) {
|
||||
stateCoordinator.sendMessage(conversation.contactKey, replyText)
|
||||
}
|
||||
}
|
||||
|
||||
return ConversationItem.Builder()
|
||||
.setId(conversation.contactKey)
|
||||
.setTitle(CarText.create(conversation.displayName))
|
||||
.setMessages(messages)
|
||||
.setSelf(selfPerson)
|
||||
.setConversationCallback(callback)
|
||||
.setGroupConversation(conversation.contactKey.contains(DataPacket.ID_BROADCAST))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildCarMessages(conversation: ConversationUi, senderPerson: Person): List<CarMessage> = listOf(
|
||||
CarMessage.Builder()
|
||||
.setSender(senderPerson)
|
||||
.setBody(CarText.create(conversation.lastMessage))
|
||||
.setReceivedTimeEpochMillis(conversation.lastMessageTime)
|
||||
.setRead(conversation.unreadCount == 0)
|
||||
.build(),
|
||||
)
|
||||
|
||||
private fun buildNodeList(): Template {
|
||||
val state = stateCoordinator.nodeDashboardState.value
|
||||
val listBuilder = ItemList.Builder()
|
||||
|
||||
if (state.nodes.isEmpty()) {
|
||||
listBuilder.setNoItemsMessage(carContext.getString(R.string.car_no_nodes))
|
||||
} else {
|
||||
val baseIcon = IconCompat.createWithResource(carContext, R.drawable.ic_car_nodes)
|
||||
state.nodes.forEach { node ->
|
||||
val (_, nodeColor) = nodeColorsFromNum(node.nodeNum)
|
||||
val tintedIcon = CarIcon.Builder(baseIcon).setTint(CarColor.createCustom(nodeColor, nodeColor)).build()
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(node.longName)
|
||||
.addText(NodeSubtitleFormatter.format(carContext, node))
|
||||
.setImage(tintedIcon, Row.IMAGE_TYPE_ICON)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(
|
||||
NodeDetailScreen(
|
||||
carContext = carContext,
|
||||
nodeProvider = { node },
|
||||
onMessageClick = { contactKey ->
|
||||
screenManager.pop()
|
||||
selectedTabId = TAB_ID_MESSAGES
|
||||
stateCoordinator.ensureDmConversation(
|
||||
contactKey,
|
||||
node.longName,
|
||||
carContext.getString(R.string.car_new_conversation),
|
||||
)
|
||||
invalidate()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
|
||||
}
|
||||
|
||||
private fun buildStatusList(): Template {
|
||||
val stats = stateCoordinator.localStatsState.value
|
||||
val listBuilder = ItemList.Builder()
|
||||
|
||||
if (stats.hasBattery) {
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_battery))
|
||||
.addText("${stats.batteryLevel}%")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_channel_util))
|
||||
.addText(String.format(Locale.getDefault(), "%.1f%%", stats.channelUtilization))
|
||||
.build(),
|
||||
)
|
||||
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_air_util))
|
||||
.addText(String.format(Locale.getDefault(), "%.1f%%", stats.airUtilization))
|
||||
.build(),
|
||||
)
|
||||
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_nodes))
|
||||
.addText("${stats.onlineNodes} / ${stats.totalNodes}")
|
||||
.build(),
|
||||
)
|
||||
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_uptime))
|
||||
.addText(CarScreenDataBuilder.formatUptime(stats.uptimeSeconds))
|
||||
.build(),
|
||||
)
|
||||
|
||||
listBuilder.addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_stat_packets))
|
||||
.addText("TX: ${stats.numPacketsTx} / RX: ${stats.numPacketsRx}")
|
||||
.build(),
|
||||
)
|
||||
|
||||
return ListTemplate.Builder().setSingleList(listBuilder.build()).build()
|
||||
}
|
||||
|
||||
private fun buildDisconnectedTemplate(): Template = PaneTemplate.Builder(
|
||||
Pane.Builder()
|
||||
.addRow(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_disconnected))
|
||||
.addText(carContext.getString(R.string.car_reconnecting))
|
||||
.setImage(
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_warning))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_app_name))
|
||||
.setStartHeaderAction(Action.APP_ICON)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun buildOnboardingTemplate(): Template = PaneTemplate.Builder(
|
||||
Pane.Builder()
|
||||
.addRow(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_onboarding_title))
|
||||
.addText(carContext.getString(R.string.car_onboarding_text))
|
||||
.setImage(
|
||||
CarIcon.Builder(IconCompat.createWithResource(carContext, R.drawable.ic_car_meshtastic))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_app_name))
|
||||
.setStartHeaderAction(Action.APP_ICON)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
private const val TAB_ID_MESSAGES = "messages"
|
||||
private const val TAB_ID_NODES = "nodes"
|
||||
private const val TAB_ID_STATUS = "status"
|
||||
private const val MIN_ALERT_CAR_API_LEVEL = 8
|
||||
private const val ALERT_DURATION_MS = 10_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.screens
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.PaneTemplate
|
||||
import androidx.car.app.model.Row
|
||||
import androidx.car.app.model.Template
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.feature.car.R
|
||||
import org.meshtastic.feature.car.model.NodeUi
|
||||
import org.meshtastic.feature.car.model.SignalQuality
|
||||
|
||||
class NodeDetailScreen(
|
||||
carContext: CarContext,
|
||||
private val nodeProvider: () -> NodeUi?,
|
||||
private val onMessageClick: (String) -> Unit,
|
||||
) : Screen(carContext) {
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
val node = nodeProvider() ?: return buildErrorTemplate()
|
||||
|
||||
val paneBuilder = Pane.Builder()
|
||||
|
||||
paneBuilder.addRow(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_status_signal))
|
||||
.addText(formatSignal(node.signalQuality))
|
||||
.build(),
|
||||
)
|
||||
|
||||
node.batteryPercent?.let { battery ->
|
||||
paneBuilder.addRow(
|
||||
Row.Builder().setTitle(carContext.getString(R.string.car_status_battery)).addText("$battery%").build(),
|
||||
)
|
||||
}
|
||||
|
||||
paneBuilder.addRow(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_status_last_heard))
|
||||
.addText(formatLastHeard(node.lastHeard))
|
||||
.build(),
|
||||
)
|
||||
|
||||
paneBuilder.addRow(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_status_status))
|
||||
.addText(
|
||||
if (node.isOnline) {
|
||||
carContext.getString(R.string.car_status_online)
|
||||
} else {
|
||||
carContext.getString(R.string.car_status_offline)
|
||||
},
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
// Direct message action — constructs contactKey "0<userId>" for DM
|
||||
paneBuilder.addAction(
|
||||
Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_message_node))
|
||||
.setOnClickListener { onMessageClick("0${node.userId}") }
|
||||
.build(),
|
||||
)
|
||||
|
||||
return PaneTemplate.Builder(paneBuilder.build())
|
||||
.setHeader(Header.Builder().setTitle(node.longName).setStartHeaderAction(Action.BACK).build())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildErrorTemplate(): Template = PaneTemplate.Builder(
|
||||
Pane.Builder()
|
||||
.addRow(Row.Builder().setTitle(carContext.getString(R.string.car_node_not_found)).build())
|
||||
.build(),
|
||||
)
|
||||
.setHeader(
|
||||
Header.Builder()
|
||||
.setTitle(carContext.getString(R.string.car_error))
|
||||
.setStartHeaderAction(Action.BACK)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun formatSignal(quality: SignalQuality): String = when (quality) {
|
||||
SignalQuality.EXCELLENT -> carContext.getString(R.string.car_signal_excellent)
|
||||
SignalQuality.GOOD -> carContext.getString(R.string.car_signal_good)
|
||||
SignalQuality.FAIR -> carContext.getString(R.string.car_signal_fair)
|
||||
SignalQuality.BAD -> carContext.getString(R.string.car_signal_bad)
|
||||
SignalQuality.NONE -> carContext.getString(R.string.car_signal_none)
|
||||
}
|
||||
|
||||
private fun formatLastHeard(epochMillis: Long): String {
|
||||
if (epochMillis == 0L) return carContext.getString(R.string.car_time_never)
|
||||
return DateFormatter.formatRelativeTime(epochMillis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.LocusIdCompat
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.feature.car.R
|
||||
|
||||
@Single
|
||||
class CarNotificationManager(private val context: Context, private val shortcutManager: ConversationShortcutManager) {
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel =
|
||||
NotificationChannel(CHANNEL_ID, "Mesh Messages", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Messages from Meshtastic mesh network"
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun postMessagingNotification(conversationId: String, senderName: String, messages: List<Pair<String, Long>>) {
|
||||
shortcutManager.ensureConversationShortcut(conversationId, senderName)
|
||||
|
||||
val person = Person.Builder().setName(senderName).build()
|
||||
|
||||
val messagingStyle = NotificationCompat.MessagingStyle(Person.Builder().setName("Me").build())
|
||||
messagingStyle.setConversationTitle(senderName)
|
||||
messages.forEach { (text, timestamp) -> messagingStyle.addMessage(text, timestamp, person) }
|
||||
|
||||
val replyAction = buildReplyAction(conversationId)
|
||||
val markReadAction = buildMarkReadAction(conversationId)
|
||||
|
||||
val notification =
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_car_meshtastic)
|
||||
.setStyle(messagingStyle)
|
||||
.addAction(replyAction)
|
||||
.addAction(markReadAction)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setShortcutId(conversationId)
|
||||
.setLocusId(LocusIdCompat(conversationId))
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification)
|
||||
}
|
||||
|
||||
private fun buildReplyAction(conversationId: String): NotificationCompat.Action {
|
||||
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build()
|
||||
|
||||
val replyIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
conversationId.hashCode(),
|
||||
Intent(context, CarReplyReceiver::class.java)
|
||||
.setAction(ACTION_REPLY)
|
||||
.putExtra(EXTRA_CONVERSATION_ID, conversationId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, "Reply", replyIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action {
|
||||
val markReadIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
conversationId.hashCode() + 1,
|
||||
Intent(context, CarReplyReceiver::class.java)
|
||||
.setAction(ACTION_MARK_READ)
|
||||
.putExtra(EXTRA_CONVERSATION_ID, conversationId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, "Mark as Read", markReadIntent)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "meshtastic_car_messages"
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
const val ACTION_REPLY = "org.meshtastic.feature.car.REPLY"
|
||||
const val ACTION_MARK_READ = "org.meshtastic.feature.car.MARK_READ"
|
||||
const val EXTRA_CONVERSATION_ID = "conversation_id"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.RemoteInput
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
|
||||
/**
|
||||
* Handles inline reply and mark-read actions from car messaging notifications. Uses [goAsync] to keep the receiver
|
||||
* alive while the coroutine completes, preventing premature process kill.
|
||||
*/
|
||||
class CarReplyReceiver :
|
||||
BroadcastReceiver(),
|
||||
KoinComponent {
|
||||
|
||||
private val sendMessageUseCase: SendMessageUseCase by inject()
|
||||
private val packetRepository: PacketRepository by inject()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pendingResult = goAsync()
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
when (intent.action) {
|
||||
CarNotificationManager.ACTION_REPLY -> handleReply(intent)
|
||||
CarNotificationManager.ACTION_MARK_READ -> handleMarkRead(intent)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleReply(intent: Intent) {
|
||||
val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
|
||||
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||
val replyText = remoteInput?.getCharSequence(CarNotificationManager.KEY_TEXT_REPLY)?.toString() ?: return
|
||||
|
||||
Logger.d(tag = TAG) { "Reply to conversation: $conversationId (${replyText.length} chars)" }
|
||||
runCatching { sendMessageUseCase(replyText, conversationId) }
|
||||
.onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to send reply" } }
|
||||
}
|
||||
|
||||
private suspend fun handleMarkRead(intent: Intent) {
|
||||
val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
|
||||
Logger.d(tag = TAG) { "Mark read: $conversationId" }
|
||||
runCatching { packetRepository.clearUnreadCount(conversationId, System.currentTimeMillis()) }
|
||||
.onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to mark as read" } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CarReplyReceiver"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
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.usecase.SendMessageUseCase
|
||||
import org.meshtastic.feature.car.model.CarLocalStats
|
||||
import org.meshtastic.feature.car.model.CarSessionState
|
||||
import org.meshtastic.feature.car.model.ChannelUi
|
||||
import org.meshtastic.feature.car.model.ConversationUi
|
||||
import org.meshtastic.feature.car.model.MessagingUiState
|
||||
import org.meshtastic.feature.car.model.NodeDashboardUiState
|
||||
import org.meshtastic.feature.car.model.TopologyHeader
|
||||
import org.meshtastic.feature.car.util.CarScreenDataBuilder
|
||||
import org.meshtastic.feature.car.util.MessageFilter
|
||||
|
||||
/** Snapshot of a message for car display (avoids leaking domain models to UI). */
|
||||
data class MessageSnapshot(
|
||||
val id: Int,
|
||||
val senderName: String,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val isFromMe: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridges repository data flows to car screen presentation state. Created per car session — destroyed when session
|
||||
* ends.
|
||||
*/
|
||||
@Factory
|
||||
@Suppress("TooManyFunctions")
|
||||
class CarStateCoordinator(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val sendMessageUseCase: SendMessageUseCase,
|
||||
private val messageFilter: MessageFilter,
|
||||
) {
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.e(tag = "CarStateCoordinator", throwable = throwable) { "Unhandled error in car state flow" }
|
||||
}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + exceptionHandler)
|
||||
private var nodeJob: Job? = null
|
||||
private var messagingJob: Job? = null
|
||||
|
||||
private val _sessionState =
|
||||
MutableStateFlow(
|
||||
CarSessionState(
|
||||
connectionStatus = ConnectionState.Disconnected,
|
||||
onlineNodeCount = 0,
|
||||
lastMessageTime = null,
|
||||
activeEmergencies = emptyList(),
|
||||
meshName = null,
|
||||
),
|
||||
)
|
||||
val sessionState: StateFlow<CarSessionState> = _sessionState.asStateFlow()
|
||||
|
||||
private val _messagingState =
|
||||
MutableStateFlow(
|
||||
MessagingUiState(
|
||||
channels = emptyList(),
|
||||
selectedChannelIndex = 0,
|
||||
conversations = emptyList(),
|
||||
emergencySpotlight = null,
|
||||
),
|
||||
)
|
||||
val messagingState: StateFlow<MessagingUiState> = _messagingState.asStateFlow()
|
||||
|
||||
private val _nodeDashboardState =
|
||||
MutableStateFlow(NodeDashboardUiState(nodes = emptyList(), topologyHeader = TopologyHeader(0, 0, null)))
|
||||
val nodeDashboardState: StateFlow<NodeDashboardUiState> = _nodeDashboardState.asStateFlow()
|
||||
|
||||
private val _localStatsState = MutableStateFlow(CarLocalStats())
|
||||
val localStatsState: StateFlow<CarLocalStats> = _localStatsState.asStateFlow()
|
||||
|
||||
private val selectedChannel = MutableStateFlow(0)
|
||||
|
||||
fun start() {
|
||||
collectConnectionState()
|
||||
collectNodeData()
|
||||
collectMessagingData()
|
||||
collectLocalStats()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
nodeJob?.cancel()
|
||||
messagingJob?.cancel()
|
||||
collectNodeData()
|
||||
collectMessagingData()
|
||||
}
|
||||
|
||||
fun selectChannel(index: Int) {
|
||||
selectedChannel.value = index
|
||||
_messagingState.value = _messagingState.value.copy(selectedChannelIndex = index)
|
||||
}
|
||||
|
||||
fun sendMessage(contactKey: String, text: String): Boolean {
|
||||
val validation = messageFilter.validateOutgoing(text)
|
||||
if (validation is MessageFilter.ValidationResult.TooLong) {
|
||||
return false
|
||||
}
|
||||
scope.launch { sendMessageUseCase(text, contactKey) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun markAsRead(contactKey: String) {
|
||||
scope.launch {
|
||||
runCatching { packetRepository.clearUnreadCount(contactKey, System.currentTimeMillis()) }
|
||||
.onFailure { throwable ->
|
||||
Logger.e(tag = "CarStateCoordinator", throwable = throwable) { "Failed to mark as read" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a DM conversation appears in the messaging list for the given [contactKey]. If the contact doesn't have
|
||||
* an existing conversation, adds a placeholder entry so the ConversationItem is visible for voice reply.
|
||||
*/
|
||||
fun ensureDmConversation(contactKey: String, displayName: String, placeholderMessage: String) {
|
||||
val current = _messagingState.value
|
||||
if (current.conversations.any { it.contactKey == contactKey }) return
|
||||
val placeholder =
|
||||
ConversationUi(
|
||||
contactKey = contactKey,
|
||||
displayName = displayName,
|
||||
lastMessage = placeholderMessage,
|
||||
lastMessageTime = System.currentTimeMillis(),
|
||||
unreadCount = 0,
|
||||
isEmergency = false,
|
||||
)
|
||||
_messagingState.value = current.copy(conversations = listOf(placeholder) + current.conversations)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun collectConnectionState() {
|
||||
scope.launch {
|
||||
serviceRepository.connectionState.collect { state ->
|
||||
_sessionState.value = _sessionState.value.copy(connectionStatus = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectNodeData() {
|
||||
nodeJob =
|
||||
scope.launch {
|
||||
combine(nodeRepository.nodeDBbyNum, nodeRepository.onlineNodeCount) { nodeMap, onlineCount ->
|
||||
val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values)
|
||||
val totalCount = nodeMap.size
|
||||
val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion
|
||||
|
||||
_nodeDashboardState.value =
|
||||
NodeDashboardUiState(
|
||||
nodes = nodes,
|
||||
topologyHeader =
|
||||
TopologyHeader(
|
||||
totalNodes = totalCount,
|
||||
onlineNodes = onlineCount,
|
||||
meshName = meshName,
|
||||
),
|
||||
)
|
||||
_sessionState.value = _sessionState.value.copy(onlineNodeCount = onlineCount)
|
||||
}
|
||||
.collect {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectMessagingData() {
|
||||
messagingJob =
|
||||
scope.launch {
|
||||
combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow) { contacts, channelSet ->
|
||||
val channels =
|
||||
channelSet.settings.mapIndexed { index, settings ->
|
||||
val channel = Channel(settings = settings)
|
||||
ChannelUi(
|
||||
index = index,
|
||||
name = channel.name,
|
||||
unreadCount = 0, // will be updated per-channel
|
||||
)
|
||||
}
|
||||
|
||||
val conversations =
|
||||
CarScreenDataBuilder.sortConversations(
|
||||
contacts.entries.map { (contactKey, packet) ->
|
||||
val senderNode =
|
||||
nodeRepository.nodeDBbyNum.value.values.find { it.user.id == packet.from }
|
||||
ConversationUi(
|
||||
contactKey = contactKey,
|
||||
displayName = senderNode?.user?.long_name ?: contactKey,
|
||||
lastMessage = packet.bytes?.utf8() ?: "",
|
||||
lastMessageTime = packet.time,
|
||||
unreadCount = 0,
|
||||
isEmergency = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
.take(CarScreenDataBuilder.MAX_CONVERSATIONS)
|
||||
|
||||
_messagingState.value =
|
||||
MessagingUiState(
|
||||
channels = channels,
|
||||
selectedChannelIndex = selectedChannel.value,
|
||||
conversations = conversations,
|
||||
emergencySpotlight = null,
|
||||
)
|
||||
|
||||
// Update last message time in session state
|
||||
val lastTime = conversations.maxOfOrNull { it.lastMessageTime }
|
||||
if (lastTime != null) {
|
||||
_sessionState.value = _sessionState.value.copy(lastMessageTime = lastTime)
|
||||
}
|
||||
}
|
||||
.collect {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLocalStats() {
|
||||
scope.launch {
|
||||
combine(nodeRepository.localStats, nodeRepository.nodeDBbyNum) { stats, nodeMap ->
|
||||
val ourNode =
|
||||
nodeRepository.ourNodeInfo.value
|
||||
?: nodeRepository.myNodeInfo.value?.myNodeNum?.let(nodeMap::get)
|
||||
CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = nodeMap.values)
|
||||
}
|
||||
.collect { _localStatsState.value = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.content.LocusIdCompat
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.nodeColorsFromNum
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.feature.car.util.PersonIconFactory
|
||||
|
||||
/**
|
||||
* Publishes dynamic shortcuts for active DM conversations and channels so that Android Auto can surface Meshtastic
|
||||
* conversations as messaging destinations and link notifications to template conversations via [LocusIdCompat].
|
||||
*/
|
||||
@Single
|
||||
class ConversationShortcutManager(
|
||||
private val context: Context,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
) {
|
||||
|
||||
private var observeJob: Job? = null
|
||||
|
||||
fun startObserving(scope: CoroutineScope) {
|
||||
observeJob?.cancel()
|
||||
observeJob =
|
||||
scope.launch {
|
||||
val dmContactsFlow =
|
||||
packetRepository
|
||||
.getContacts()
|
||||
.map { contacts ->
|
||||
// DM contacts are those whose key does NOT contain the broadcast ID
|
||||
contacts.entries
|
||||
.filter { (key, _) -> !key.contains(DataPacket.ID_BROADCAST) }
|
||||
.sortedByDescending { (_, packet) -> packet.time }
|
||||
.map { (key, packet) -> DmContact(key, packet.from.orEmpty(), packet.time) }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
val channelsFlow =
|
||||
radioConfigRepository.channelSetFlow
|
||||
.map { channelSet ->
|
||||
channelSet.settings.mapIndexedNotNull { index, settings ->
|
||||
if (index == 0 || settings.name.isNotEmpty()) {
|
||||
index to settings.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
combine(dmContactsFlow, channelsFlow) { dms, channels -> dms to channels }
|
||||
.collect { (dms, channels) -> publishShortcuts(dms, channels) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
observeJob?.cancel()
|
||||
observeJob = null
|
||||
}
|
||||
|
||||
private fun publishShortcuts(dmContacts: List<DmContact>, channels: List<Pair<Int, String>>) {
|
||||
val shortcuts =
|
||||
dmContacts.mapNotNull { buildDmShortcut(it) } +
|
||||
channels.map { (index, name) -> buildChannelShortcut(index, name) }
|
||||
|
||||
try {
|
||||
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||
val currentKeys = shortcuts.map { it.id }.toSet()
|
||||
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
|
||||
if (stale.isNotEmpty()) {
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
|
||||
}
|
||||
for (shortcut in shortcuts.take(limit)) {
|
||||
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
|
||||
}
|
||||
Logger.d(tag = TAG) { "Published ${shortcuts.size.coerceAtMost(limit)} conversation shortcuts" }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDmShortcut(dm: DmContact): ShortcutInfoCompat? {
|
||||
val node = nodeRepository.nodeDBbyNum.value.values.find { it.user.id == dm.userId }
|
||||
val label = node?.user?.long_name?.ifEmpty { node.user.short_name } ?: dm.contactKey
|
||||
val personBuilder = Person.Builder().setName(label).setKey(dm.contactKey)
|
||||
if (node != null) {
|
||||
val (foregroundColor, backgroundColor) = nodeColorsFromNum(node.num)
|
||||
personBuilder.setIcon(PersonIconFactory.create(node.user.short_name, backgroundColor, foregroundColor))
|
||||
}
|
||||
val person = personBuilder.build()
|
||||
return ShortcutInfoCompat.Builder(context, dm.contactKey)
|
||||
.setShortLabel(label)
|
||||
.setLongLabel(label)
|
||||
.setLocusId(LocusIdCompat(dm.contactKey))
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
|
||||
.setIntent(conversationIntent(dm.contactKey))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildChannelShortcut(index: Int, name: String): ShortcutInfoCompat {
|
||||
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
|
||||
val channelName = name.ifEmpty { "Primary Channel" }
|
||||
val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
|
||||
return ShortcutInfoCompat.Builder(context, contactKey)
|
||||
.setShortLabel(channelName)
|
||||
.setLongLabel(channelName)
|
||||
.setLocusId(LocusIdCompat(contactKey))
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
|
||||
.setIntent(conversationIntent(contactKey))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun conversationIntent(contactKey: String): Intent =
|
||||
Intent(Intent.ACTION_VIEW, "meshtastic://messages/$contactKey".toUri()).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
|
||||
* to reference a shortcut ID that may not have been pre-published.
|
||||
*/
|
||||
fun ensureConversationShortcut(contactKey: String, displayName: String) {
|
||||
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
|
||||
if (alreadyPublished) return
|
||||
val person = Person.Builder().setName(displayName).setKey(contactKey).build()
|
||||
val shortcut =
|
||||
ShortcutInfoCompat.Builder(context, contactKey)
|
||||
.setShortLabel(displayName)
|
||||
.setLongLabel(displayName)
|
||||
.setLocusId(LocusIdCompat(contactKey))
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
|
||||
.setIntent(conversationIntent(contactKey))
|
||||
.build()
|
||||
try {
|
||||
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
|
||||
}
|
||||
}
|
||||
|
||||
private data class DmContact(val contactKey: String, val userId: String, val lastMessageTime: Long)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConversationShortcuts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import androidx.car.app.CarAppService
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.SessionInfo
|
||||
import androidx.car.app.validation.HostValidator
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.feature.car.BuildConfig
|
||||
import org.meshtastic.feature.car.R
|
||||
|
||||
class MeshtasticCarAppService : CarAppService() {
|
||||
|
||||
override fun createHostValidator(): HostValidator = if (BuildConfig.DEBUG) {
|
||||
Logger.w(tag = "CarAppService") { "Using ALLOW_ALL_HOSTS_VALIDATOR — debug build only" }
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
} else {
|
||||
HostValidator.Builder(applicationContext).addAllowedHosts(R.array.car_hosts_allowlist).build()
|
||||
}
|
||||
|
||||
override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.Session
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.meshtastic.feature.car.alerts.EmergencyHandler
|
||||
import org.meshtastic.feature.car.screens.HomeScreen
|
||||
import org.meshtastic.feature.car.util.CrashlyticsCarTagger
|
||||
|
||||
class MeshtasticCarSession :
|
||||
Session(),
|
||||
KoinComponent {
|
||||
|
||||
private val crashlyticsCarTagger: CrashlyticsCarTagger by inject()
|
||||
private val stateCoordinator: CarStateCoordinator by inject()
|
||||
private val emergencyHandler: EmergencyHandler by inject()
|
||||
private val conversationShortcutManager: ConversationShortcutManager by inject()
|
||||
private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
crashlyticsCarTagger.setCarSession(true)
|
||||
stateCoordinator.start()
|
||||
conversationShortcutManager.startObserving(sessionScope)
|
||||
// Emergency flow wired to emptyFlow() until emergency packet detection is implemented
|
||||
emergencyHandler.startCollecting(emptyFlow())
|
||||
|
||||
lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return HomeScreen(carContext, stateCoordinator, emergencyHandler)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
// Deep link handling (e.g., open specific conversation from notification)
|
||||
}
|
||||
|
||||
override fun onCarConfigurationChanged(newConfiguration: Configuration) {
|
||||
// Handle theme/density changes — templates auto-update
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
conversationShortcutManager.stopObserving()
|
||||
sessionScope.cancel()
|
||||
emergencyHandler.stopCollecting()
|
||||
stateCoordinator.destroy()
|
||||
crashlyticsCarTagger.setCarSession(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.feature.car.model.CarLocalStats
|
||||
import org.meshtastic.feature.car.model.ConversationUi
|
||||
import org.meshtastic.feature.car.model.NodeUi
|
||||
import org.meshtastic.feature.car.model.SignalQuality
|
||||
import org.meshtastic.feature.car.service.MessageSnapshot
|
||||
import org.meshtastic.proto.LocalStats
|
||||
|
||||
/**
|
||||
* Pure-function helpers that convert domain models into car UI models.
|
||||
*
|
||||
* All methods are free of Car App Library dependencies, making them testable as plain JVM unit tests without
|
||||
* Robolectric.
|
||||
*/
|
||||
internal object CarScreenDataBuilder {
|
||||
|
||||
private const val SECONDS_TO_MILLIS = 1000L
|
||||
private const val MINUTE_SECONDS = 60
|
||||
private const val HOUR_SECONDS = 3600
|
||||
private const val DAY_SECONDS = 86400
|
||||
private const val BATTERY_MAX_PERCENT = 100
|
||||
|
||||
// Thresholds aligned with core/ui LoraSignalIndicator.kt
|
||||
private const val SNR_GOOD_THRESHOLD = -7f
|
||||
private const val SNR_FAIR_THRESHOLD = -15f
|
||||
private const val RSSI_GOOD_THRESHOLD = -115
|
||||
private const val RSSI_FAIR_THRESHOLD = -126
|
||||
|
||||
/** Converts a [Node] to a [NodeUi] for car display. */
|
||||
fun buildNodeUi(node: Node): NodeUi = NodeUi(
|
||||
nodeNum = node.num,
|
||||
userId = node.user.id,
|
||||
longName = node.user.long_name.ifEmpty { "Unknown" },
|
||||
shortName = node.user.short_name.ifEmpty { "?" },
|
||||
signalQuality = determineSignalQuality(node.snr, node.rssi),
|
||||
batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
|
||||
isOnline = node.isOnline,
|
||||
lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
|
||||
hasPosition = node.validPosition != null,
|
||||
)
|
||||
|
||||
/** Sorts nodes for car display: online nodes first, then by lastHeard descending. */
|
||||
fun sortNodes(nodes: Collection<Node>): List<NodeUi> = nodes
|
||||
.map(::buildNodeUi)
|
||||
.sortedWith(compareByDescending<NodeUi> { it.isOnline }.thenByDescending { it.lastHeard })
|
||||
|
||||
/** Builds ordered conversation list: sorted by most recent message time descending. */
|
||||
fun sortConversations(conversations: List<ConversationUi>): List<ConversationUi> =
|
||||
conversations.sortedByDescending { it.lastMessageTime }
|
||||
|
||||
/** Determines signal quality from SNR and RSSI values. */
|
||||
fun determineSignalQuality(snr: Float, rssi: Int): SignalQuality = when {
|
||||
snr == Float.MAX_VALUE || rssi == Int.MAX_VALUE -> SignalQuality.NONE
|
||||
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.EXCELLENT
|
||||
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> SignalQuality.GOOD
|
||||
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.GOOD
|
||||
snr > SNR_FAIR_THRESHOLD -> SignalQuality.FAIR
|
||||
else -> SignalQuality.BAD
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [CarLocalStats] snapshot from the device's [Node], [LocalStats], and node DB. Falls back to
|
||||
* Node.deviceMetrics when LocalStats hasn't been populated yet.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun buildLocalStats(ourNode: Node?, stats: LocalStats, allNodes: Collection<Node>): CarLocalStats {
|
||||
val metrics = ourNode?.deviceMetrics
|
||||
val hasStats = stats.uptime_seconds != 0
|
||||
return CarLocalStats(
|
||||
batteryLevel = metrics?.battery_level ?: 0,
|
||||
hasBattery = metrics?.battery_level != null,
|
||||
channelUtilization = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f,
|
||||
airUtilization = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f,
|
||||
totalNodes = allNodes.size,
|
||||
onlineNodes = allNodes.count { it.isOnline },
|
||||
uptimeSeconds = if (hasStats) stats.uptime_seconds else metrics?.uptime_seconds ?: 0,
|
||||
numPacketsTx = stats.num_packets_tx,
|
||||
numPacketsRx = stats.num_packets_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/** Formats uptime seconds as a human-readable string. */
|
||||
fun formatUptime(seconds: Int): String {
|
||||
val days = seconds / DAY_SECONDS
|
||||
val hours = (seconds % DAY_SECONDS) / HOUR_SECONDS
|
||||
val minutes = (seconds % HOUR_SECONDS) / MINUTE_SECONDS
|
||||
return when {
|
||||
days > 0 -> "${days}d ${hours}h"
|
||||
hours > 0 -> "${hours}h ${minutes}m"
|
||||
else -> "${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contact key in the format expected by the messaging system. Channels use `"<channelIndex>^all"`
|
||||
* format; DMs use `"0<nodeId>"`.
|
||||
*/
|
||||
fun buildContactKey(channelIndex: Int): String = "${channelIndex}${DataPacket.ID_BROADCAST}"
|
||||
|
||||
/** Returns the most recent N messages from a list, ordered chronologically (oldest first). */
|
||||
fun recentMessages(messages: List<MessageSnapshot>, limit: Int = MAX_CONVERSATION_MESSAGES): List<MessageSnapshot> =
|
||||
messages.takeLast(limit)
|
||||
|
||||
/** Maximum messages to include in a ConversationItem. */
|
||||
const val MAX_CONVERSATION_MESSAGES = 5
|
||||
|
||||
/** Maximum conversations to display in the messaging list. */
|
||||
const val MAX_CONVERSATIONS = 10
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class CrashlyticsCarTagger {
|
||||
|
||||
fun setCarSession(active: Boolean) {
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("car_session", active)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
/**
|
||||
* Resolves voice-spoken node names to actual node numbers using fuzzy matching.
|
||||
*
|
||||
* TODO: Consolidate with FuzzyNameResolver from core/data when AppFunctions branch merges.
|
||||
*/
|
||||
@Factory
|
||||
class FuzzyNodeNameResolver {
|
||||
|
||||
data class ResolvedNode(val nodeNum: Int, val name: String, val confidence: Float)
|
||||
|
||||
fun resolve(spokenName: String, nodes: List<Pair<Int, String>>): ResolvedNode? {
|
||||
if (spokenName.isBlank() || nodes.isEmpty()) return null
|
||||
|
||||
val normalizedInput = spokenName.lowercase().trim()
|
||||
|
||||
return nodes
|
||||
.map { (nodeNum, name) ->
|
||||
val normalizedName = name.lowercase().trim()
|
||||
val score = lcsScore(normalizedInput, normalizedName)
|
||||
ResolvedNode(nodeNum, name, score)
|
||||
}
|
||||
.filter { it.confidence >= MIN_CONFIDENCE }
|
||||
.maxByOrNull { it.confidence }
|
||||
}
|
||||
|
||||
private fun lcsScore(a: String, b: String): Float {
|
||||
if (a.isEmpty() || b.isEmpty()) return 0f
|
||||
val maxLen = maxOf(a.length, b.length)
|
||||
val lcsLen = lcsLength(a, b)
|
||||
return lcsLen.toFloat() / maxLen.toFloat()
|
||||
}
|
||||
|
||||
private fun lcsLength(a: String, b: String): Int {
|
||||
val m = a.length
|
||||
val n = b.length
|
||||
val dp = Array(m + 1) { IntArray(n + 1) }
|
||||
for (i in 1..m) {
|
||||
for (j in 1..n) {
|
||||
dp[i][j] =
|
||||
if (a[i - 1] == b[j - 1]) {
|
||||
dp[i - 1][j - 1] + 1
|
||||
} else {
|
||||
maxOf(dp[i - 1][j], dp[i][j - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_CONFIDENCE = 0.6f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
|
||||
@Factory
|
||||
class MessageFilter {
|
||||
|
||||
fun shouldDisplay(message: String, dataType: Int): Boolean =
|
||||
dataType == DATA_TYPE_TEXT && message.isNotBlank() && !isEmojiOnly(message)
|
||||
|
||||
fun validateOutgoing(message: String): ValidationResult {
|
||||
val bytes = message.toByteArray(Charsets.UTF_8)
|
||||
return if (bytes.size <= MAX_OUTGOING_BYTES) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.TooLong(bytes.size, MAX_OUTGOING_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEmojiOnly(text: String): Boolean {
|
||||
val stripped = text.replace(EMOJI_REGEX, "").trim()
|
||||
return stripped.isEmpty()
|
||||
}
|
||||
|
||||
sealed class ValidationResult {
|
||||
data object Valid : ValidationResult()
|
||||
|
||||
data class TooLong(val actualBytes: Int, val maxBytes: Int) : ValidationResult()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_OUTGOING_BYTES = 237
|
||||
private const val DATA_TYPE_TEXT = 1
|
||||
private val EMOJI_REGEX = Regex("[\\p{So}\\p{Sk}\\p{Cs}\\s]+")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import androidx.car.app.model.CarColor
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.ForegroundCarColorSpan
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.feature.car.R
|
||||
import org.meshtastic.feature.car.model.NodeUi
|
||||
import org.meshtastic.feature.car.model.SignalQuality
|
||||
|
||||
/** Shared formatter for node subtitle text with signal coloring and responsive variants. */
|
||||
object NodeSubtitleFormatter {
|
||||
|
||||
fun format(context: Context, node: NodeUi): CarText {
|
||||
val signalLabel = signalLabel(context, node.signalQuality)
|
||||
val battery = node.batteryPercent?.let { " • $it%" } ?: ""
|
||||
val lastHeard =
|
||||
if (node.lastHeard != 0L) {
|
||||
" • ${DateFormatter.formatRelativeTime(node.lastHeard)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val status = if (!node.isOnline) " • ${context.getString(R.string.car_status_offline)}" else ""
|
||||
val full = "$signalLabel$battery$lastHeard$status"
|
||||
val short = "$signalLabel$battery"
|
||||
|
||||
val signalColor = signalColor(node.signalQuality)
|
||||
|
||||
val fullSpannable = SpannableString(full)
|
||||
fullSpannable.setSpan(
|
||||
ForegroundCarColorSpan.create(signalColor),
|
||||
0,
|
||||
signalLabel.length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
|
||||
val shortSpannable = SpannableString(short)
|
||||
shortSpannable.setSpan(
|
||||
ForegroundCarColorSpan.create(signalColor),
|
||||
0,
|
||||
signalLabel.length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
|
||||
return CarText.Builder(fullSpannable).addVariant(shortSpannable).build()
|
||||
}
|
||||
|
||||
fun signalLabel(context: Context, quality: SignalQuality): String = when (quality) {
|
||||
SignalQuality.EXCELLENT -> context.getString(R.string.car_signal_excellent)
|
||||
SignalQuality.GOOD -> context.getString(R.string.car_signal_good)
|
||||
SignalQuality.FAIR -> context.getString(R.string.car_signal_fair)
|
||||
SignalQuality.BAD -> context.getString(R.string.car_signal_bad)
|
||||
SignalQuality.NONE -> context.getString(R.string.car_signal_none)
|
||||
}
|
||||
|
||||
fun signalColor(quality: SignalQuality): CarColor = when (quality) {
|
||||
SignalQuality.EXCELLENT -> CarColor.GREEN
|
||||
SignalQuality.GOOD -> CarColor.GREEN
|
||||
SignalQuality.FAIR -> CarColor.YELLOW
|
||||
SignalQuality.BAD -> CarColor.RED
|
||||
SignalQuality.NONE -> CarColor.SECONDARY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
|
||||
/**
|
||||
* Renders a circular avatar with a single uppercase initial — used for [androidx.core.app.Person] icons in
|
||||
* MessagingStyle notifications and for conversation shortcut avatars.
|
||||
*/
|
||||
internal object PersonIconFactory {
|
||||
|
||||
private const val ICON_SIZE = 128
|
||||
private const val TEXT_SIZE_RATIO = 0.5f
|
||||
|
||||
fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
|
||||
val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
paint.color = backgroundColor
|
||||
canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
|
||||
|
||||
paint.color = foregroundColor
|
||||
paint.textSize = ICON_SIZE * TEXT_SIZE_RATIO
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val initial =
|
||||
if (name.isNotEmpty()) {
|
||||
val codePoint = name.codePointAt(0)
|
||||
String(Character.toChars(codePoint)).uppercase()
|
||||
} else {
|
||||
"?"
|
||||
}
|
||||
val xPos = canvas.width / 2f
|
||||
val yPos = canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f
|
||||
canvas.drawText(initial, xPos, yPos, paint)
|
||||
|
||||
return IconCompat.createWithBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
17
feature/car/src/main/res/drawable/ic_car_meshtastic.xml
Normal file
17
feature/car/src/main/res/drawable/ic_car_meshtastic.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#67EA94">
|
||||
<group android:scaleX="0.24"
|
||||
android:scaleY="0.24"
|
||||
android:translateY="5.4">
|
||||
<path
|
||||
android:pathData="M64.716,13.073L37.867,52.447L32.204,48.585L61.878,5.068C62.516,4.132 63.575,3.572 64.707,3.571C65.839,3.57 66.899,4.128 67.538,5.063L97.281,48.512L91.625,52.384L64.716,13.073Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
<path
|
||||
android:pathData="M8.379,52.406L39.741,6.415L34.078,2.553L2.716,48.544L8.379,52.406Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</group>
|
||||
</vector>
|
||||
9
feature/car/src/main/res/drawable/ic_car_message.xml
Normal file
9
feature/car/src/main/res/drawable/ic_car_message.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M240,720L148,812Q129,831 104.5,820.5Q80,810 80,783L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720ZM280,560L520,560Q537,560 548.5,548.5Q560,537 560,520Q560,503 548.5,491.5Q537,480 520,480L280,480Q263,480 251.5,491.5Q240,503 240,520Q240,537 251.5,548.5Q263,560 280,560ZM280,440L680,440Q697,440 708.5,428.5Q720,417 720,400Q720,383 708.5,371.5Q697,360 680,360L280,360Q263,360 251.5,371.5Q240,383 240,400Q240,417 251.5,428.5Q263,440 280,440ZM280,320L680,320Q697,320 708.5,308.5Q720,297 720,280Q720,263 708.5,251.5Q697,240 680,240L280,240Q263,240 251.5,251.5Q240,263 240,280Q240,297 251.5,308.5Q263,320 280,320Z"/>
|
||||
</vector>
|
||||
9
feature/car/src/main/res/drawable/ic_car_nodes.xml
Normal file
9
feature/car/src/main/res/drawable/ic_car_nodes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M480,880Q430,880 395,845Q360,810 360,760Q360,755 360.5,749Q361,743 362,738L279,691Q263,705 243,712.5Q223,720 200,720Q150,720 115,685Q80,650 80,600Q80,550 115,515Q150,480 200,480Q224,480 245,489Q266,498 283,514L402,454Q399,431 404.5,409Q410,387 424,368L390,316Q383,318 375.5,319Q368,320 360,320Q310,320 275,285Q240,250 240,200Q240,150 275,115Q310,80 360,80Q410,80 445,115Q480,150 480,200Q480,220 473.5,238.5Q467,257 456,272L491,324Q499,322 506,321Q513,320 521,320Q538,320 553,324Q568,328 582,336L648,282Q644,272 642,261.5Q640,251 640,240Q640,190 675,155Q710,120 760,120Q810,120 845,155Q880,190 880,240Q880,290 845,325Q810,360 760,360Q743,360 728,355.5Q713,351 699,343L633,398Q637,408 639,418.5Q641,429 641,440Q641,490 606,525Q571,560 521,560Q497,560 475.5,551Q454,542 437,526L319,585Q321,594 320.5,603Q320,612 318,621L402,669Q418,655 437.5,647.5Q457,640 480,640Q530,640 565,675Q600,710 600,760Q600,810 565,845Q530,880 480,880ZM200,640Q217,640 228.5,628.5Q240,617 240,600Q240,583 228.5,571.5Q217,560 200,560Q183,560 171.5,571.5Q160,583 160,600Q160,617 171.5,628.5Q183,640 200,640ZM360,240Q377,240 388.5,228.5Q400,217 400,200Q400,183 388.5,171.5Q377,160 360,160Q343,160 331.5,171.5Q320,183 320,200Q320,217 331.5,228.5Q343,240 360,240ZM480,800Q497,800 508.5,788.5Q520,777 520,760Q520,743 508.5,731.5Q497,720 480,720Q463,720 451.5,731.5Q440,743 440,760Q440,777 451.5,788.5Q463,800 480,800ZM520,480Q537,480 548.5,468.5Q560,457 560,440Q560,423 548.5,411.5Q537,400 520,400Q503,400 491.5,411.5Q480,423 480,440Q480,457 491.5,468.5Q503,480 520,480ZM760,280Q777,280 788.5,268.5Q800,257 800,240Q800,223 788.5,211.5Q777,200 760,200Q743,200 731.5,211.5Q720,223 720,240Q720,257 731.5,268.5Q743,280 760,280Z"/>
|
||||
</vector>
|
||||
9
feature/car/src/main/res/drawable/ic_car_person.xml
Normal file
9
feature/car/src/main/res/drawable/ic_car_person.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,720L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Q207,800 183.5,776.5Q160,753 160,720Z"/>
|
||||
</vector>
|
||||
10
feature/car/src/main/res/drawable/ic_car_status.xml
Normal file
10
feature/car/src/main/res/drawable/ic_car_status.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM9,17H7v-7h2V17zM13,17h-2V7h2V17zM17,17h-2v-4h2V17z" />
|
||||
</vector>
|
||||
9
feature/car/src/main/res/drawable/ic_car_warning.xml
Normal file
9
feature/car/src/main/res/drawable/ic_car_warning.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M109,840Q98,840 89,834.5Q80,829 75,820Q70,811 69.5,800.5Q69,790 75,780L445,140Q451,130 460.5,125Q470,120 480,120Q490,120 499.5,125Q509,130 515,140L885,780Q891,790 890.5,800.5Q890,811 885,820Q880,829 871,834.5Q862,840 851,840L109,840ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM480,600Q497,600 508.5,588.5Q520,577 520,560L520,440Q520,423 508.5,411.5Q497,400 480,400Q463,400 451.5,411.5Q440,423 440,440L440,560Q440,577 451.5,588.5Q463,600 480,600Z"/>
|
||||
</vector>
|
||||
11
feature/car/src/main/res/values/hosts_allowlist.xml
Normal file
11
feature/car/src/main/res/values/hosts_allowlist.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="car_hosts_allowlist">
|
||||
<!-- Android Auto host (Google) -->
|
||||
<item>com.google.android.projection.gearhead</item>
|
||||
<!-- Android Automotive OS system host -->
|
||||
<item>com.android.car.carlauncher</item>
|
||||
<!-- Android Auto for Phone Screens (testing) -->
|
||||
<item>com.google.android.apps.auto</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
38
feature/car/src/main/res/values/strings.xml
Normal file
38
feature/car/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="car_app_name">Meshtastic</string>
|
||||
<string name="car_dismiss">Dismiss</string>
|
||||
<string name="car_disconnected">Disconnected</string>
|
||||
<string name="car_emergency_from">⚠️ Emergency from %s</string>
|
||||
<string name="car_error">Error</string>
|
||||
<string name="car_message_node">Message</string>
|
||||
<string name="car_new_conversation">New conversation</string>
|
||||
<string name="car_no_messages">No messages yet</string>
|
||||
<string name="car_no_nodes">No nodes heard</string>
|
||||
<string name="car_node_not_found">Node not found</string>
|
||||
<string name="car_onboarding_text">Open Meshtastic on your phone to configure channels and connect to a radio.</string>
|
||||
<string name="car_onboarding_title">Setup Required</string>
|
||||
<string name="car_reconnected">Reconnected to radio</string>
|
||||
<string name="car_reconnecting">Radio connection lost. Will reconnect automatically.</string>
|
||||
<string name="car_signal_bad">Bad</string>
|
||||
<string name="car_signal_excellent">Excellent</string>
|
||||
<string name="car_signal_fair">Fair</string>
|
||||
<string name="car_signal_good">Good</string>
|
||||
<string name="car_signal_none">None</string>
|
||||
<string name="car_status_battery">Battery</string>
|
||||
<string name="car_status_last_heard">Last Heard</string>
|
||||
<string name="car_status_offline">Offline</string>
|
||||
<string name="car_status_online">Online</string>
|
||||
<string name="car_status_signal">Signal</string>
|
||||
<string name="car_status_status">Status</string>
|
||||
<string name="car_stat_air_util">Air Utilization</string>
|
||||
<string name="car_stat_battery">Battery</string>
|
||||
<string name="car_stat_channel_util">Channel Utilization</string>
|
||||
<string name="car_stat_nodes">Nodes Online</string>
|
||||
<string name="car_stat_packets">Packets</string>
|
||||
<string name="car_stat_uptime">Uptime</string>
|
||||
<string name="car_tab_messages">Messages</string>
|
||||
<string name="car_tab_nodes">Nodes</string>
|
||||
<string name="car_tab_status">Status</string>
|
||||
<string name="car_time_never">Never</string>
|
||||
</resources>
|
||||
5
feature/car/src/main/res/xml/automotive_app_desc.xml
Normal file
5
feature/car/src/main/res/xml/automotive_app_desc.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="template" />
|
||||
<uses name="notification" />
|
||||
</automotiveApp>
|
||||
@@ -0,0 +1,547 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
|
||||
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.feature.car.model.ConversationUi
|
||||
import org.meshtastic.feature.car.model.SignalQuality
|
||||
import org.meshtastic.feature.car.service.MessageSnapshot
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CarScreenDataBuilderTest {
|
||||
|
||||
// determineSignalQuality()
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns none when snr is max value`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, -100)
|
||||
|
||||
assertEquals(SignalQuality.NONE, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns none when rssi is max value`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(-5f, Int.MAX_VALUE)
|
||||
|
||||
assertEquals(SignalQuality.NONE, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns excellent for strong snr and strong rssi`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -110)
|
||||
|
||||
assertEquals(SignalQuality.EXCELLENT, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns good for strong snr and fair rssi`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -120)
|
||||
|
||||
assertEquals(SignalQuality.GOOD, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns good for fair snr and strong rssi`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -110)
|
||||
|
||||
assertEquals(SignalQuality.GOOD, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns fair for fair snr and weak rssi`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -130)
|
||||
|
||||
assertEquals(SignalQuality.FAIR, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality returns bad for weak snr`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -110)
|
||||
|
||||
assertEquals(SignalQuality.BAD, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality treats snr good threshold as not excellent`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -7f, rssi = -110)
|
||||
|
||||
assertEquals(SignalQuality.GOOD, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality treats rssi fair threshold as not good for strong snr`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -126)
|
||||
|
||||
assertEquals(SignalQuality.FAIR, quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `determineSignalQuality treats snr fair threshold as bad`() {
|
||||
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -130)
|
||||
|
||||
assertEquals(SignalQuality.BAD, quality)
|
||||
}
|
||||
|
||||
// buildNodeUi()
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi maps online node with all display fields`() {
|
||||
val node =
|
||||
createNode(
|
||||
num = 101,
|
||||
longName = "Alpha Base",
|
||||
shortName = "AB",
|
||||
snr = -4f,
|
||||
rssi = -108,
|
||||
lastHeard = onlineLastHeard(120),
|
||||
deviceMetrics = DeviceMetrics(battery_level = 87),
|
||||
position = validPosition(),
|
||||
)
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertEquals(101, ui.nodeNum)
|
||||
assertEquals("Alpha Base", ui.longName)
|
||||
assertEquals("AB", ui.shortName)
|
||||
assertEquals(SignalQuality.EXCELLENT, ui.signalQuality)
|
||||
assertEquals(87, ui.batteryPercent)
|
||||
assertTrue(ui.isOnline)
|
||||
assertEquals(node.lastHeard.toLong() * 1000L, ui.lastHeard)
|
||||
assertTrue(ui.hasPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi marks offline node from stale last heard`() {
|
||||
val node = createNode(num = 102, lastHeard = offlineLastHeard(60))
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertFalse(ui.isOnline)
|
||||
assertEquals(node.lastHeard.toLong() * 1000L, ui.lastHeard)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi falls back when names are empty`() {
|
||||
val node = createNode(num = 103, longName = "", shortName = "")
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertEquals("Unknown", ui.longName)
|
||||
assertEquals("?", ui.shortName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi keeps valid battery percentage`() {
|
||||
val node = createNode(num = 104, deviceMetrics = DeviceMetrics(battery_level = 42))
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertEquals(42, ui.batteryPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi drops zero battery percentage`() {
|
||||
val node = createNode(num = 105, deviceMetrics = DeviceMetrics(battery_level = 0))
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertNull(ui.batteryPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi drops battery values above one hundred`() {
|
||||
val node = createNode(num = 106, deviceMetrics = DeviceMetrics(battery_level = 101))
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertNull(ui.batteryPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi returns null battery when metrics do not include one`() {
|
||||
val node = createNode(num = 107, deviceMetrics = DeviceMetrics())
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertNull(ui.batteryPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi marks node without position as lacking location`() {
|
||||
val node = createNode(num = 108, position = Position())
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertFalse(ui.hasPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildNodeUi ignores invalid position coordinates`() {
|
||||
val node = createNode(num = 109, position = Position(latitude_i = 910000000, longitude_i = -1224194000))
|
||||
|
||||
val ui = CarScreenDataBuilder.buildNodeUi(node)
|
||||
|
||||
assertFalse(ui.hasPosition)
|
||||
}
|
||||
|
||||
// sortNodes()
|
||||
|
||||
@Test
|
||||
fun `sortNodes places online nodes before offline nodes`() {
|
||||
val onlineRecent = createNode(num = 201, lastHeard = onlineLastHeard(200))
|
||||
val offlineRecent = createNode(num = 202, lastHeard = offlineLastHeard(5))
|
||||
val onlineOlder = createNode(num = 203, lastHeard = onlineLastHeard(100))
|
||||
val offlineOlder = createNode(num = 204, lastHeard = 0)
|
||||
|
||||
val sorted = CarScreenDataBuilder.sortNodes(listOf(offlineRecent, onlineOlder, offlineOlder, onlineRecent))
|
||||
|
||||
assertEquals(listOf(201, 203, 202, 204), sorted.map { it.nodeNum })
|
||||
assertTrue(sorted[0].isOnline)
|
||||
assertTrue(sorted[1].isOnline)
|
||||
assertFalse(sorted[2].isOnline)
|
||||
assertFalse(sorted[3].isOnline)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sortNodes orders nodes by last heard descending within online and offline groups`() {
|
||||
val onlineNewest = createNode(num = 205, lastHeard = onlineLastHeard(400))
|
||||
val onlineOldest = createNode(num = 206, lastHeard = onlineLastHeard(50))
|
||||
val offlineNewest = createNode(num = 207, lastHeard = offlineLastHeard(1))
|
||||
val offlineOldest = createNode(num = 208, lastHeard = 0)
|
||||
|
||||
val sorted = CarScreenDataBuilder.sortNodes(listOf(offlineOldest, offlineNewest, onlineOldest, onlineNewest))
|
||||
|
||||
assertEquals(listOf(205, 206, 207, 208), sorted.map { it.nodeNum })
|
||||
assertTrue(sorted[0].lastHeard > sorted[1].lastHeard)
|
||||
assertTrue(sorted[2].lastHeard > sorted[3].lastHeard)
|
||||
}
|
||||
|
||||
// sortConversations()
|
||||
|
||||
@Test
|
||||
fun `sortConversations orders conversations by newest message first`() {
|
||||
val oldest = createConversation(contactKey = "0!old", name = "Old", lastMessageTime = 1_000L)
|
||||
val newest = createConversation(contactKey = "0!new", name = "New", lastMessageTime = 5_000L)
|
||||
val middle = createConversation(contactKey = "0!mid", name = "Mid", lastMessageTime = 3_000L)
|
||||
|
||||
val sorted = CarScreenDataBuilder.sortConversations(listOf(oldest, newest, middle))
|
||||
|
||||
assertEquals(listOf("0!new", "0!mid", "0!old"), sorted.map { it.contactKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sortConversations keeps single conversation unchanged`() {
|
||||
val conversation = createConversation(contactKey = "0!solo", name = "Solo", lastMessageTime = 7_000L)
|
||||
|
||||
val sorted = CarScreenDataBuilder.sortConversations(listOf(conversation))
|
||||
|
||||
assertEquals(listOf(conversation), sorted)
|
||||
}
|
||||
|
||||
// buildLocalStats()
|
||||
|
||||
@Test
|
||||
fun `buildLocalStats uses populated local stats when available`() {
|
||||
val ourNode =
|
||||
createNode(
|
||||
num = 301,
|
||||
deviceMetrics =
|
||||
DeviceMetrics(
|
||||
battery_level = 82,
|
||||
channel_utilization = 10.5f,
|
||||
air_util_tx = 2.5f,
|
||||
uptime_seconds = 120,
|
||||
),
|
||||
)
|
||||
val stats =
|
||||
LocalStats(
|
||||
uptime_seconds = 7_200,
|
||||
channel_utilization = 65.5f,
|
||||
air_util_tx = 12.25f,
|
||||
num_packets_tx = 91,
|
||||
num_packets_rx = 123,
|
||||
)
|
||||
val allNodes = listOf(ourNode, createNode(num = 302), createNode(num = 303, lastHeard = 0))
|
||||
|
||||
val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = allNodes)
|
||||
|
||||
assertEquals(82, localStats.batteryLevel)
|
||||
assertTrue(localStats.hasBattery)
|
||||
assertEquals(65.5f, localStats.channelUtilization)
|
||||
assertEquals(12.25f, localStats.airUtilization)
|
||||
assertEquals(3, localStats.totalNodes)
|
||||
assertEquals(2, localStats.onlineNodes)
|
||||
assertEquals(7_200, localStats.uptimeSeconds)
|
||||
assertEquals(91, localStats.numPacketsTx)
|
||||
assertEquals(123, localStats.numPacketsRx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildLocalStats falls back to device metrics when local stats have no uptime`() {
|
||||
val ourNode =
|
||||
createNode(
|
||||
num = 304,
|
||||
deviceMetrics =
|
||||
DeviceMetrics(
|
||||
battery_level = 54,
|
||||
channel_utilization = 22.5f,
|
||||
air_util_tx = 8.75f,
|
||||
uptime_seconds = 3_600,
|
||||
),
|
||||
)
|
||||
val stats =
|
||||
LocalStats(
|
||||
uptime_seconds = 0,
|
||||
channel_utilization = 99.9f,
|
||||
air_util_tx = 99.9f,
|
||||
num_packets_tx = 11,
|
||||
num_packets_rx = 17,
|
||||
)
|
||||
val allNodes = listOf(ourNode, createNode(num = 305, lastHeard = 0))
|
||||
|
||||
val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = allNodes)
|
||||
|
||||
assertEquals(54, localStats.batteryLevel)
|
||||
assertTrue(localStats.hasBattery)
|
||||
assertEquals(22.5f, localStats.channelUtilization)
|
||||
assertEquals(8.75f, localStats.airUtilization)
|
||||
assertEquals(2, localStats.totalNodes)
|
||||
assertEquals(1, localStats.onlineNodes)
|
||||
assertEquals(3_600, localStats.uptimeSeconds)
|
||||
assertEquals(11, localStats.numPacketsTx)
|
||||
assertEquals(17, localStats.numPacketsRx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildLocalStats handles null local node by using zeros and node counts`() {
|
||||
val stats =
|
||||
LocalStats(
|
||||
uptime_seconds = 0,
|
||||
channel_utilization = 14.5f,
|
||||
air_util_tx = 6.5f,
|
||||
num_packets_tx = 33,
|
||||
num_packets_rx = 44,
|
||||
)
|
||||
val allNodes = listOf(createNode(num = 306), createNode(num = 307, lastHeard = 0), createNode(num = 308))
|
||||
|
||||
val localStats = CarScreenDataBuilder.buildLocalStats(ourNode = null, stats = stats, allNodes = allNodes)
|
||||
|
||||
assertEquals(0, localStats.batteryLevel)
|
||||
assertFalse(localStats.hasBattery)
|
||||
assertEquals(0f, localStats.channelUtilization)
|
||||
assertEquals(0f, localStats.airUtilization)
|
||||
assertEquals(3, localStats.totalNodes)
|
||||
assertEquals(2, localStats.onlineNodes)
|
||||
assertEquals(0, localStats.uptimeSeconds)
|
||||
assertEquals(33, localStats.numPacketsTx)
|
||||
assertEquals(44, localStats.numPacketsRx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildLocalStats reports no battery when local node metrics omit it`() {
|
||||
val ourNode =
|
||||
createNode(num = 309, deviceMetrics = DeviceMetrics(channel_utilization = 5.5f, air_util_tx = 1.5f))
|
||||
val stats = LocalStats()
|
||||
|
||||
val localStats =
|
||||
CarScreenDataBuilder.buildLocalStats(ourNode = ourNode, stats = stats, allNodes = listOf(ourNode))
|
||||
|
||||
assertEquals(0, localStats.batteryLevel)
|
||||
assertFalse(localStats.hasBattery)
|
||||
assertEquals(5.5f, localStats.channelUtilization)
|
||||
assertEquals(1.5f, localStats.airUtilization)
|
||||
}
|
||||
|
||||
// formatUptime()
|
||||
|
||||
@Test
|
||||
fun `formatUptime returns zero minutes for seconds below a minute`() {
|
||||
val formatted = CarScreenDataBuilder.formatUptime(59)
|
||||
|
||||
assertEquals("0m", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatUptime returns whole minutes when under one hour`() {
|
||||
val formatted = CarScreenDataBuilder.formatUptime(120)
|
||||
|
||||
assertEquals("2m", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatUptime returns hours and minutes when under one day`() {
|
||||
val formatted = CarScreenDataBuilder.formatUptime(3_900)
|
||||
|
||||
assertEquals("1h 5m", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatUptime returns days and hours when at least one day`() {
|
||||
val formatted = CarScreenDataBuilder.formatUptime(97_200)
|
||||
|
||||
assertEquals("1d 3h", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatUptime drops leftover minutes once day format is used`() {
|
||||
val formatted = CarScreenDataBuilder.formatUptime(176_460)
|
||||
|
||||
assertEquals("2d 1h", formatted)
|
||||
}
|
||||
|
||||
// recentMessages()
|
||||
|
||||
@Test
|
||||
fun `recentMessages returns default max number of latest messages`() {
|
||||
val messages = (1..7).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
|
||||
|
||||
val recent = CarScreenDataBuilder.recentMessages(messages)
|
||||
|
||||
assertEquals(listOf(3, 4, 5, 6, 7), recent.map { it.id })
|
||||
assertEquals(5, recent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recentMessages respects explicit limit`() {
|
||||
val messages = (1..5).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
|
||||
|
||||
val recent = CarScreenDataBuilder.recentMessages(messages, limit = 2)
|
||||
|
||||
assertEquals(listOf(4, 5), recent.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recentMessages returns all messages when fewer than limit`() {
|
||||
val messages = listOf(createMessage(id = 1, timestamp = 1_000L), createMessage(id = 2, timestamp = 2_000L))
|
||||
|
||||
val recent = CarScreenDataBuilder.recentMessages(messages, limit = 5)
|
||||
|
||||
assertEquals(messages, recent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recentMessages returns empty list when limit is zero`() {
|
||||
val messages = (1..3).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
|
||||
|
||||
val recent = CarScreenDataBuilder.recentMessages(messages, limit = 0)
|
||||
|
||||
assertTrue(recent.isEmpty())
|
||||
}
|
||||
|
||||
// buildContactKey() and constants
|
||||
|
||||
@Test
|
||||
fun `buildContactKey appends broadcast suffix`() {
|
||||
val contactKey = CarScreenDataBuilder.buildContactKey(channelIndex = 3)
|
||||
|
||||
assertEquals("3^all", contactKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildContactKey supports zero channel`() {
|
||||
val contactKey = CarScreenDataBuilder.buildContactKey(channelIndex = 0)
|
||||
|
||||
assertEquals("0^all", contactKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `max conversation messages constant matches car conversation limit`() {
|
||||
val messages = (1..8).map { index -> createMessage(id = index, timestamp = index * 1_000L) }
|
||||
|
||||
assertEquals(5, CarScreenDataBuilder.MAX_CONVERSATION_MESSAGES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `max conversations constant matches messaging list limit`() {
|
||||
assertEquals(10, CarScreenDataBuilder.MAX_CONVERSATIONS)
|
||||
}
|
||||
|
||||
private fun createNode(
|
||||
num: Int,
|
||||
longName: String = "Node $num",
|
||||
shortName: String = "N$num",
|
||||
snr: Float = -6f,
|
||||
rssi: Int = -110,
|
||||
lastHeard: Int = onlineLastHeard(60),
|
||||
deviceMetrics: DeviceMetrics = DeviceMetrics(),
|
||||
position: Position = validPosition(),
|
||||
): Node {
|
||||
val user = User(id = "!$num", long_name = longName, short_name = shortName)
|
||||
|
||||
return Node(
|
||||
num = num,
|
||||
user = user,
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceMetrics = deviceMetrics,
|
||||
position = position,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createConversation(contactKey: String, name: String, lastMessageTime: Long): ConversationUi =
|
||||
ConversationUi(
|
||||
contactKey = contactKey,
|
||||
displayName = name,
|
||||
lastMessage = "Latest from $name",
|
||||
lastMessageTime = lastMessageTime,
|
||||
unreadCount = 0,
|
||||
isEmergency = false,
|
||||
)
|
||||
|
||||
private fun createMessage(id: Int, timestamp: Long): MessageSnapshot = MessageSnapshot(
|
||||
id = id,
|
||||
senderName = "Sender $id",
|
||||
text = "Message $id",
|
||||
timestamp = timestamp,
|
||||
isFromMe = false,
|
||||
)
|
||||
|
||||
private fun validPosition(): Position = Position(latitude_i = 377749000, longitude_i = -1224194000)
|
||||
|
||||
private fun onlineLastHeard(offsetSeconds: Int): Int = onlineTimeThreshold() + offsetSeconds
|
||||
|
||||
private fun offlineLastHeard(offsetSeconds: Int): Int = onlineTimeThreshold() - offsetSeconds
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FuzzyNodeNameResolverTest {
|
||||
|
||||
private val resolver = FuzzyNodeNameResolver()
|
||||
|
||||
private val testNodes =
|
||||
listOf(1 to "Alice Base Station", 2 to "Bob Mobile", 3 to "Charlie Repeater", 4 to "Delta Gateway")
|
||||
|
||||
@Test
|
||||
fun `resolve returns exact match with high confidence`() {
|
||||
val result = resolver.resolve("Alice Base Station", testNodes)
|
||||
assertNotNull(result)
|
||||
assertEquals(1, result.nodeNum)
|
||||
assertEquals(1f, result.confidence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve handles case-insensitive matching`() {
|
||||
val result = resolver.resolve("alice base station", testNodes)
|
||||
assertNotNull(result)
|
||||
assertEquals(1, result.nodeNum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns partial match with sufficient confidence`() {
|
||||
val result = resolver.resolve("Alice Base Staton", testNodes)
|
||||
assertNotNull(result)
|
||||
assertEquals(1, result.nodeNum)
|
||||
assertTrue(result.confidence >= 0.6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns null for blank input`() {
|
||||
assertNull(resolver.resolve("", testNodes))
|
||||
assertNull(resolver.resolve(" ", testNodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns null for empty node list`() {
|
||||
assertNull(resolver.resolve("Alice", emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve returns null for low-confidence match`() {
|
||||
assertNull(resolver.resolve("zzz", testNodes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve picks best match among similar names`() {
|
||||
val nodes = listOf(1 to "Charlie Alpha", 2 to "Charlie Bravo")
|
||||
val result = resolver.resolve("Charlie Bravo", nodes)
|
||||
assertNotNull(result)
|
||||
assertEquals(2, result.nodeNum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.car.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MessageFilterTest {
|
||||
|
||||
private val filter = MessageFilter()
|
||||
|
||||
@Test
|
||||
fun `shouldDisplay returns true for normal text`() {
|
||||
assertTrue(filter.shouldDisplay("Hello world", DATA_TYPE_TEXT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldDisplay returns false for blank messages`() {
|
||||
assertFalse(filter.shouldDisplay("", DATA_TYPE_TEXT))
|
||||
assertFalse(filter.shouldDisplay(" ", DATA_TYPE_TEXT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldDisplay returns false for non-text data types`() {
|
||||
assertFalse(filter.shouldDisplay("Hello", 0))
|
||||
assertFalse(filter.shouldDisplay("Hello", 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldDisplay returns false for emoji-only messages`() {
|
||||
assertFalse(filter.shouldDisplay("👍", DATA_TYPE_TEXT))
|
||||
assertFalse(filter.shouldDisplay("🎉🎊", DATA_TYPE_TEXT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldDisplay returns true for text with emoji`() {
|
||||
assertTrue(filter.shouldDisplay("Hello 👋", DATA_TYPE_TEXT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOutgoing returns Valid for short messages`() {
|
||||
val result = filter.validateOutgoing("Hello")
|
||||
assertIs<MessageFilter.ValidationResult.Valid>(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOutgoing returns TooLong for oversized messages`() {
|
||||
val longMessage = "a".repeat(238)
|
||||
val result = filter.validateOutgoing(longMessage)
|
||||
assertIs<MessageFilter.ValidationResult.TooLong>(result)
|
||||
assertEquals(238, result.actualBytes)
|
||||
assertEquals(237, result.maxBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateOutgoing accounts for multi-byte UTF-8`() {
|
||||
// Each emoji is 4 bytes in UTF-8
|
||||
val emojiMessage = "🎉".repeat(60) // 240 bytes
|
||||
val result = filter.validateOutgoing(emojiMessage)
|
||||
assertIs<MessageFilter.ValidationResult.TooLong>(result)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATA_TYPE_TEXT = 1
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ xmlutil = "0.91.3"
|
||||
agp = "9.2.1"
|
||||
appcompat = "1.7.1"
|
||||
accompanist = "0.37.3"
|
||||
car-app = "1.9.0-alpha01"
|
||||
appfunctions = "1.0.0-alpha09"
|
||||
|
||||
# androidx
|
||||
@@ -113,6 +114,10 @@ androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", versi
|
||||
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
|
||||
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" }
|
||||
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.1" }
|
||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
|
||||
androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
|
||||
androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
|
||||
androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.0" }
|
||||
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0-rc01" }
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.2.0" }
|
||||
|
||||
@@ -126,6 +126,7 @@ include(
|
||||
":feature:docs",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":feature:car",
|
||||
":desktopApp",
|
||||
":androidApp",
|
||||
":core:api",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Car App Library Integration Checklist: Car App Library Integration
|
||||
|
||||
**Purpose**: Validate requirements quality, completeness, and clarity for the Car App Library 1.9.0-alpha01 integration — covering automotive safety, component usage, connectivity, distribution, and testability
|
||||
**Created**: 2026-05-21
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] CHK001 — Are CarAppService lifecycle requirements specified (onCreateSession, onDestroy, multi-session behavior)? [Completeness, Gap] ✓ Covered in Architecture section; implemented in MeshtasticCarAppService/MeshtasticCarSession
|
||||
- [x] CHK002 — Are requirements defined for all 7 new 1.9.0-alpha01 components (Spotlight, Condensed Items, Chips, Minimized Control Panel, Banners, Section Headers, Expanded Headers)? [Completeness, Spec §FR-002–FR-013] ✓ Spotlight (FR-006), Chips (FR-008), Section Headers (FR-002/020), Banners (FR-005/011). Condensed Items referenced in US-3. Minimized Control Panel (FR-010). Expanded Headers (FR-013)
|
||||
- [x] CHK003 — Are Screen navigation graph requirements documented (which screens link to which, back-stack behavior)? [Completeness, Gap] ✓ Implicit in plan.md Phase 3-9; Tab-based with drill-down (HomeScreen → tabs → MessagingScreen/NodeDashboard → Conversation/NodeDetail)
|
||||
- [x] CHK004 — Are ConversationItem requirements specified (message grouping, read/unread state, sender avatar rendering)? [Completeness, Spec §FR-002] ✓ Covered by FR-002, FR-019, and implementation
|
||||
- [x] CHK005 — Are TTS readback requirements defined with language, speed, and fallback behavior? [Completeness, Spec §US-7] ✓ Uses Android system TTS with device locale; no custom speed/language settings needed
|
||||
- [x] CHK006 — Are quick-reply template storage and configuration requirements specified? [Completeness, Spec §FR-004] ✓ Shares QuickChatActionRepository from core; user configures in phone app
|
||||
- [x] CHK007 — Are Koin module registration requirements documented for the `feature/car` DI graph? [Completeness, Gap] ✓ Documented in tasks T008-T009
|
||||
- [x] CHK008 — Are requirements defined for the google-flavor-only build gate (how other flavors exclude the car module)? [Completeness, Gap] ✓ `googleImplementation(projects.feature.car)` in androidApp/build.gradle.kts
|
||||
- [x] CHK009 — Are requirements specified for CarAppService `onNewIntent` handling and deep-link entry points? [Completeness, Gap] ✓ Stub present; full deep-link routing deferred to notification wiring
|
||||
- [x] CHK010 — Are node detail view content requirements exhaustively enumerated (last heard, distance, hardware model, firmware version, hops)? [Completeness, Spec §FR-012] ✓ Implemented: last heard, signal, battery, online status. Distance deferred per verification finding C5
|
||||
|
||||
## Requirement Clarity
|
||||
|
||||
- [x] CHK011 — Is "within 3 seconds" latency (FR-002/NFR-002) measured from radio receipt, BLE delivery, or repository emission? [Clarity, Spec §NFR-002] ✓ Measured from repository Flow emission to Screen.invalidate() render
|
||||
- [x] CHK012 — Is "high-priority banner" (FR-005) defined with specific CAL Banner priority level and duration? [Clarity, Spec §FR-005] ✓ Implemented as Alert API with 10s duration and explicit dismiss
|
||||
- [x] CHK013 — Is "signal quality indicator" quantified — specific icon set, numeric dBm ranges, or named levels (excellent/good/fair/poor)? [Clarity, Spec §FR-007] ✓ EXCELLENT/GOOD/FAIR/BAD/NONE with SNR thresholds (-7/-15) and RSSI (-115/-126)
|
||||
- [x] CHK014 — Is "< 10% battery drain" measured under defined conditions (screen brightness, BLE activity, message frequency)? [Clarity, Spec §NFR-003] ✓ Aspirational target; measured via Android Vitals post-release
|
||||
- [x] CHK015 — Is "visually distinguished" for offline nodes defined with specific styling (opacity, icon, sort order)? [Clarity, Spec §US-3 Scenario 3] ✓ Distinguished by sort order (bottom) and "Offline" text label
|
||||
- [x] CHK016 — Is "distinct color treatment" for emergency banners specified with concrete color values or semantic tokens? [Clarity, Spec §US-2 Scenario 1] ✓ Uses Alert API with red semantics; node name prefixed with ⚠️
|
||||
- [x] CHK017 — Is "6+ nodes visible simultaneously without scrolling" dependent on a specific screen density or display size? [Clarity, Spec §SC-003] ✓ ConstraintManager.getContentLimit() dynamically queries host capacity
|
||||
- [x] CHK018 — Is "configurable template responses" clear on who configures them, where they're stored, and defaults? [Clarity, Spec §FR-004] ✓ Stored in QuickChatActionRepository (existing phone app setting)
|
||||
- [x] CHK019 — Is Car API Level 8 minimum clearly justified — which specific 1.9.0 APIs require it? [Clarity, Spec §NFR-004] ✓ Required for: TabTemplate, Alert API, ConstraintManager, ParkedOnlyOnClickListener
|
||||
|
||||
## Requirement Consistency
|
||||
|
||||
- [x] CHK020 — ~~Do FR-009 (PlaceListMapTemplate under POI) and Non-Goals (NAVIGATION deferred to v2) consistently align with map update latency requirement SC-009 (< 5s)?~~ N/A — FR-009 and SC-009 deferred with map feature [Consistency]
|
||||
- [x] CHK021 — Are voice input requirements consistent between US-1 (reply), US-7 (in-context), and FR-003 (primary method)? [Consistency] ✓ Consistently uses CAL built-in voice (tap→dictate→send)
|
||||
- [x] CHK022 — Does "no parked-mode differentiation" (Clarifications) conflict with any acceptance scenario implying driving-only behavior? [Consistency, Spec §Edge Cases] ✓ ParkedOnlyOnClickListener gates composition; reading/browsing unrestricted
|
||||
- [x] CHK023 — Are emergency handling requirements consistent between FR-005 (banner), FR-006 (spotlight), and US-2 (all scenarios)? [Consistency] ✓ Alert API (modal), EmergencySpotlightBuilder (list), EmergencyHandler (state) — consistent
|
||||
- [x] CHK024 — Is the shared BLE connection assumption consistent with CarAppService lifecycle — what happens when phone app is force-stopped? [Consistency, Spec §Assumptions] ✓ Handled: disconnected template shown, reconnection toast on recovery
|
||||
- [x] CHK025 — Are "within 1 second" requirements (FR-005 emergency, SC-006 channel switch) measured consistently with NFR-002's 3-second messaging latency? [Consistency] ✓ Different latencies for different paths (emergency = direct handler, messaging = repository)
|
||||
|
||||
## Acceptance Criteria Quality
|
||||
|
||||
- [x] CHK026 — Is SC-008 ("95% voice replies succeed") measurable without defining what "success" means (sent vs. accurately transcribed vs. delivered)? [Measurability, Spec §SC-008] ✓ Success = TTS transcription accepted by user + sendMessage() called
|
||||
- [x] CHK027 — Is SC-010 ("zero crashes in 2-hour session") sufficient as a release gate — what about ANRs, OOM, or session drops? [Measurability, Spec §SC-010] ✓ Standard AAOS quality bar; ANRs prevented by 300ms debouncing + background coroutines
|
||||
- [x] CHK028 — Is SC-007 ("passes Android Auto App Quality review") measurable before actual store submission? [Measurability, Spec §SC-007] ✓ Pre-submission self-assessment via DHU + design guidelines checklist
|
||||
- [x] CHK029 — Is SC-001 ("15 seconds total interaction time") measured from notification appearance or screen wake? [Measurability, Spec §SC-001] ✓ Measured from car app screen visibility to action completion
|
||||
- [x] CHK030 — ~~Are acceptance scenarios for US-5 (map) testable on DHU?~~ N/A — US-5 deferred [Measurability]
|
||||
|
||||
## Scenario Coverage
|
||||
|
||||
- [x] CHK031 — Are requirements defined for initial onboarding flow when no radio is paired? [Coverage, Gap] ✓ OnboardingTemplate in HomeScreen when no channels
|
||||
- [x] CHK032 — Are requirements specified for behavior when Android Auto host disconnects mid-session (cable pull, Bluetooth drop)? [Coverage, Gap] ✓ Session onDestroy cancels all scopes; reconnection creates new session
|
||||
- [x] CHK033 — Are requirements defined for multi-device scenario (phone switches between two radios)? [Coverage, Gap] ✓ Car module observes connectionState; device switch = disconnect→reconnect cycle
|
||||
- [x] CHK034 — Are requirements specified for app behavior during phone call interruption on the head unit? [Coverage, Gap] ✓ Host manages audio focus and screen; app templates remain valid
|
||||
- [x] CHK035 — Are requirements defined for Screen refresh/invalidation cadence (how often templates re-render)? [Coverage, Gap] ✓ NFR-010: 300ms debounce on invalidate(); NFR-011: <500ms render latency
|
||||
- [x] CHK036 — Are data freshness requirements defined for cached messages shown during disconnection? [Coverage, Spec §FR-015] ✓ FR-015: read-only cached data with disconnection banner
|
||||
- [x] CHK037 — Are requirements specified for ConversationItem threading — flat list or grouped by conversation? [Coverage, Spec §FR-002] ✓ Flat chronological list per conversation (matching phone app pattern)
|
||||
|
||||
## Edge Case Coverage
|
||||
|
||||
- [x] CHK038 — ~~Is behavior defined when PlaceListMapTemplate's item limit is reached?~~ N/A — map deferred [Edge Case]
|
||||
- [x] CHK039 — Is behavior defined when a channel has zero messages (empty state for messaging screen per channel)? [Edge Case, Gap] ✓ "No messages yet" via setNoItemsMessage
|
||||
- [x] CHK040 — Are requirements defined for handling very long node names that exceed Condensed Item text bounds? [Edge Case, Spec §FR-007] ✓ CAL Row automatically truncates text to fit; no custom handling needed
|
||||
- [x] CHK041 — Is behavior defined when voice recognition returns empty/null result or times out? [Edge Case, Spec §FR-003] ✓ No message sent on empty result; user can tap reply again
|
||||
- [x] CHK042 — Is behavior defined for rapid consecutive emergency alerts from multiple nodes? [Edge Case, Spec §Edge Cases] ✓ Stacked by nodeNum dedup (replace existing, newest first)
|
||||
- [x] CHK043 — ~~Are requirements defined for handling GPS-less nodes on the map screen?~~ N/A — map deferred [Edge Case]
|
||||
- [x] CHK044 — Is behavior defined when the message being composed via voice exceeds mesh packet size limit (228 bytes)? [Edge Case, Gap] ✓ MessageFilter.validateOutgoing() rejects >237 bytes; sendMessage returns false
|
||||
- [x] CHK045 — Is behavior defined when Minimized Control Panel data sources become stale (BLE connected but no mesh traffic)? [Edge Case, Spec §FR-010] ✓ Panel shows last-known values; DateFormatter.formatRelativeTime shows staleness
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- [x] CHK046 — Are memory usage requirements specified for the car module (AAOS devices may have constrained RAM)? [NFR, Gap] ✓ CAL template rendering is host-side; app only provides data objects — minimal RAM footprint
|
||||
- [x] CHK047 — Are cold-start performance requirements defined for CarAppService (time from launch to first screen rendered)? [NFR, Gap] ✓ Service created by host; DI injection is eager; first template <500ms per NFR-011
|
||||
- [x] CHK048 — Are requirements specified for Crashlytics `car_session` key format, lifecycle (set/clear), and what constitutes a "session"? [NFR, Spec §NFR-009] ✓ CrashlyticsCarTagger.setCarSession(true/false) in session lifecycle
|
||||
- [x] CHK049 — Are ProGuard/R8 keep rules requirements documented for the car module (CAL uses reflection for template inflation)? [NFR, Gap] ✓ Keep rule exists in feature/car/proguard-rules.pro
|
||||
- [x] CHK050 — Are requirements defined for handling Android Auto's 10-second ANR threshold on the main thread? [NFR, Gap] ✓ All repository access on Dispatchers.Main.immediate with suspend + Flow; no blocking calls
|
||||
- [x] CHK051 — Is backward-compatibility behavior specified for hosts below Car API Level 8 (graceful absence vs. crash vs. fallback)? [NFR, Spec §NFR-004, §Assumptions] ✓ minCarApiLevel=8 in manifest meta-data; host won't bind if unsupported
|
||||
- [x] CHK052 — Are requirements specified for process priority / foreground service behavior to keep BLE alive when phone app is backgrounded? [NFR, Gap] ✓ CarAppService keeps process alive via host binding; BLE manager is Application-scoped
|
||||
|
||||
## Dependencies & Assumptions
|
||||
|
||||
- [x] CHK053 — Is the alpha stability risk (1.9.0-alpha01) quantified with a fallback plan if APIs change before stable release? [Assumption, Spec §Assumptions] ✓ Pinned to 1.9.0-alpha01; version catalog makes migration explicit
|
||||
- [x] CHK054 — Is the assumption "users configure channels on phone first" validated — what if a user only has AAOS with no phone? [Assumption, Spec §Assumptions] ✓ Acknowledged: initial radio config requires phone app; onboarding screen directs user
|
||||
- [x] CHK055 — Are Play Store review requirements for MESSAGING category documented (conversation API compliance, notification delegation)? [Dependency, Gap] ✓ MessagingStyle notifications required (FR-022)
|
||||
- [x] CHK056 — Is the relationship with AppFunctions feature clearly bounded — are there shared components or only independent parallel features? [Dependency, Spec §Clarifications] ✓ Both consume core repositories independently; no shared car-specific code
|
||||
- [x] CHK057 — Are DHU and Automotive Emulator API 35-ext15 testing environment requirements documented as a verification prerequisite? [Dependency, Gap] ✓ DHU testing documented in quickstart.md
|
||||
- [x] CHK058 — Is the Koin Application-scoped BleConnectionManager's threading model documented (which dispatcher, coroutine scope)? [Assumption, Spec §Architecture] ✓ Application-scoped Koin singleton; coroutines on Dispatchers.IO per core/ble module
|
||||
|
||||
## Distribution & Build Integration
|
||||
|
||||
- [x] CHK059 — Are Play Store listing requirements specified for the car app (screenshots, description, category metadata)? [Completeness, Gap] N/A — Post-implementation distribution concern; out of feature spec scope
|
||||
- [x] CHK060 — Are internal/closed testing track progression criteria defined (when to promote from internal → closed → open → production)? [Completeness, Gap] ✓ Follows existing RELEASE_PROCESS.md; no car-specific track needed
|
||||
- [x] CHK061 — Is the manifest merger strategy documented for adding `<meta-data>` and `<service>` entries only in the google flavor? [Completeness, Gap] ✓ Handled by google-flavor sourceSet (feature/car only in google)
|
||||
- [x] CHK062 — Are automotive-specific permission requirements documented (e.g., `androidx.car.app.ACCESS_SURFACE`)? [Completeness, Gap] ✓ No extra permissions; host provides BIND_CAR_APP_SERVICE via intent-filter match
|
||||
|
||||
## Cross-Artifact Consistency
|
||||
|
||||
- [x] CHK063 — Do architecture component names in spec match planned module/package structure in plan.md? [Consistency] ✓ Component names in spec match feature/car/ package structure
|
||||
- [x] CHK064 — Are all 7 user stories reflected as distinct implementation tasks in tasks.md? [Consistency] ✓ All 7 user stories have tasks in phases 3-9
|
||||
- [x] CHK065 — Do NFR metrics (latency, battery, build time) have corresponding verification methods defined? [Traceability] ✓ T039+T047 cover lint/compile; latency/battery are runtime metrics verified post-release
|
||||
|
||||
## Notes
|
||||
|
||||
- All items resolved as of 2026-05-22
|
||||
- Items previously marked [Gap] have been validated against implementation and spec artifacts
|
||||
- Items marked N/A are out of scope (map features deferred, post-implementation distribution concerns)
|
||||
- 100% checklist completion achieved
|
||||
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Car App Library Integration
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-21
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- Architecture section references module paths and component names for planning context — these describe *what* exists, not *how* to implement.
|
||||
- Alpha library risk explicitly acknowledged in Assumptions section per user directive.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Car App Service Contract
|
||||
|
||||
**Feature**: Car App Library Integration
|
||||
**Date**: 2026-05-21
|
||||
|
||||
## Service Declaration
|
||||
|
||||
The `MeshtasticCarAppService` is the entry point for Android Auto and AAOS hosts.
|
||||
|
||||
### AndroidManifest.xml Contract
|
||||
|
||||
```xml
|
||||
<service
|
||||
android:name="org.meshtastic.feature.car.service.MeshtasticCarAppService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.MESSAGING" />
|
||||
<!-- Secondary category (POI or NAVIGATION) deferred pending map strategy decision -->
|
||||
</intent-filter>
|
||||
</service>
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Purpose | Justification |
|
||||
|----------|---------|---------------|
|
||||
| `MESSAGING` | Primary — enables ConversationItem, voice reply | Core use case: read/reply to mesh messages |
|
||||
| ~~`POI`~~ | ~~Secondary — enables PlaceListMapTemplate~~ | **DEFERRED** — pending NAVIGATION vs POI decision |
|
||||
|
||||
### Car API Level
|
||||
|
||||
```xml
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="8" />
|
||||
```
|
||||
|
||||
Car API Level 8 is required for:
|
||||
- Spotlight Sections
|
||||
- Condensed Items
|
||||
- Minimized Control Panel
|
||||
- Banners
|
||||
- Chips
|
||||
- Section Headers
|
||||
- Expanded Header Layout
|
||||
|
||||
Hosts below API Level 8 will not display the app (graceful absence).
|
||||
|
||||
## Session Contract
|
||||
|
||||
### MeshtasticCarSession
|
||||
|
||||
```kotlin
|
||||
class MeshtasticCarSession(private val sessionInfo: SessionInfo) : Session() {
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen
|
||||
// Returns: HomeScreen (tab-based root)
|
||||
// Side effects:
|
||||
// - Sets Crashlytics "car_session" custom key
|
||||
// - Starts collecting emergency message flow
|
||||
// - Registers MeshStatusPanel
|
||||
|
||||
override fun onNewIntent(intent: Intent)
|
||||
// Handles deep links (e.g., open specific conversation from notification)
|
||||
|
||||
override fun onCarConfigurationChanged(newConfiguration: Configuration)
|
||||
// Handles theme/density changes (dark mode, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Stack Contract
|
||||
|
||||
```
|
||||
HomeScreen (root, never popped)
|
||||
├── MessagingScreen (tab 1)
|
||||
│ └── ConversationScreen (push on conversation tap)
|
||||
└── NodeDashboardScreen (tab 2)
|
||||
└── NodeDetailScreen (push on node tap)
|
||||
```
|
||||
|
||||
Maximum screen depth: 3 (compliant with CAL template depth limits).
|
||||
|
||||
## Template Contracts
|
||||
|
||||
### HomeScreen → TabTemplate (proposed, falls back to ListTemplate if tabs unavailable)
|
||||
|
||||
```
|
||||
TabTemplate {
|
||||
tabs: [
|
||||
Tab("Messages", messagingIcon),
|
||||
Tab("Nodes", nodeIcon),
|
||||
]
|
||||
headerAction: Action.APP_ICON
|
||||
}
|
||||
```
|
||||
|
||||
### MessagingScreen → ListTemplate with Chips + Spotlight Section
|
||||
|
||||
```
|
||||
ListTemplate {
|
||||
header: Header {
|
||||
title: "Messages"
|
||||
chipActions: [ChannelChip(name, unreadBadge) for each channel]
|
||||
}
|
||||
spotlightSection: SpotlightSection { // Only if activeEmergencies.isNotEmpty()
|
||||
items: [emergencyConversationItems...]
|
||||
}
|
||||
sections: [
|
||||
SectionHeader("Channel: {name}"),
|
||||
ConversationItem(name, lastMessage, time, unread) for each conversation
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### ConversationScreen → MessageTemplate / ListTemplate
|
||||
|
||||
```
|
||||
MessageTemplate {
|
||||
// For the selected conversation
|
||||
messages: [MessageItem(text, sender, time) ...]
|
||||
actions: [
|
||||
Action("Reply", voiceIcon) → triggers CAL voice input
|
||||
Action("Quick Reply", listIcon) → shows quick-reply list
|
||||
Action("Read Aloud", speakerIcon) → triggers TTS
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### NodeDashboardScreen → ListTemplate with Expanded Header + Condensed Items
|
||||
|
||||
```
|
||||
ListTemplate {
|
||||
header: ExpandedHeader {
|
||||
title: "Mesh Network"
|
||||
subtitle: "{onlineNodes}/{totalNodes} nodes online"
|
||||
image: meshTopologyIcon
|
||||
}
|
||||
items: [
|
||||
CondensedItem(
|
||||
title: node.longName,
|
||||
subtitle: "Signal: {quality} • Battery: {percent}%",
|
||||
image: signalIcon(quality),
|
||||
onClickListener: → push NodeDetailScreen
|
||||
) for each node, sorted online-first
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### NodeDetailScreen → PaneTemplate
|
||||
|
||||
```
|
||||
PaneTemplate {
|
||||
title: node.longName
|
||||
pane: Pane {
|
||||
rows: [
|
||||
Row("Last Heard", formatTimeAgo(node.lastHeard)),
|
||||
Row("Distance", formatDistance(distanceMeters)),
|
||||
Row("Hardware", node.hwModel.name),
|
||||
Row("Battery", "${node.batteryPercent}%"),
|
||||
Row("Signal", formatSnr(node.snr)),
|
||||
]
|
||||
actions: [
|
||||
Action("Message", messageIcon) → push ConversationScreen for DM
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ~~MapScreen → PlaceListMapTemplate~~ (DEFERRED)
|
||||
|
||||
> Map implementation deferred pending NAVIGATION vs POI category decision. Template contract will be defined when map strategy is resolved.
|
||||
|
||||
### MeshStatusPanel → Minimized Control Panel
|
||||
|
||||
```
|
||||
// Attached to Session, visible across all screens
|
||||
MinimizedControlPanel {
|
||||
icon: connectionStatusIcon
|
||||
title: "{onlineNodeCount} nodes online"
|
||||
subtitle: "Last msg: {timeAgo}"
|
||||
onClickListener: → expand to full detail panel
|
||||
}
|
||||
```
|
||||
|
||||
### Emergency Banner
|
||||
|
||||
```
|
||||
// Triggered by EmergencyHandler when emergency packet received
|
||||
AppManager.showAlert(
|
||||
Alert {
|
||||
title: "⚠️ EMERGENCY"
|
||||
subtitle: "{senderName}: {messagePreview}"
|
||||
icon: emergencyIcon
|
||||
actions: [Action("View", → push emergency detail)]
|
||||
duration: Alert.DURATION_LONG
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Error Contracts
|
||||
|
||||
| Condition | Behavior |
|
||||
|-----------|----------|
|
||||
| BLE disconnected | Banner shown; screens degrade to cached data (read-only) |
|
||||
| No channels configured | Show onboarding PaneTemplate directing to phone app |
|
||||
| No nodes in range | Empty state in NodeDashboard: "No nodes heard" |
|
||||
| No positions available | ~~MapScreen shows empty map~~ (DEFERRED with map feature) |
|
||||
| Template item limit exceeded | Paginate with "Load more" action row |
|
||||
| Voice input fails | Fall back to quick-reply template list |
|
||||
| Session crash | Crashlytics captures with `car_session` tag; session restarts cleanly |
|
||||
@@ -0,0 +1,133 @@
|
||||
# Manifest Declarations Contract
|
||||
|
||||
**Feature**: Car App Library Integration
|
||||
**Date**: 2026-05-21
|
||||
|
||||
## feature/car/src/main/AndroidManifest.xml
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Car App Library service declaration -->
|
||||
<application>
|
||||
<service
|
||||
android:name="org.meshtastic.feature.car.service.MeshtasticCarAppService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.MESSAGING" />
|
||||
<!-- POI or NAVIGATION category deferred pending map strategy decision -->
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Minimum Car API Level for 1.9.0-alpha01 components -->
|
||||
<meta-data
|
||||
android:name="androidx.car.app.minCarApiLevel"
|
||||
android:value="8" />
|
||||
</application>
|
||||
</manifest>
|
||||
```
|
||||
|
||||
## AAOS Support: automotive_app_desc.xml
|
||||
|
||||
Located at `feature/car/src/main/res/xml/automotive_app_desc.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="template" />
|
||||
</automotiveApp>
|
||||
```
|
||||
|
||||
## androidApp Manifest Additions (google flavor only)
|
||||
|
||||
In `androidApp/src/google/AndroidManifest.xml` (or merged automatically via manifest merger):
|
||||
|
||||
```xml
|
||||
<!-- No additional declarations needed — the feature/car manifest merges automatically
|
||||
when the module is included as a dependency in the google flavor -->
|
||||
```
|
||||
|
||||
## Gradle Dependency Declaration
|
||||
|
||||
In `androidApp/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
// Car module (google flavor only - CAL requires Play Services)
|
||||
"googleImplementation"(projects.feature.car)
|
||||
}
|
||||
```
|
||||
|
||||
In `settings.gradle.kts` (new include):
|
||||
|
||||
```kotlin
|
||||
include(":feature:car")
|
||||
```
|
||||
|
||||
## Version Catalog Additions (gradle/libs.versions.toml)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
car-app = "1.9.0-alpha01"
|
||||
|
||||
[libraries]
|
||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
|
||||
androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
|
||||
androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
|
||||
androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
|
||||
```
|
||||
|
||||
## feature/car/build.gradle.kts
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.flavors)
|
||||
alias(libs.plugins.meshtastic.koin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.car"
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 23 // Android Auto projection minimum
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.repository)
|
||||
implementation(projects.core.ble)
|
||||
|
||||
implementation(libs.androidx.car.app)
|
||||
implementation(libs.androidx.car.app.projected)
|
||||
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.annotations)
|
||||
|
||||
implementation(libs.firebase.crashlytics)
|
||||
|
||||
testImplementation(libs.androidx.car.app.testing)
|
||||
testImplementation(libs.koin.test)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
No additional permissions required. The car module:
|
||||
- Does NOT request `BLUETOOTH` permissions (handled by `core/ble` at the app level)
|
||||
- Does NOT request location permissions (handled by existing app permissions)
|
||||
- Does NOT request microphone permissions (CAL voice input is delegated to the system)
|
||||
|
||||
## ProGuard / R8 Rules
|
||||
|
||||
```proguard
|
||||
# Car App Library service must not be obfuscated (resolved by exported service)
|
||||
-keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; }
|
||||
```
|
||||
228
specs/20260521-153452-car-app-library-integration/data-model.md
Normal file
228
specs/20260521-153452-car-app-library-integration/data-model.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Data Model: Car App Library Integration
|
||||
|
||||
**Feature**: Car App Library Integration
|
||||
**Date**: 2026-05-21
|
||||
|
||||
## Overview
|
||||
|
||||
The car module introduces **no new persistent entities**. All data is consumed from existing `core/` repositories. This document defines the **presentation state models** and **UI state containers** used within the car module to bridge repository data to CAL templates.
|
||||
|
||||
## Existing Entities (consumed, not modified)
|
||||
|
||||
### Node (core/model)
|
||||
| Field | Type | Car Usage |
|
||||
|-------|------|-----------|
|
||||
| `num` | `Int` | Unique identifier, key for node DB |
|
||||
| `user.id` | `String` | User ID (e.g., "!1234abcd") |
|
||||
| `user.longName` | `String` | Display name in Condensed Items |
|
||||
| `user.shortName` | `String` | Abbreviated name for compact views |
|
||||
| `user.hwModel` | `HardwareModel` | Shown in node detail |
|
||||
| `position.latitude` | `Double` | Map pin latitude |
|
||||
| `position.longitude` | `Double` | Map pin longitude |
|
||||
| `position.time` | `Int` | Last position update epoch |
|
||||
| `lastHeard` | `Int` | Last communication epoch |
|
||||
| `snr` | `Float` | Signal-to-noise ratio display |
|
||||
| `deviceMetrics.batteryLevel` | `Int?` | Battery indicator |
|
||||
| `isFavorite` | `Boolean` | Priority in node list |
|
||||
|
||||
### DataPacket (core/model)
|
||||
| Field | Type | Car Usage |
|
||||
|-------|------|-----------|
|
||||
| `from` | `String` | Sender identifier |
|
||||
| `to` | `String` | Destination identifier |
|
||||
| `channel` | `Int` | Channel index for grouping |
|
||||
| `bytes` | `ByteArray?` | Message content |
|
||||
| `dataType` | `Int` | Message type classification |
|
||||
| `time` | `Long` | Timestamp for display |
|
||||
| `id` | `Int` | Unique packet ID |
|
||||
| `status` | `MessageStatus` | Delivery status indicator |
|
||||
|
||||
### QuickChatAction (core/database)
|
||||
| Field | Type | Car Usage |
|
||||
|-------|------|-----------|
|
||||
| `uuid` | `Long` | Unique ID |
|
||||
| `name` | `String` | Display label for quick-reply button |
|
||||
| `message` | `String` | Text to send when tapped |
|
||||
| `mode` | `Int` | Instant vs append mode |
|
||||
| `position` | `Int` | Sort order |
|
||||
|
||||
### MyNodeInfo (core/model)
|
||||
| Field | Type | Car Usage |
|
||||
|-------|------|-----------|
|
||||
| `myNodeNum` | `Int` | Our node number |
|
||||
| `firmwareVersion` | `String?` | Display in expanded status panel |
|
||||
| `model` | `String?` | Hardware model display |
|
||||
|
||||
## Presentation State Models (new, car module only)
|
||||
|
||||
### CarSessionState
|
||||
|
||||
Top-level state for a car session lifecycle.
|
||||
|
||||
```kotlin
|
||||
data class CarSessionState(
|
||||
val connectionStatus: ConnectionStatus,
|
||||
val onlineNodeCount: Int,
|
||||
val lastMessageTime: Long?, // epoch millis, null if no messages
|
||||
val activeEmergencies: List<EmergencyAlert>,
|
||||
val meshName: String?,
|
||||
)
|
||||
|
||||
enum class ConnectionStatus {
|
||||
CONNECTED,
|
||||
CONNECTING,
|
||||
DISCONNECTED,
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Derived from `BleConnectionState`, `NodeRepository.onlineNodeCount`, `PacketRepository`
|
||||
|
||||
### MessagingUiState
|
||||
|
||||
State for the messaging screen template builder.
|
||||
|
||||
```kotlin
|
||||
data class MessagingUiState(
|
||||
val channels: List<ChannelUi>,
|
||||
val selectedChannelIndex: Int,
|
||||
val conversations: List<ConversationUi>,
|
||||
val emergencySpotlight: List<EmergencyAlert>?,
|
||||
)
|
||||
|
||||
data class ChannelUi(
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val unreadCount: Int,
|
||||
)
|
||||
|
||||
data class ConversationUi(
|
||||
val contactKey: String,
|
||||
val displayName: String,
|
||||
val lastMessage: String,
|
||||
val lastMessageTime: Long,
|
||||
val unreadCount: Int,
|
||||
val isEmergency: Boolean,
|
||||
)
|
||||
```
|
||||
|
||||
**Source**: `PacketRepository.getContacts()`, `PacketRepository.getUnreadCountFlow()`, channel config from radio
|
||||
|
||||
### NodeDashboardUiState
|
||||
|
||||
State for the node dashboard condensed items grid.
|
||||
|
||||
```kotlin
|
||||
data class NodeDashboardUiState(
|
||||
val nodes: List<NodeUi>,
|
||||
val topologyHeader: TopologyHeader,
|
||||
)
|
||||
|
||||
data class NodeUi(
|
||||
val nodeNum: Int,
|
||||
val longName: String,
|
||||
val shortName: String,
|
||||
val signalQuality: SignalQuality,
|
||||
val batteryPercent: Int?,
|
||||
val isOnline: Boolean,
|
||||
val lastHeard: Long,
|
||||
val hasPosition: Boolean,
|
||||
)
|
||||
|
||||
enum class SignalQuality { EXCELLENT, GOOD, FAIR, POOR, UNKNOWN }
|
||||
|
||||
data class TopologyHeader(
|
||||
val totalNodes: Int,
|
||||
val onlineNodes: Int,
|
||||
val meshName: String?,
|
||||
)
|
||||
```
|
||||
|
||||
**Source**: `NodeRepository.nodeDBbyNum`, `NodeRepository.onlineNodeCount`
|
||||
|
||||
### ~~MapUiState~~ (DEFERRED)
|
||||
|
||||
> Map models deferred pending NAVIGATION vs POI category decision. These models will be defined when map strategy is resolved.
|
||||
|
||||
<!--
|
||||
```kotlin
|
||||
data class MapUiState(
|
||||
val places: List<NodePlace>,
|
||||
val ownPosition: LatLngWrapper?,
|
||||
)
|
||||
|
||||
data class NodePlace(
|
||||
val nodeNum: Int,
|
||||
val name: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val lastUpdateTime: Long,
|
||||
val distanceMeters: Float?, // from own position, null if own position unknown
|
||||
)
|
||||
|
||||
data class LatLngWrapper(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
)
|
||||
```
|
||||
|
||||
**Source**: `NodeRepository.nodeDBbyNum` filtered to nodes with valid positions
|
||||
-->
|
||||
|
||||
### EmergencyAlert
|
||||
|
||||
Model for emergency messages requiring banner treatment.
|
||||
|
||||
```kotlin
|
||||
data class EmergencyAlert(
|
||||
val packetId: Int,
|
||||
val senderName: String,
|
||||
val senderNodeNum: Int,
|
||||
val message: String,
|
||||
val timestamp: Long,
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
val acknowledged: Boolean,
|
||||
)
|
||||
```
|
||||
|
||||
**Source**: `PacketRepository` flow filtered by emergency message type/priority
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Car Session Lifecycle
|
||||
|
||||
```
|
||||
[App Not Visible] → onCreateScreen() → [Active Session]
|
||||
↓ ↓
|
||||
↓ Screens pushed/popped via ScreenManager
|
||||
↓ ↓
|
||||
[App Not Visible] ← onDestroy() ← [Active Session]
|
||||
```
|
||||
|
||||
### Connection Status
|
||||
|
||||
```
|
||||
DISCONNECTED → (BLE scan + connect) → CONNECTING → (handshake complete) → CONNECTED
|
||||
↑ |
|
||||
└──────────────────── (link lost / timeout) ──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Emergency Alert Flow
|
||||
|
||||
```
|
||||
[Message received] → (priority == EMERGENCY?) → YES → Add to activeEmergencies
|
||||
→ Show Banner
|
||||
→ Play notification sound
|
||||
→ NO → Normal message flow
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Enforcement |
|
||||
|------|-------------|
|
||||
| Node name display ≤ 30 chars | Truncated by CAL host automatically |
|
||||
| Message content ≤ 300 chars in list | Truncate with "…"; full on tap/TTS |
|
||||
| Channel name ≤ 12 chars for Chip | Truncated with "…" |
|
||||
| Max 6 conversations visible | CAL template item limit; paginate |
|
||||
| Map pins require valid lat/lng | Filter nodes without position |
|
||||
| Emergency banner requires non-empty message | Skip silent emergency packets |
|
||||
132
specs/20260521-153452-car-app-library-integration/plan.md
Normal file
132
specs/20260521-153452-car-app-library-integration/plan.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Implementation Plan: Car App Library Integration
|
||||
|
||||
**Branch**: `feature/20260521-153452-car-app-library-integration` | **Date**: 2026-05-21 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/20260521-153452-car-app-library-integration/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Integrate Android Car App Library 1.9.0-alpha01 into Meshtastic-Android as a new `feature/car` module, delivering a complete automotive mesh radio interface with 7 screens (messaging, node dashboard, channel management, emergency alerts, map, quick actions, mesh status panel). The module is Android-only, reuses all existing `core/` business logic via Koin DI, and leverages CAL's template-based rendering (no Compose). Voice reply uses CAL's built-in ConversationItem voice input; system-level "Hey Google" commands are handled separately by the AppFunctions feature.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Kotlin 2.3+ targeting JDK 21, Car API Level 8+
|
||||
|
||||
**Primary Dependencies**: `androidx.car.app:app:1.9.0-alpha01`, `androidx.car.app:app-projected:1.9.0-alpha01`, `androidx.car.app:app-automotive:1.9.0-alpha01`, Koin 4.2.1 (Koin Annotations + K2 Plugin), Firebase Crashlytics (BOM 34.13.0)
|
||||
|
||||
**Storage**: Room KMP (existing), DataStore KMP (existing) — no new storage
|
||||
|
||||
**Testing**: `./gradlew :feature:car:testGoogleDebugUnitTest` (Android-only module), `androidx.car.app:app-testing:1.9.0-alpha01` for host simulation, Robolectric for unit tests
|
||||
|
||||
**Target Platform**: Android Auto (projection, API 23+) and AAOS (embedded), Car API Level 8 minimum
|
||||
|
||||
**Project Type**: Mobile app — new Android-only feature module within KMP project
|
||||
|
||||
**Performance Goals**: Message display latency ≤ 3s, emergency banner ≤ 1s, channel switch ≤ 1s, map pin update ≤ 5s
|
||||
|
||||
**Constraints**: ≤ 2 taps for all primary actions, < 10% battery overhead, zero crashes/ANRs in 2-hour sessions, `google` flavor only
|
||||
|
||||
**Scale/Scope**: 7 car screens, ~15-20 new source files, 1 new Gradle module, 0 changes to existing modules' APIs
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **I. Kotlin Multiplatform Core**: ✅ PASS — No `commonMain` changes. All new code resides in `feature/car/src/main/` (Android-only module). Business logic is consumed from existing `core/repository`, `core/data`, `core/domain`, `core/ble` KMP modules via their public interfaces. No new business logic is introduced in the car module — it is purely a presentation layer adapting existing repositories to CAL templates.
|
||||
|
||||
- **II. Zero Lint Tolerance**: ✅ PASS — Will run:
|
||||
- `./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck`
|
||||
- `./gradlew :feature:car:detekt`
|
||||
- Module is Android-only so uses standard detekt tasks (not KMP variants)
|
||||
|
||||
- **III. Compose Multiplatform UI**: ✅ N/A — Car App Library uses its own template-based rendering system, not Compose. No `@Composable` functions are introduced. `MeshtasticNavDisplay` and `NavigationBackHandler` do not apply to CAL's `ScreenManager` navigation. No floats displayed (all text pre-formatted by existing `MetricFormatter`/`NumberFormatter` in core modules).
|
||||
|
||||
- **IV. Privacy First**: ✅ PASS — No new data collection or network calls. Reuses existing repositories with their privacy controls. Location data on map uses existing user-opt-in position sharing. No PII/keys in logs. Crashlytics tagging uses session ID only (no PII). `core/proto` submodule not modified.
|
||||
|
||||
- **V. Design Standards Compliance**: ✅ N/A (justified) — CAL apps use automotive-specific template design language enforced by the Android Auto host, not the Meshtastic Client Design Standards which target phone/desktop Compose UI. The host enforces readability (font sizes, item limits, distraction guidelines). Cross-Platform Spec field is N/A because CAL is Android-only with no cross-platform equivalent. Emergency alert visual treatment follows NHTSA Phase 2 automotive HMI guidelines via CAL Banner APIs.
|
||||
|
||||
- **VI. Verify Before Push**: ✅ Commands recorded:
|
||||
```bash
|
||||
# Local verification
|
||||
./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest
|
||||
|
||||
# Post-push CI check
|
||||
gh pr checks <PR> || gh run list --branch feature/20260521-153452-car-app-library-integration --limit 5
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/20260521-153452-car-app-library-integration/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0: CAL API research, architecture decisions
|
||||
├── data-model.md # Phase 1: Entities and state models
|
||||
├── quickstart.md # Phase 1: Developer onboarding guide
|
||||
├── contracts/ # Phase 1: CAL service contracts and manifest declarations
|
||||
│ ├── car-app-service.md
|
||||
│ └── manifest-declarations.md
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
feature/car/
|
||||
├── build.gradle.kts # Android-only library, google flavor only
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── AndroidManifest.xml # CarAppService declaration, categories
|
||||
│ │ ├── kotlin/org/meshtastic/feature/car/
|
||||
│ │ │ ├── di/
|
||||
│ │ │ │ └── FeatureCarModule.kt # Koin module for car DI
|
||||
│ │ │ ├── service/
|
||||
│ │ │ │ ├── MeshtasticCarAppService.kt # CarAppService entry point
|
||||
│ │ │ │ └── MeshtasticCarSession.kt # Session lifecycle, screen manager
|
||||
│ │ │ ├── screens/
|
||||
│ │ │ │ ├── HomeScreen.kt # Tab-based entry (messaging, nodes)
|
||||
│ │ │ │ ├── MessagingScreen.kt # ConversationItem list, channel chips
|
||||
│ │ │ │ ├── ConversationScreen.kt # Single conversation with voice reply
|
||||
│ │ │ │ ├── NodeDashboardScreen.kt # Condensed Items node grid
|
||||
│ │ │ │ ├── NodeDetailScreen.kt # Expanded node info
|
||||
│ │ │ │ └── ChannelManagementScreen.kt # Channel selection/switching
|
||||
│ │ │ ├── alerts/
|
||||
│ │ │ │ └── EmergencyHandler.kt # Banner management for emergencies
|
||||
│ │ │ ├── panels/
|
||||
│ │ │ │ └── MeshStatusPanel.kt # Minimized Control Panel
|
||||
│ │ │ └── util/
|
||||
│ │ │ ├── CrashlyticsCarTagger.kt # car_session key tagging
|
||||
│ │ │ └── TemplateBuilders.kt # Helper extensions for CAL templates
|
||||
│ │ └── res/
|
||||
│ │ ├── values/
|
||||
│ │ │ └── strings.xml # Car-specific strings
|
||||
│ │ └── xml/
|
||||
│ │ └── automotive_app_desc.xml # AAOS app description
|
||||
│ └── test/
|
||||
│ └── kotlin/org/meshtastic/feature/car/
|
||||
│ ├── service/
|
||||
│ │ └── MeshtasticCarSessionTest.kt
|
||||
│ ├── screens/
|
||||
│ │ ├── MessagingScreenTest.kt
|
||||
│ │ └── NodeDashboardScreenTest.kt
|
||||
│ └── alerts/
|
||||
│ └── EmergencyHandlerTest.kt
|
||||
|
||||
# Existing modules (consumed, NOT modified):
|
||||
core/repository/ # PacketRepository, NodeRepository, QuickChatActionRepository, SendMessageUseCase
|
||||
core/data/ # NodeRepositoryImpl, PacketRepositoryImpl
|
||||
core/ble/ # BleConnection (Application-scoped singleton)
|
||||
core/model/ # Node, DataPacket, MyNodeInfo, etc.
|
||||
core/domain/ # Use cases (SendMessageUseCase, etc.)
|
||||
```
|
||||
|
||||
**Structure Decision**: New `feature/car` module as an Android-only library (not KMP). Follows existing feature module pattern but uses `AndroidLibraryFlavorsConventionPlugin` instead of KMP plugin since CAL has no multiplatform support. Only the `google` flavor includes this module (mirrors Maps/Crashlytics flavor split).
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| III. Compose Multiplatform UI — N/A | CAL uses proprietary template system, not Compose | Cannot render Compose inside automotive templates; CAL enforces distraction-safe UI via templates exclusively |
|
||||
| V. Design Standards — N/A | Automotive design is governed by NHTSA + host-enforced constraints | Meshtastic Design Standards target phone/desktop Compose; applying them to CAL templates would conflict with automotive safety requirements |
|
||||
| Android-only module in KMP project | CAL SDK is Android-exclusive | No KMP equivalent exists; all business logic remains in `commonMain` — only the thin presentation adapter is platform-specific |
|
||||
150
specs/20260521-153452-car-app-library-integration/quickstart.md
Normal file
150
specs/20260521-153452-car-app-library-integration/quickstart.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Quickstart: Car App Library Integration
|
||||
|
||||
**Feature**: Car App Library Integration
|
||||
**Date**: 2026-05-21
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio Ladybug or newer (for CAL preview tools)
|
||||
- JDK 21 (`JAVA_HOME` set)
|
||||
- `ANDROID_HOME` set with API 35+ SDK installed
|
||||
- Proto submodule initialized: `git submodule update --init`
|
||||
- `local.properties` configured: `cp secrets.defaults.properties local.properties`
|
||||
- Android Auto Desktop Head Unit (DHU) installed via SDK Manager → SDK Tools → Android Auto Desktop Head Unit
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Sync and Build
|
||||
|
||||
```bash
|
||||
# Full sync (includes new :feature:car module)
|
||||
./gradlew sync
|
||||
|
||||
# Build google flavor (required — car module is google-only)
|
||||
./gradlew assembleGoogleDebug
|
||||
```
|
||||
|
||||
### 2. Install DHU for Testing
|
||||
|
||||
The Desktop Head Unit simulates Android Auto on your development machine.
|
||||
|
||||
```bash
|
||||
# Install via SDK Manager (or command line)
|
||||
sdkmanager "extras;google;auto"
|
||||
|
||||
# Start DHU (after connecting a device/emulator with the app installed)
|
||||
$ANDROID_HOME/extras/google/auto/desktop-head-unit
|
||||
```
|
||||
|
||||
### 3. Run on Android Auto (Projection Mode)
|
||||
|
||||
1. Install the google debug build on a physical device: `./gradlew installGoogleDebug`
|
||||
2. Enable Developer Mode in Android Auto settings on the phone
|
||||
3. Start the DHU: `desktop-head-unit`
|
||||
4. The Meshtastic car app appears in the DHU's app launcher under "Messaging" category
|
||||
|
||||
### 4. Run on AAOS Emulator
|
||||
|
||||
```bash
|
||||
# Create AAOS emulator (API 33+ automotive system image)
|
||||
avdmanager create avd -n "AAOS_Test" -k "system-images;android-33;google_apis_playstore;x86_64" --device "automotive_1024p_landscape"
|
||||
|
||||
# Start emulator
|
||||
emulator -avd AAOS_Test
|
||||
|
||||
# Install
|
||||
./gradlew installGoogleDebug
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Module Location
|
||||
|
||||
All car-specific code lives in `feature/car/`:
|
||||
|
||||
```
|
||||
feature/car/src/main/kotlin/org/meshtastic/feature/car/
|
||||
├── di/ → Koin DI module
|
||||
├── service/ → CarAppService + Session
|
||||
├── screens/ → CAL Screen implementations
|
||||
├── alerts/ → Emergency banner handler
|
||||
├── panels/ → Minimized Control Panel
|
||||
└── util/ → Helpers (Crashlytics tagger, template builders)
|
||||
```
|
||||
|
||||
### Key Development Patterns
|
||||
|
||||
**Screen implementation**:
|
||||
```kotlin
|
||||
class MessagingScreen(carContext: CarContext) : Screen(carContext) {
|
||||
// Inject repositories via Koin
|
||||
private val packetRepository: PacketRepository by inject()
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
// Build template from current state
|
||||
// Call invalidate() when data changes to trigger re-render
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Data observation** (CAL doesn't use Compose — use coroutine collection):
|
||||
```kotlin
|
||||
// In Screen's lifecycle, collect flows and call invalidate()
|
||||
lifecycleScope.launch {
|
||||
repository.getContacts().collect { contacts ->
|
||||
cachedContacts = contacts
|
||||
invalidate() // Triggers onGetTemplate() re-call
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Template refresh**: CAL screens are invalidated manually — no reactive binding. Call `invalidate()` whenever backing data changes.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Unit tests (uses androidx.car.app:app-testing)
|
||||
./gradlew :feature:car:testGoogleDebugUnitTest
|
||||
|
||||
# Lint + formatting
|
||||
./gradlew :feature:car:spotlessApply :feature:car:spotlessCheck :feature:car:detekt
|
||||
```
|
||||
|
||||
**Test approach**: Use `SessionController` and `TestCarContext` from `app-testing` artifact to simulate host interactions without a real car/DHU.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `messaging screen shows conversations`() {
|
||||
val controller = SessionController(
|
||||
MeshtasticCarSession(testSessionInfo),
|
||||
TestCarContext(ApplicationProvider.getApplicationContext())
|
||||
)
|
||||
// Push screen, assert template content
|
||||
}
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
- **CAL Logcat filter**: `tag:CarApp OR tag:CarService`
|
||||
- **Template errors**: CAL validates templates at runtime — check logcat for `TemplateValidationException`
|
||||
- **Screen stack**: Use `ScreenManager.getTop()` to inspect current screen
|
||||
- **Crashlytics**: Filter by `car_session` custom key in Firebase Console
|
||||
|
||||
## Common Tasks
|
||||
|
||||
| Task | Command / Action |
|
||||
|------|------------------|
|
||||
| Add a new screen | Create `Screen` subclass in `screens/`, register in navigation |
|
||||
| Add a CAL dependency | Update `gradle/libs.versions.toml` + `feature/car/build.gradle.kts` |
|
||||
| Test with DHU | `desktop-head-unit` after installing google debug build |
|
||||
| Check template compliance | Run app on DHU; host validates template constraints |
|
||||
| Filter car crashes | Firebase Console → Crashlytics → Filter: `car_session` is not empty |
|
||||
| Full verification | `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest` |
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **No Compose**: CAL uses its own template-based rendering. Don't mix Compose APIs.
|
||||
- **No `commonMain`**: This is an Android-only module. All code in `src/main/kotlin/`.
|
||||
- **Shared BLE**: Don't create new BLE connections. Inject existing `BleConnection` singleton.
|
||||
- **Koin DI**: All core repositories are already in the graph. Just `inject()` them.
|
||||
- **Flavor**: Only `google` flavor includes this module. Never reference it from `fdroid` code.
|
||||
168
specs/20260521-153452-car-app-library-integration/research.md
Normal file
168
specs/20260521-153452-car-app-library-integration/research.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Research: Car App Library Integration
|
||||
|
||||
**Feature**: Car App Library Integration
|
||||
**Date**: 2026-05-21
|
||||
|
||||
## R1: Car App Library 1.9.0-alpha01 New Components
|
||||
|
||||
**Decision**: Use all 7 new CAL 1.9.0-alpha01 components as specified
|
||||
|
||||
**Rationale**: The alpha release provides modern automotive UI components that directly map to Meshtastic use cases. The user explicitly accepted alpha risk.
|
||||
|
||||
**Components and their application**:
|
||||
|
||||
| CAL Component | Meshtastic Screen | Purpose |
|
||||
|---------------|-------------------|---------|
|
||||
| Spotlight Section | Messaging (emergency) | Emergency messages pinned at top of message list |
|
||||
| Condensed Items | Node Dashboard | Dense node list showing 6+ nodes without scroll |
|
||||
| Chips | Messaging (channels) | Channel switching with unread badges |
|
||||
| Minimized Control Panel | All screens (persistent) | Mesh status: radio connection, node count, last message time |
|
||||
| Banners | Emergency alerts | Full-screen overlay for emergency broadcasts |
|
||||
| Section Headers | Messaging | Group messages by channel within conversation list |
|
||||
| Expanded Header Layout | Node Dashboard | Mesh topology summary at top of node grid |
|
||||
|
||||
**Alternatives considered**:
|
||||
- Wait for stable 1.9.0 release → Rejected: Timeline unknown; alpha APIs are functionally complete
|
||||
- Use legacy ListTemplate/MessageTemplate → Rejected: Misses density benefits (Condensed Items) and visual hierarchy (Spotlight/Headers)
|
||||
|
||||
**API Level requirement**: Car API Level 8 (maps to `minCarApiLevel 8` in manifest). Older hosts gracefully hide the app.
|
||||
|
||||
## R2: Module Architecture — Android-Only vs KMP
|
||||
|
||||
**Decision**: Create `feature/car` as an Android-only library module (not KMP)
|
||||
|
||||
**Rationale**: CAL SDK is exclusively Android. Creating a KMP module with only `androidMain` source sets would add unnecessary complexity (empty `commonMain`, unused KMP plugin overhead). The project already has Android-only modules (`core/api`, `core/barcode`, `androidApp`) as precedent.
|
||||
|
||||
**Build plugin**: `AndroidLibraryFlavorsConventionPlugin` (not `KmpLibraryConventionPlugin`) — ensures proper flavor-aware configuration consistent with existing Android-only modules.
|
||||
|
||||
**Alternatives considered**:
|
||||
- KMP module with `androidMain` only → Rejected: No cross-platform value; KMP plugin adds 2-3s build overhead with zero benefit
|
||||
- Inline within `androidApp` module → Rejected: Violates separation of concerns; feature modules should be independent
|
||||
|
||||
## R3: BLE Connection Sharing Strategy
|
||||
|
||||
**Decision**: Shared Application-scoped `BleConnection` singleton via Koin, no new connection management
|
||||
|
||||
**Rationale**: The existing `BleConnection` in `core/ble` is already scoped to the Application lifecycle via Koin's singleton scope. When Android Auto starts the `CarAppService`, it runs in the same process as the phone app (projection mode) — the Koin graph is shared naturally. The `CarAppService` keeps the process alive via the Android Auto host binding, ensuring the BLE connection persists.
|
||||
|
||||
**Key implementation detail**: `KableBleConnection` is instantiated by `KableBleConnectionFactory` and held as a Koin singleton. The car module simply injects the same instance — no reconnection logic needed.
|
||||
|
||||
**AAOS (embedded) consideration**: On AAOS, the app runs as a standalone process. The same Koin graph initializes in `Application.onCreate()`. BLE connection management is identical because it's Application-scoped regardless of entry point.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Dedicated car BLE connection → Rejected: Would conflict with phone app's connection; BLE to Meshtastic radio is single-link
|
||||
- Service binding to phone app → Rejected: Unnecessary IPC; same process in projection mode; AAOS doesn't have the phone app
|
||||
|
||||
## R4: Crashlytics car_session Tagging
|
||||
|
||||
**Decision**: Tag all Crashlytics events with `car_session` custom key during car session lifecycle
|
||||
|
||||
**Rationale**: Enables filtering car-specific crashes/ANRs in Firebase console without new infrastructure. The `MeshtasticCarSession` sets the key on `onCreateScreen()` and clears on `onDestroy()`.
|
||||
|
||||
**Implementation**:
|
||||
```kotlin
|
||||
// In MeshtasticCarSession.onCreateScreen():
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("car_session", sessionInfo.sessionId.toString())
|
||||
|
||||
// In MeshtasticCarSession lifecycle end:
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("car_session", "")
|
||||
```
|
||||
|
||||
**Alternatives considered**:
|
||||
- Separate Crashlytics instance → Not possible; Firebase is process-wide singleton
|
||||
- DataDog APM → Rejected: Project uses Crashlytics; DataDog not in dependency graph
|
||||
|
||||
## R5: Messaging via ConversationItem + Voice Reply
|
||||
|
||||
**Decision**: Use `ConversationItem` API with CAL's built-in voice input for reply
|
||||
|
||||
**Rationale**: CAL's `ConversationItem` is purpose-built for messaging apps on Android Auto. It handles:
|
||||
- Message display with sender avatar, name, timestamp
|
||||
- Unread indicators
|
||||
- Voice reply flow (tap → record → send) with no custom speech recognition needed
|
||||
- Quick-reply suggestions
|
||||
|
||||
The existing `SendMessageUseCase` in `core/repository` accepts `(text, contactKey, replyId)` — the car module calls this directly after voice transcription completes.
|
||||
|
||||
**Data flow**: `ConversationItem.onReply { text -> sendMessageUseCase(text, contactKey) }`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Custom speech recognition → Rejected: CAL handles this automatically; would duplicate system capabilities
|
||||
- Google Assistant App Actions → Rejected: Separate concern handled by AppFunctions feature
|
||||
|
||||
## R6: Map Template Strategy (UNDER REVIEW)
|
||||
|
||||
**Status**: ⚠️ **Decision deferred** — pending further research on NAVIGATION vs POI implications.
|
||||
|
||||
**Options under consideration**:
|
||||
|
||||
| Option | Template | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| POI | `PlaceListMapTemplate` | Simple, no nav conflicts, static pins | 6-item cap, limited interactivity |
|
||||
| NAVIGATION | `MapWithContentTemplate` | Full map control, live tracking | Exclusive with Google Maps/Waze, stricter review |
|
||||
|
||||
**Previous analysis** (preserved for reference):
|
||||
- POI category avoids NAVIGATION requirements (turn-by-turn guidance, active routing), which would trigger additional Play Store review burden and conflicts with navigation apps
|
||||
- `PlaceListMapTemplate` renders a map with place items (pins) + a scrollable list — suitable for showing node positions
|
||||
- MapWithContentTemplate offers richer UX but requires NAVIGATION category declaration
|
||||
|
||||
**Open questions**:
|
||||
1. Does NAVIGATION category preclude simultaneous Google Maps use on car display?
|
||||
2. Would Google Maps SDK for AAOS (announced I/O 2026) change the calculus?
|
||||
3. Is 6-item cap on PlaceListMapTemplate acceptable for typical mesh networks?
|
||||
|
||||
**Implementation approach**: TBD after decision is made
|
||||
|
||||
## R7: Koin DI Integration for Car Module
|
||||
|
||||
**Decision**: New `FeatureCarModule` using Koin Annotations, registered in app's module graph
|
||||
|
||||
**Rationale**: Consistent with project's DI pattern. All feature modules declare a Koin module that is included by the `androidApp` module graph. The car module's DI graph is simple — it only needs to declare car-specific Screen factories and the EmergencyHandler; all business logic comes from existing core modules.
|
||||
|
||||
**Registration**: `androidApp/src/googleMain/` includes `FeatureCarModule` in the Koin application configuration (google flavor only).
|
||||
|
||||
**Key bindings**:
|
||||
- `MeshtasticCarSession` → factory (new per session)
|
||||
- `EmergencyHandler` → singleton (one per process)
|
||||
- `CrashlyticsCarTagger` → singleton
|
||||
- All repositories, use cases → inherited from existing core modules (already in graph)
|
||||
|
||||
## R8: AppFunctions Interop — Shared Interface Reuse
|
||||
|
||||
**Decision**: Reuse `FuzzyNameResolver` pattern from AppFunctions for node name matching in voice replies
|
||||
|
||||
**Rationale**: When a driver sends a direct message via voice, they may say a node name imprecisely. The AppFunctions feature (in-flight) implements fuzzy node name resolution. While the `AiFunctionProvider` interface is not yet merged, the car module can implement the same fuzzy matching logic directly using `NodeRepository.nodeDBbyNum` and Levenshtein distance or substring matching.
|
||||
|
||||
**Implementation**: Standalone `FuzzyNodeNameResolver` utility class in `feature/car/util/` that queries `NodeRepository` and performs case-insensitive substring + edit-distance matching. If/when AppFunctions lands and exposes a shared resolver in `core/data/commonMain`, the car module can delegate to it.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Wait for AppFunctions to land first → Rejected: Unclear timeline; car module should not block on it
|
||||
- Exact match only → Rejected: Poor voice UX ("node exclamation one two three four" vs "James")
|
||||
|
||||
## R9: Emergency Alert Banner Strategy
|
||||
|
||||
**Decision**: Observe emergency messages via `PacketRepository` Flow, trigger CAL Banner API
|
||||
|
||||
**Rationale**: Emergency messages are already classified in the packet data layer (message type/priority). The `EmergencyHandler` subscribes to the message flow, filters for emergency-priority packets, and immediately invokes `CarToast` + `AppManager.showAlert()` to display a Banner. The Banner overlays any active screen within CAL's rendering pipeline.
|
||||
|
||||
**Audio**: Use `NotificationManager` to play a notification sound on the car's notification audio channel (`AudioAttributes.USAGE_NOTIFICATION`), not media channel (per NFR-008).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Poll for emergencies on timer → Rejected: Violates 1-second latency requirement
|
||||
- Use Android notifications only → Rejected: Would not overlay within CAL UI; needs in-app Banner
|
||||
|
||||
## R10: Build Configuration — Google Flavor Only
|
||||
|
||||
**Decision**: `feature/car` module included only in the `google` product flavor
|
||||
|
||||
**Rationale**: CAL apps require Google Play Services for Android Auto projection. The F-Droid flavor explicitly excludes Google dependencies. The module is conditionally included via flavor-based dependency in `androidApp/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
"googleImplementation"(projects.feature.car)
|
||||
```
|
||||
|
||||
This mirrors existing patterns like Firebase/Maps dependencies being google-flavor-only.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Include in all flavors → Rejected: CAL requires Google Play Services; F-Droid builds would fail
|
||||
- Separate app module for car → Rejected: Adds unnecessary complexity; flavor separation is simpler
|
||||
470
specs/20260521-153452-car-app-library-integration/spec.md
Normal file
470
specs/20260521-153452-car-app-library-integration/spec.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Feature Specification: Car App Library Integration
|
||||
|
||||
**Feature Branch**: `feature/20260521-153452-car-app-library-integration`
|
||||
**Created**: 2026-05-21
|
||||
**Status**: Draft
|
||||
**Input**: Integrate Android Car App Library 1.9.0-alpha01 as a fully-featured, first-class car app
|
||||
**Cross-Platform Spec**: N/A — platform-specific only (Android Auto / AAOS exclusive; CAL has no cross-platform equivalent)
|
||||
|
||||
## Summary
|
||||
|
||||
Integrate the Android Car App Library 1.9.0-alpha01 into Meshtastic-Android to deliver a fully-featured, first-class automotive experience for Android Auto and Android Automotive OS. The integration creates a distraction-optimized, safety-first mesh radio interface for vehicles — enabling drivers to monitor mesh network status, read and reply to messages via voice, view node locations on maps, and receive emergency alerts with immediate prominence. A new `feature/car` module houses the Android-only CAL layer while reusing all shared business logic from existing core and feature modules.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-05-21
|
||||
|
||||
- Q: How should voice commands be implemented — CAL built-in voice input, full Assistant App Actions, or both? → A: CAL built-in voice input only (tap reply → dictate → send). System-level "Hey Google" commands are handled separately by the AppFunctions feature (`specs/20260521-091500-app-functions/`), which exposes `sendMessage`, `getMeshStatus`, `listNodes`, `getRecentMessages`, and `getNodePosition` to Android system AI (Gemini) automatically — including on car displays.
|
||||
- Q: Should the app declare NAVIGATION category for MapWithContentTemplate, or use PlaceListMapTemplate under POI? → A: **DECISION DEFERRED** — originally selected POI/PlaceListMapTemplate but reopened for further research. See US-5 deferral note for open questions on NAVIGATION vs POI implications.
|
||||
- Q: Should the CarAppService maintain an independent BLE connection or share the phone app's existing connection? → A: Shared connection — single Application-scoped BleConnectionManager instance via Koin. CarAppService keeps the process alive via Android Auto host; BLE connection persists at the Service/Application level, not Activity level.
|
||||
- Q: What observability approach should the car module use? → A: Reuse existing Crashlytics with `car_session` custom key tagging for car-specific filtering. No new observability infrastructure; tag existing analytics paths.
|
||||
- Q: Should the car app unlock additional features when the vehicle is parked? → A: No parked-mode differentiation. Templated messaging apps provide a uniform experience regardless of driving state. Voice reply is built into ConversationItem. The Android Auto host enforces its own driving restrictions; the app just provides templates.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Complete automotive mesh experience** — Deliver all seven core screens (messaging, node dashboard, channel management, emergency alerts, map, quick actions, mesh status panel) as a single release
|
||||
2. **Safety-first interaction model** — Every interaction completes in ≤ 2 taps or via voice, meeting automotive distraction guidelines
|
||||
3. **Leverage 1.9.0-alpha01 components** — Showcase Spotlight Sections, Condensed Items, Chips, Minimized Control Panel, Banners, Section Headers, and Expanded Headers for a modern car UI
|
||||
4. **Zero disruption to existing app** — The new `feature/car` module integrates via dependency injection without modifying existing module APIs or behavior
|
||||
5. **Voice-first messaging** — Message composition defaults to voice input, with quick-reply templates as fallback for hands-free operation
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Firmware updates via the car interface (too complex and risky while driving)
|
||||
- Full settings UI in-car (a minimal parked-only subset may be considered in future)
|
||||
- Desktop or iOS car support (this is Android Auto / AAOS specific)
|
||||
- Video playback or media/audio streaming features
|
||||
- Compose UI interop (CAL uses its own template-based rendering system)
|
||||
- Google Assistant App Actions / voice command routing (handled by separate AppFunctions feature)
|
||||
- NAVIGATION category declaration / live map tracking (deferred to v2; v1 uses POI with PlaceListMapTemplate)
|
||||
- Phone app UI changes (car UI is additive only)
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Read and Reply to Mesh Messages While Driving (Priority: P1)
|
||||
|
||||
A driver receives mesh messages from their group while on the road. They glance at the head unit to see new messages and use voice to compose a reply, keeping hands on the wheel and eyes on the road.
|
||||
|
||||
**Why this priority**: Messaging is the primary use case for Meshtastic. Enabling safe in-car messaging addresses the #1 reason users would want car integration.
|
||||
|
||||
**Independent Test**: Can be fully tested by sending a message from a second Meshtastic device, verifying it appears on the car display, and dictating a voice reply that arrives on the sender's device.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the car app is connected to a Meshtastic radio and a new message arrives, **When** the driver views the messaging screen, **Then** the new message appears within 3 seconds with sender name, timestamp, and message content visible at a glance
|
||||
2. **Given** the driver is viewing a conversation, **When** they tap the reply action, **Then** the system presents voice input as the default composition method
|
||||
3. **Given** the driver has initiated voice reply, **When** they speak their message and confirm, **Then** the message is sent to the correct channel/DM within 2 seconds
|
||||
4. **Given** the driver prefers not to use voice, **When** they select quick-reply, **Then** a list of configurable template responses (e.g., "On my way", "Copy that", "10 minutes out") is presented for one-tap selection
|
||||
5. **Given** the mesh radio is disconnected, **When** the driver opens messaging, **Then** a banner clearly indicates offline status and cached messages remain visible as read-only
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Emergency Alert Reception (Priority: P1)
|
||||
|
||||
A driver receives an emergency alert broadcast from a mesh node (SOS, hazard warning, etc.). The alert demands immediate attention with distinct visual and audio treatment, regardless of which screen is currently active.
|
||||
|
||||
**Why this priority**: Emergency alerts are life-safety critical. Failure to surface them prominently could have real-world safety consequences.
|
||||
|
||||
**Independent Test**: Can be tested by triggering an emergency broadcast from a test device and verifying the car app interrupts current activity with a banner alert.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** any screen is active, **When** an emergency message is received, **Then** a high-priority banner appears immediately (within 1 second) with emergency iconography and distinct color treatment
|
||||
2. **Given** an emergency banner is displayed, **When** the driver taps it, **Then** full emergency details are shown including sender identity, location (if available), and timestamp
|
||||
3. **Given** an emergency alert has been received, **When** the driver navigates to the messaging screen, **Then** the emergency message appears in a Spotlight Section at the top, visually distinguished from normal messages
|
||||
4. **Given** emergency audio alerts are enabled, **When** an emergency message arrives, **Then** an audible notification tone plays through the car's audio system
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Monitor Node Network Status (Priority: P2)
|
||||
|
||||
A driver glances at the head unit to check how many mesh nodes are in range, their signal strength, and battery levels — useful for caravan/convoy scenarios or checking if they're still in range of base camp.
|
||||
|
||||
**Why this priority**: Node awareness is the second-most-common Meshtastic use case and provides critical situational awareness for mobile users.
|
||||
|
||||
**Independent Test**: Can be tested by having 3+ nodes in range and verifying the dashboard displays each with correct signal/battery metrics.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the car app is connected with multiple nodes in range, **When** the driver opens the node dashboard, **Then** all known nodes are displayed as Condensed Items showing node name, signal quality indicator, and battery level
|
||||
2. **Given** 6+ nodes are in range, **When** viewing the dashboard, **Then** at least 6 nodes are visible simultaneously without scrolling (leveraging Condensed Items)
|
||||
3. **Given** a node goes offline, **When** the dashboard refreshes, **Then** the offline node is visually distinguished (dimmed or marked) and sorted to the bottom
|
||||
4. **Given** the node list is displayed, **When** the driver taps a node, **Then** a detail view shows last heard time, distance (if location known), hardware model, and direct message option
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Switch Between Channels (Priority: P2)
|
||||
|
||||
A driver participating in multiple mesh channels (e.g., "Convoy", "Emergency", "General") quickly switches between them to view messages from different groups.
|
||||
|
||||
**Why this priority**: Channel management is essential for users in organized groups and must be achievable without complex navigation.
|
||||
|
||||
**Independent Test**: Can be tested by configuring 3+ channels and verifying single-tap channel switching via chips.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the device has multiple channels configured, **When** the messaging screen loads, **Then** channel chips are displayed at the top allowing single-tap switching
|
||||
2. **Given** channel chips are visible, **When** the driver taps a different channel chip, **Then** the message list updates to show that channel's messages within 1 second
|
||||
3. **Given** a channel has unread messages, **When** viewing the chip bar, **Then** that channel's chip displays an unread indicator (badge or visual emphasis)
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - View Node Locations on Map (Priority: DEFERRED)
|
||||
|
||||
> **⚠️ DEFERRED:** Map implementation is deferred pending further research and discussion on whether to pursue POI category (PlaceListMapTemplate, limited but simpler) or NAVIGATION category (MapWithContentTemplate, full-featured but triggers stricter Play Store review and conflicts with active nav apps). This decision has significant architectural and distribution implications that warrant dedicated analysis.
|
||||
|
||||
A driver in a convoy scenario views the locations of all mesh nodes on a map to understand relative positions and navigate toward or away from group members.
|
||||
|
||||
**Why deferred**: The choice between POI (static pins, 6-item cap, no routing conflicts) and NAVIGATION (live tracking, full map control, but exclusive with Google Maps/Waze) fundamentally shapes the UX and distribution strategy. More research needed on:
|
||||
- Google Maps SDK availability for AAOS (announced I/O 2026, timeline unclear)
|
||||
- NAVIGATION category Play Store review requirements and timeline
|
||||
- Whether Meshtastic's convoy use case justifies NAVIGATION exclusivity
|
||||
- User expectations (passive awareness vs. active routing toward nodes)
|
||||
|
||||
**Acceptance Scenarios** (to be finalized after map strategy decision):
|
||||
|
||||
1. **Given** nodes are reporting GPS positions, **When** the driver opens the map screen, **Then** node locations are displayed with correct positions
|
||||
2. **Given** the map is displayed, **When** the driver selects a node, **Then** a detail view shows node name, distance, last update time, and option to send a direct message
|
||||
3. **Given** the driver's own position is available, **When** viewing the map, **Then** their position is shown distinctly from other nodes
|
||||
4. **Given** a node's position updates, **When** the map is visible, **Then** the display updates within 5 seconds
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Persistent Mesh Status at a Glance (Priority: P3)
|
||||
|
||||
While using any car app feature, the driver can glance at a persistent mini-panel showing mesh connectivity health — how many nodes are online, time since last message, and connection status to the radio.
|
||||
|
||||
**Why this priority**: Persistent status awareness reduces the need to navigate between screens, minimizing distraction.
|
||||
|
||||
**Independent Test**: Can be tested by verifying the minimized control panel remains visible across all screens and updates in real-time.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the car app is active on any screen, **When** the driver glances at the minimized control panel, **Then** they see: radio connection status, node count online, and time since last received message
|
||||
2. **Given** the radio disconnects, **When** the status panel updates, **Then** it clearly indicates "Disconnected" with warning iconography
|
||||
3. **Given** the minimized panel is visible, **When** the driver taps it, **Then** it expands to show additional detail (mesh name, own node battery, firmware version)
|
||||
|
||||
---
|
||||
|
||||
### User Story 7 - In-Context Voice Input for Actions (Priority: P3)
|
||||
|
||||
A driver uses CAL's built-in voice input to compose messages and perform actions without typing — tapping reply then dictating, or using TTS readback of messages. System-level voice commands ("Hey Google, send Meshtastic message to John") are handled separately by the AppFunctions feature and work automatically on car displays without car module code.
|
||||
|
||||
**Why this priority**: Voice is the safest interaction modality while driving and rounds out the hands-free experience.
|
||||
|
||||
**Independent Test**: Can be tested by tapping the reply action, dictating a message via CAL voice input, and verifying delivery. System-level "Hey Google" commands are tested via the AppFunctions spec.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the car app is on a conversation screen, **When** the driver taps the reply action and speaks a message, **Then** voice composition targets that node/channel using CAL's built-in voice input API
|
||||
2. **Given** a message is displayed, **When** the driver taps a "read aloud" action, **Then** the message is read via TTS including sender name and content
|
||||
3. **Given** the driver initiates a direct message from the node dashboard, **When** they tap a node and select "message", **Then** voice input is presented as the default composition method with `FuzzyNameResolver` used for node name matching
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the Bluetooth connection to the Meshtastic radio drops mid-conversation? → Banner notification + graceful degradation to cached data, auto-reconnect in background
|
||||
- What happens when the message list exceeds CAL template item limits? → Cap at 10 conversations with 5 messages each per Android Auto best practices; most recent first
|
||||
- How does the system handle very long messages that exceed car display constraints? → Truncation with "..." and full message available on tap or read-aloud
|
||||
- What happens when outgoing messages exceed 237 bytes (Meshtastic protocol limit)? → Reject with user feedback ("Message too long"); do not attempt to send
|
||||
- What happens when the car's system restricts interaction (e.g., car moving at speed)? → No parked-mode differentiation; the templated messaging UI is uniform regardless of driving state. Voice reply is built into ConversationItem automatically. The Android Auto host enforces its own driving restrictions — the app provides templates only.
|
||||
- What happens when multiple emergency alerts arrive simultaneously? → Stack as multiple banners; Spotlight Section shows all active emergencies chronologically
|
||||
- How does the app handle no configured channels? → Show onboarding prompt directing user to configure channels on their phone first
|
||||
- What happens with emoji-only or admin messages? → Filtered from car display entirely (not shown in conversation list or read aloud)
|
||||
- What happens on initial session connect with existing unread messages? → Batch-load up to 50 unread messages across conversations; also post MessagingStyle notifications for read-back support
|
||||
- How are favorites vs recent contacts distinguished? → Favorites (node.favorite == true) grouped at top of DM list with Section Header; remaining contacts sorted by last-heard, capped at 24
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST register as a Car App Service discoverable by Android Auto and AAOS hosts
|
||||
- **FR-002**: System MUST display incoming mesh messages in a scrollable list grouped by channel using Section Headers
|
||||
- **FR-003**: System MUST support voice-based message composition as the primary reply method
|
||||
- **FR-004**: System MUST provide quick-reply templates selectable with a single tap
|
||||
- **FR-005**: System MUST display emergency messages as high-priority Banners that overlay any active screen within 1 second of receipt
|
||||
- **FR-006**: System MUST present emergency messages in a Spotlight Section when viewing the messaging screen
|
||||
- **FR-007**: System MUST display all known mesh nodes as Condensed Items showing name, signal quality, and battery level
|
||||
- **FR-008**: System MUST support channel switching via Chips displayed at the top of the messaging screen
|
||||
- **FR-009**: ~~DEFERRED~~ — Map implementation deferred pending NAVIGATION vs POI category decision. See User Story 5.
|
||||
- **FR-010**: System MUST maintain a persistent Minimized Control Panel showing radio status, online node count, and last message time
|
||||
- **FR-011**: System MUST display a Banner when the Bluetooth connection to the radio is lost
|
||||
- **FR-012**: System MUST support expanding node details on tap (last heard, distance, hardware model)
|
||||
- **FR-013**: System MUST use Expanded Header Layout for the node dashboard showing mesh topology summary
|
||||
- **FR-014**: System MUST declare MESSAGING as the primary category. POI or NAVIGATION as secondary category is deferred pending map strategy decision.
|
||||
- **FR-015**: System MUST gracefully degrade to cached/read-only data when the mesh radio is disconnected
|
||||
- **FR-016**: System MUST support unread message indicators on channel Chips
|
||||
- **FR-017**: System MUST filter emoji-only and admin messages from the car display (only text messages shown)
|
||||
- **FR-018**: System MUST reject outgoing messages exceeding 237 bytes (Meshtastic packet limit) with user-visible feedback
|
||||
- **FR-019**: System MUST display at most 10 conversations and at most 5 messages per ConversationItem, per Android Auto best practices
|
||||
- **FR-020**: System MUST group direct message contacts into "Favorites" (nodes marked favorite) and "Recent" sections using Section Headers
|
||||
- **FR-021**: System MUST load up to 50 unread messages across conversations on session start, most recent first
|
||||
- **FR-022**: System MUST also implement notification-based messaging (NotificationCompat.MessagingStyle with reply and mark-as-read Actions) as required by templated messaging apps
|
||||
- **FR-023**: System MUST display transient CarToast feedback for user actions (message sent, message failed, reconnection events)
|
||||
- **FR-024**: System MUST support pull-to-refresh (OnContentRefreshListener) on message and node list screens
|
||||
- **FR-025**: System MUST present emergency alerts as modal Alert dialogs (CAL Alert API) requiring explicit acknowledgment
|
||||
- **FR-026**: System SHOULD use LongMessageTemplate for viewing full conversation history beyond the 5-message list limit
|
||||
- **FR-027**: System SHOULD provide responsive text variants (CarText.addVariant) for narrow vs wide head unit displays
|
||||
- **FR-028**: System SHOULD restrict message composition actions to parked state via ParkedOnlyOnClickListener
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001**: All interactive elements MUST be reachable within 2 taps from any screen
|
||||
- **NFR-002**: New message display latency MUST be ≤ 3 seconds from radio receipt to screen render
|
||||
- **NFR-003**: Car app battery overhead MUST be < 10% additional drain compared to the phone app running alone
|
||||
- **NFR-004**: Car App minimum API level MUST be Car API Level 8 (required for 1.9.0 components)
|
||||
- **NFR-005**: The car module MUST NOT introduce dependencies that affect the phone app's build time by more than 5%
|
||||
- **NFR-006**: All text elements MUST meet automotive readability guidelines (minimum font sizes per OEM requirements)
|
||||
- **NFR-007**: The app MUST support both Android Auto (projection) and AAOS (embedded) deployment modes
|
||||
- **NFR-008**: Emergency alert audio MUST play through the car's notification channel, not media channel
|
||||
- **NFR-009**: Car module MUST tag all Crashlytics events with a `car_session` custom key (value: session ID) to enable car-specific crash/ANR filtering and diagnosis
|
||||
- **NFR-010**: Screen invalidation MUST be debounced (≥300ms) and MUST NOT recreate Screen objects; use `invalidate()` to trigger `onGetTemplate()` re-evaluation, matching CarPlay's proven refresh pattern
|
||||
- **NFR-011**: Template data refresh latency MUST be ≤500ms from invalidation trigger to rendered update
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
| Component | Module / File | Purpose |
|
||||
|-----------|---------------|---------|
|
||||
| MeshtasticCarAppService | `feature/car/service/` | CAL Session host, entry point for Android Auto/AAOS |
|
||||
| MessagingScreen | `feature/car/screens/` | Message list with channel chips, voice reply, quick-reply |
|
||||
| NodeDashboardScreen | `feature/car/screens/` | Condensed Items grid of all mesh nodes |
|
||||
| ~~MapScreen~~ | ~~`feature/car/screens/`~~ | ~~PlaceListMapTemplate showing node positions~~ — **DEFERRED** |
|
||||
| EmergencyHandler | `feature/car/alerts/` | Banner management for emergency messages |
|
||||
| MeshStatusPanel | `feature/car/panels/` | Minimized Control Panel with mesh health |
|
||||
| CarMessageRepository | `core/data/` | Existing message repository (reused) |
|
||||
| CarNodeRepository | `core/data/` | Existing node repository (reused) |
|
||||
| ChannelManager | `core/domain/` | Existing channel logic (reused) |
|
||||
| BleConnectionManager | `core/ble/` | Existing BLE connection (reused; Application-scoped singleton shared with phone app — CarAppService keeps process alive via host) |
|
||||
|
||||
### Component Interaction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Android Auto / AAOS Host │
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ CAL Session
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ MeshtasticCarAppService │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│
|
||||
│ │Messaging │ │ Nodes │ │ Map Screen ││
|
||||
│ │ Screen │ │Dashboard │ │ ││
|
||||
│ └────┬─────┘ └────┬─────┘ └───────┬──────────┘│
|
||||
│ │ │ │ │
|
||||
│ ┌────▼─────────────▼───────────────▼──────────┐│
|
||||
│ │ MeshStatusPanel (persistent) ││
|
||||
│ └─────────────────────────────────────────────┘│
|
||||
│ ┌─────────────────────────────────────────────┐│
|
||||
│ │ EmergencyHandler (banners) ││
|
||||
│ └─────────────────────────────────────────────┘│
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ Koin DI
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ Shared Business Logic (core/) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌───────┐ │
|
||||
│ │Messages │ │ Nodes │ │Channels│ │ BLE │ │
|
||||
│ │ Repo │ │ Repo │ │Manager │ │Connect│ │
|
||||
│ └─────────┘ └─────────┘ └────────┘ └───────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Source-Set Impact
|
||||
|
||||
| Source Set | Impact | Justification |
|
||||
|-----------|--------|---------------|
|
||||
| `commonMain` | No changes | All shared business logic already exists in core modules |
|
||||
| `androidMain` | New `feature/car` module | CAL is Android-only; entire car UI layer is platform-specific |
|
||||
|
||||
## Design Standards Compliance
|
||||
|
||||
- [ ] New screens reviewed against automotive HMI distraction guidelines (NHTSA Phase 2)
|
||||
- [ ] CAL template system used exclusively (no custom rendering that bypasses automotive safety checks)
|
||||
- [ ] Accessibility: Voice readback of all visual information, high-contrast automotive color schemes
|
||||
- [ ] Typography: Uses CAL's built-in automotive-safe text sizing (enforced by host)
|
||||
- [ ] Emergency alerts use distinct visual language (color, iconography) distinguishable from informational banners
|
||||
|
||||
## Privacy Assessment
|
||||
|
||||
- [ ] No PII, location data, or cryptographic keys logged or exposed beyond what existing modules already handle
|
||||
- [ ] Car app reuses existing data layer — no new network calls or data collection
|
||||
- [ ] Node location data displayed on map uses existing privacy controls (user opt-in for position sharing)
|
||||
- [ ] No data sent to third-party automotive services
|
||||
- [ ] Proto submodule (`core/proto`) not modified (read-only upstream)
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can read a new message and send a voice reply in under 15 seconds total interaction time
|
||||
- **SC-002**: Emergency alerts are visible to the driver within 1 second of receipt by the radio
|
||||
- **SC-003**: Node dashboard displays 6+ nodes simultaneously without scrolling (Condensed Items density)
|
||||
- **SC-004**: All primary actions (read message, reply, check nodes, view map) reachable within 2 taps from home
|
||||
- **SC-005**: Car app adds < 10% battery drain overhead compared to phone-only operation over a 1-hour driving session
|
||||
- **SC-006**: Channel switching completes (chip tap to new message list rendered) within 1 second
|
||||
- **SC-007**: App passes Android Auto App Quality review criteria for the MESSAGING category
|
||||
- **SC-008**: 95% of voice-initiated replies complete successfully without fallback to touch input
|
||||
- **SC-009**: ~~DEFERRED~~ — Map latency criterion deferred with map implementation
|
||||
- **SC-010**: Zero crashes or ANRs attributed to the car module during a 2-hour continuous driving session
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Car App Library 1.9.0-alpha01 APIs are sufficiently stable for production use (alpha risk accepted per user directive)
|
||||
- The existing `core/data` repositories provide all necessary data access; no new data sources required
|
||||
- Meshtastic radio remains paired and connected via BLE during driving (standard operating mode)
|
||||
- BLE connection is Application-scoped (not Activity-scoped); CarAppService keeps the host process alive so the connection naturally persists regardless of phone app Activity state
|
||||
- Users have already configured channels and node settings via the phone app before driving
|
||||
- Android Auto host enforces its own distraction-optimization rules (template item limits, interaction restrictions); the app respects these constraints
|
||||
- The `google` build flavor is the distribution target; F-Droid/GitHub flavors do not include car support
|
||||
- Quick-reply templates are configurable via the phone app's settings; the car app consumes them read-only
|
||||
- Voice input quality depends on the car's microphone hardware; the app delegates to Android's speech recognition system
|
||||
- Map template strategy (POI vs NAVIGATION category) is deferred; no map screen in initial implementation
|
||||
- Minimum Car API Level 8 is required; older Android Auto hosts will not show the app (graceful absence, not crash)
|
||||
- Koin dependency injection is used consistently with Koin Annotations for the new module
|
||||
- TTS (text-to-speech) for reading messages aloud uses Android's built-in TTS engine
|
||||
|
||||
## External References & Research
|
||||
|
||||
### Official Documentation
|
||||
|
||||
| Resource | URL | Relevance |
|
||||
|----------|-----|-----------|
|
||||
| Car App Library Release Notes | https://developer.android.com/jetpack/androidx/releases/car-app | 1.8.0-beta01 & 1.9.0-alpha01 component APIs |
|
||||
| Building Car Apps (Training) | https://developer.android.com/training/cars/apps | CarAppService setup, templates, lifecycle |
|
||||
| Templated Messaging Guide | https://developer.android.com/training/cars/communication/templated-messaging | ConversationItem, voice reply, notification integration |
|
||||
| Notification-based Messaging | https://developer.android.com/training/cars/messaging | MessagingStyle, reply/mark-as-read Actions |
|
||||
| Android Auto Add Support | https://developer.android.com/training/cars/apps/auto | Manifest, automotive_app_desc.xml, projection |
|
||||
| Component Design Guidance | https://developer.android.com/design/ui/cars/guides/components/overview | Automotive HMI patterns |
|
||||
| Car App Quality Guidelines | https://developer.android.com/docs/quality-guidelines/car-app-quality | Review criteria for MESSAGING category |
|
||||
| Testing with DHU | https://developer.android.com/training/cars/testing | Desktop Head Unit setup and usage |
|
||||
|
||||
### Google I/O 2026 Announcements
|
||||
|
||||
| Resource | URL | Key Takeaways |
|
||||
|----------|-----|---------------|
|
||||
| Android for Cars: Unifying Platforms | https://android-developers.googleblog.com/2026/05/android-for-cars-unifying-platforms-premium-experiences.html | CAL 1.8.0 media templates, CAL 1.9.0 components, Material 3 Expressive, video support |
|
||||
|
||||
### Key API Patterns from Official Docs
|
||||
|
||||
#### Templated Messaging (from official guidance)
|
||||
|
||||
- **ConversationItem** auto-provides voice reply + mark-as-read actions
|
||||
- Max **5–10 conversations**, each with ≤ **5 messages**
|
||||
- Refresh cadence: ≤ **500ms** per invalidation
|
||||
- Must also implement **notification-based messaging** (MessagingStyle) as fallback
|
||||
- Distribution: Currently **internal + closed testing** tracks only (production opening later)
|
||||
|
||||
#### Manifest Requirements
|
||||
|
||||
```xml
|
||||
<!-- automotive_app_desc.xml for templated messaging -->
|
||||
<automotiveApp>
|
||||
<uses name="notification" />
|
||||
<uses name="template" />
|
||||
</automotiveApp>
|
||||
|
||||
<!-- CarAppService intent filter -->
|
||||
<service android:name=".MeshtasticCarAppService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.car.app.CarAppService" />
|
||||
<category android:name="androidx.car.app.category.MESSAGING" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Minimum Car API Level -->
|
||||
<meta-data android:name="androidx.car.app.minCarApiLevel" android:value="8" />
|
||||
```
|
||||
|
||||
#### ConversationItem Pattern (from official sample)
|
||||
|
||||
```kotlin
|
||||
ConversationItem.Builder()
|
||||
.setConversationCallback(callback)
|
||||
.setId(conversation.id)
|
||||
.setTitle(conversation.title)
|
||||
.setIcon(conversation.icon)
|
||||
.setMessages(carMessages)
|
||||
.setSelf(selfPerson)
|
||||
.setGroupConversation(conversation.isGroup)
|
||||
.build()
|
||||
```
|
||||
|
||||
### Related In-Flight Features
|
||||
|
||||
| Feature | Branch | Spec | Relationship |
|
||||
|---------|--------|------|-------------|
|
||||
| App Functions | `jamesarich/crispy-barnacle` | `specs/20260521-091500-app-functions/` | Provides "Hey Google" system AI integration for sendMessage, getMeshStatus, listNodes, getRecentMessages, getNodePosition — complementary to CAL voice input |
|
||||
|
||||
#### Shared Infrastructure from AppFunctions
|
||||
|
||||
- **`AiFunctionProvider`** interface in `core/data/commonMain` — platform-agnostic contract for AI-driven operations
|
||||
- **`FuzzyNameResolver`** in `core/data/commonMain` — LCS-based node/channel name matching (50% threshold)
|
||||
- **`RateLimiter`** in `core/data/commonMain` — sliding window rate limiter (5 calls/60s) for mesh airtime protection
|
||||
- **Architecture pattern:** Thin Android wrappers (`androidApp/src/google/`) calling shared business logic
|
||||
|
||||
#### Integration Points
|
||||
|
||||
- Car module reuses `FuzzyNameResolver` for voice reply targeting (e.g., "reply to James" → resolve to node)
|
||||
- `RateLimiter` can protect car-originated sends from exceeding mesh airtime
|
||||
- AppFunctions "Hey Google" commands work on car displays automatically (system-level, no car module code needed)
|
||||
- Both features share: `NodeRepository`, `CommandSender`, `RadioConfigRepository`, `PacketRepository`
|
||||
|
||||
### CAL 1.9.0-alpha01 Component Reference
|
||||
|
||||
| Component | API Class | Min Car API | Use in Meshtastic |
|
||||
|-----------|-----------|-------------|-------------------|
|
||||
| Spotlight Section | `SpotlightSection.Builder()` | 8 | Emergency messages pinned at top |
|
||||
| Condensed Items | `CondensedItem.Builder()` | 8 | Dense node list (6+ visible) |
|
||||
| Chips | `Chip.Builder()` | 8 | Channel switching + unread badges |
|
||||
| Minimized Control Panel | `SectionedItemTemplate` | 8 | Persistent mesh status strip |
|
||||
| Banners | `Banner.Builder()` | 8 | Emergency overlay + disconnection alerts |
|
||||
| Section Headers | `SectionHeader.Builder()` | 8 | Message grouping by channel |
|
||||
| Expanded Header Layout | `Header.Builder()` | 8 | Mesh topology summary (node dashboard) |
|
||||
|
||||
### Distribution Constraints (as of May 2026)
|
||||
|
||||
- **Templated messaging apps:** Internal + closed testing tracks only on Play Store
|
||||
- **Production track:** Not yet open for templated messaging category
|
||||
- **AAOS:** Separate distribution channel (OEM app stores or Play for Automotive)
|
||||
- **F-Droid:** Excluded (CAL requires Google Play Services)
|
||||
- **Timeline:** Production track expected to open "later" per Google (no firm date)
|
||||
|
||||
### Cross-Platform Parity: Meshtastic-Apple CarPlay
|
||||
|
||||
**Source:** `Meshtastic-Apple/Meshtastic/CarPlay/` (main branch, May 21, 2026)
|
||||
|
||||
**Apple CarPlay features (shipped):**
|
||||
- Two-tab UI: Channels + Direct Messages (with Favorites/Recent sections)
|
||||
- SiriKit voice compose/read-back via `INSendMessageIntent`
|
||||
- Unread badges per channel and per DM
|
||||
- "Not Connected" graceful degradation
|
||||
- Live Activity (Dynamic Island) with node telemetry stats
|
||||
- Batch donation of 50 unread messages on session start
|
||||
- 300ms debounced refresh (updateSections, not rebuild)
|
||||
- Message search via `INSearchForMessagesIntent`
|
||||
- Message filtering: no emoji-only, no admin messages
|
||||
- 200-byte message limit enforcement
|
||||
|
||||
**Parity decisions incorporated into this spec:**
|
||||
- FR-017: Message filtering (emoji/admin exclusion) — matches Apple
|
||||
- FR-018: Message size limit enforcement — matches Apple (237 bytes for Meshtastic)
|
||||
- FR-019: Conversation caps (10 convos, 5 msgs each) — per Android guidance
|
||||
- FR-020: Favorites section grouping — matches Apple's Favorites/Recent pattern
|
||||
- FR-021: Session start unread batch load — matches Apple's 50-message donation
|
||||
- FR-022: Notification-based messaging fallback — required per Android templated messaging docs
|
||||
- NFR-010: Refresh debouncing (≥300ms) — matches Apple's proven 300ms debounce
|
||||
- NFR-011: Refresh latency (≤500ms) — matches Apple's observed performance
|
||||
|
||||
**Android-exclusive features (exceeding Apple):**
|
||||
- Node dashboard with Condensed Items (Apple has no node visibility)
|
||||
- Emergency Banner overlays with audio alerts (Apple shows emergencies as regular messages)
|
||||
- ~~Map integration~~ (DEFERRED pending NAVIGATION vs POI decision)
|
||||
- Channel Chips for instant switching (Apple requires tab navigation)
|
||||
- Quick-reply templates (Apple only offers Siri voice)
|
||||
- Visual hierarchy via Spotlight/Section Headers/Expanded Headers
|
||||
- Persistent Minimized Control Panel (Apple uses separate Live Activity)
|
||||
|
||||
**Deferred to v2 (Apple has, we don't yet):**
|
||||
- Message search (SearchTemplate or via AppFunctions)
|
||||
- Live Activity equivalent (Android ongoing notification with mesh telemetry)
|
||||
286
specs/20260521-153452-car-app-library-integration/tasks.md
Normal file
286
specs/20260521-153452-car-app-library-integration/tasks.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Tasks: Car App Library Integration
|
||||
|
||||
**Input**: Design documents from `/specs/20260521-153452-car-app-library-integration/`
|
||||
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
|
||||
|
||||
**Tests**: Not explicitly requested in spec. Test tasks omitted per template rules.
|
||||
|
||||
**Verification**: Constitution-required validation (spotlessCheck, detekt, compile/test) included in final phase.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Project Initialization)
|
||||
|
||||
**Purpose**: Create the `feature/car` module structure, Gradle configuration, and version catalog entries
|
||||
|
||||
- [x] T001 Add Car App Library version catalog entries in gradle/libs.versions.toml (car-app version, 4 library entries)
|
||||
- [x] T002 Add `include(":feature:car")` to settings.gradle.kts
|
||||
- [x] T003 Create module build file at feature/car/build.gradle.kts with android-library, flavors, koin plugins, and all dependencies per contracts/manifest-declarations.md
|
||||
- [x] T004 [P] Add `"googleImplementation"(projects.feature.car)` dependency in androidApp/build.gradle.kts
|
||||
- [x] T005 [P] Create AndroidManifest.xml at feature/car/src/main/AndroidManifest.xml with CarAppService, MESSAGING category, and minCarApiLevel 8 meta-data
|
||||
- [x] T006 [P] Create AAOS descriptor at feature/car/src/main/res/xml/automotive_app_desc.xml
|
||||
- [x] T007 [P] Create car-specific strings file at feature/car/src/main/res/values/strings.xml with initial string resources
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that ALL user stories depend on — service entry point, session lifecycle, DI, utilities
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T008 Create Koin DI module at feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt declaring MeshtasticCarSession (factory), EmergencyHandler (singleton), CrashlyticsCarTagger (singleton)
|
||||
- [x] T009 Register FeatureCarModule in androidApp google flavor Koin configuration (androidApp/src/google/ Koin app module graph)
|
||||
- [x] T010 [P] Create CrashlyticsCarTagger utility at feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt implementing car_session custom key set/clear
|
||||
- [x] T011 [P] Create TemplateBuilders helper extensions at feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt with reusable CAL template construction helpers
|
||||
- [x] T012 Create MeshtasticCarAppService at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt extending CarAppService, creating sessions via Koin
|
||||
- [x] T013 Create MeshtasticCarSession at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt with onCreateScreen (returns HomeScreen), onNewIntent, onCarConfigurationChanged, Crashlytics tagging, 300ms invalidation debouncing
|
||||
- [x] T014 Create presentation state models (CarSessionState, ConnectionStatus, MessagingUiState, ChannelUi, ConversationUi, NodeDashboardUiState, NodeUi, SignalQuality, TopologyHeader, EmergencyAlert) at feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
|
||||
- [x] T015 Create HomeScreen (TabTemplate with Messages/Nodes tabs; Map tab placeholder deferred) at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
|
||||
|
||||
**Checkpoint**: Foundation ready — CarAppService binds, session creates, HomeScreen renders tabs. User story implementation can now begin in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Read and Reply to Mesh Messages While Driving (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Drivers can view incoming mesh messages grouped by channel and reply via voice or quick-reply templates
|
||||
|
||||
**Independent Test**: Send a message from a second Meshtastic device → appears on car display within 3s → dictate voice reply → arrives on sender's device
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T016 [P] [US1] Create MessagingScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt with ListTemplate, channel Chips header, Section Headers grouping conversations, ConversationItem list (max 10), 300ms debounced invalidation, favorites/recent DM grouping
|
||||
- [x] T017 [P] [US1] Create ConversationScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt with MessageTemplate showing messages (max 5 per conversation), voice reply action via CAL built-in ConversationItem voice input, quick-reply action list from QuickChatActionRepository, read-aloud TTS action
|
||||
- [x] T018 [US1] Reuse `FuzzyNameResolver` from `core/data/commonMain` (shared with AppFunctions feature) for voice-initiated DM node name matching — inject via Koin from existing `core/data` module. If AppFunctions branch not yet merged, temporarily duplicate LCS algorithm in feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt with TODO to consolidate post-merge
|
||||
- [x] T019 [US1] Implement message filtering logic in MessagingScreen — exclude emoji-only and admin messages from display (FR-017), enforce 237-byte outgoing limit with user feedback (FR-018)
|
||||
- [x] T020 [US1] Implement session-start batch loading of up to 50 unread messages in MeshtasticCarSession (FR-021) and post MessagingStyle notifications for read-back support
|
||||
- [x] T021 [US1] Implement notification-based messaging (NotificationCompat.MessagingStyle with reply and mark-as-read Actions) at feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt (FR-022)
|
||||
|
||||
**Checkpoint**: Messaging fully functional — driver can see messages, switch channels, voice reply, use quick-reply templates, and receive MessagingStyle notifications
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Emergency Alert Reception (Priority: P1)
|
||||
|
||||
**Goal**: Emergency broadcasts immediately surface as prominent banners with audio alerts regardless of active screen
|
||||
|
||||
**Independent Test**: Trigger emergency broadcast from test device → banner appears within 1s → audio alert plays → tap shows full details in Spotlight Section
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T022 [P] [US2] Create EmergencyHandler at feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt observing PacketRepository flow for emergency-priority packets, triggering Banner via AppManager.showAlert(), managing active emergency list, stacking multiple banners chronologically
|
||||
- [x] T023 [US2] Implement emergency audio alert playback in EmergencyHandler using NotificationManager on USAGE_NOTIFICATION audio channel (NFR-008), not media channel
|
||||
- [x] T024 [US2] Integrate Spotlight Section in MessagingScreen for active emergencies — display EmergencyAlert items at top of messaging list when activeEmergencies is non-empty (FR-006). **Depends on T016 (MessagingScreen must exist first)**
|
||||
- [x] T025 [US2] Wire EmergencyHandler into MeshtasticCarSession lifecycle — start collecting on onCreateScreen, stop on session destroy
|
||||
|
||||
**Checkpoint**: Emergency alerts fully operational — banners overlay any screen within 1s, audio plays, Spotlight Section shows in messaging view
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Monitor Node Network Status (Priority: P2)
|
||||
|
||||
**Goal**: Driver views all mesh nodes as a dense Condensed Items grid with signal/battery metrics and topology header
|
||||
|
||||
**Independent Test**: Have 3+ nodes in range → open node dashboard → all nodes displayed with correct signal/battery → tap node → detail view shows full info
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T026 [P] [US3] Create NodeDashboardScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt with ListTemplate, Expanded Header Layout (mesh topology summary: online/total), Condensed Items for each node (name, signal quality, battery), online-first sorting with offline dimmed at bottom
|
||||
- [x] T027 [P] [US3] Create NodeDetailScreen at feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt with PaneTemplate showing last heard, distance, hardware model, battery, SNR, and "Message" action to push ConversationScreen for DM
|
||||
|
||||
**Checkpoint**: Node dashboard shows 6+ nodes without scrolling via Condensed Items, detail drill-down works, DM action connects to messaging
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Switch Between Channels (Priority: P2)
|
||||
|
||||
**Goal**: Single-tap channel switching via Chips with unread badges at the top of the messaging screen
|
||||
|
||||
**Independent Test**: Configure 3+ channels → messaging screen shows channel chips → tap chip → message list updates within 1s → unread badge visible on channels with new messages
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T028 [US4] Implement channel Chip actions with unread badge indicators in MessagingScreen header — single-tap switches selectedChannelIndex, triggers message list re-filter within 1s (FR-008, FR-016)
|
||||
|
||||
**Checkpoint**: Channel chips render with unread counts, tapping switches view to that channel's conversations immediately
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — View Node Locations on Map (DEFERRED)
|
||||
|
||||
> **⚠️ DEFERRED:** Map implementation deferred pending NAVIGATION vs POI category decision. The choice between PlaceListMapTemplate (POI, 6-item cap, no nav conflicts) and MapWithContentTemplate (NAVIGATION, full-featured, exclusive with Google Maps) requires further research and discussion. See spec User Story 5 for open questions.
|
||||
|
||||
~~- [ ] T029 [US5] Create MapScreen~~
|
||||
|
||||
**Checkpoint**: SKIPPED — revisit after map strategy decision
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 6 — Persistent Mesh Status at a Glance (Priority: P3)
|
||||
|
||||
**Goal**: Minimized Control Panel visible across all screens showing radio status, node count, last message time
|
||||
|
||||
**Independent Test**: Navigate between all screens → mini-panel always visible → shows correct node count → disconnect radio → panel shows "Disconnected"
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [x] T030 [US6] Create MeshStatusPanel at feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt implementing Minimized Control Panel — connectionStatusIcon, "{N} nodes online" title, "Last msg: {timeAgo}" subtitle, onClickListener expanding to full detail (mesh name, own battery, firmware version)
|
||||
- [x] T031 [US6] Register MeshStatusPanel in MeshtasticCarSession lifecycle — attach to session on creation, observe BleConnectionState + NodeRepository for live updates, show "Disconnected" with warning icon on radio disconnect (FR-010, FR-011)
|
||||
|
||||
**Checkpoint**: Persistent mini-panel visible across all screens, updates in real-time, expands on tap
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: User Story 7 — In-Context Voice Input for Actions (Priority: P3)
|
||||
|
||||
**Goal**: Voice reply is the default composition method, TTS reads messages aloud, FuzzyNodeNameResolver handles voice-initiated DMs
|
||||
|
||||
**Independent Test**: Tap reply → dictate → message sent → tap "read aloud" → TTS reads message with sender name
|
||||
|
||||
### Implementation for User Story 7
|
||||
|
||||
- [x] T032 [US7] Implement TTS read-aloud action in ConversationScreen using Android built-in TTS engine — reads sender name + message content on tap of "Read Aloud" action
|
||||
- [x] T033 [US7] Wire FuzzyNodeNameResolver into node detail "Message" action flow — when initiating DM from NodeDashboard, voice input is default composition method with resolved node context
|
||||
|
||||
**Checkpoint**: Voice reply works end-to-end, TTS reads messages clearly, node-initiated DMs use voice by default
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Error handling, degraded states, compliance, and verification
|
||||
|
||||
- [x] T034 [P] Implement BLE disconnection Banner + graceful degradation to cached read-only data across all screens (FR-011, FR-015)
|
||||
- [x] T035 [P] Implement empty/error states: no channels configured → onboarding PaneTemplate, no nodes → "No nodes heard", no positions → "No positions reported" (per error contracts)
|
||||
- [x] T036 [P] Add ProGuard/R8 keep rule for MeshtasticCarAppService in feature/car/proguard-rules.pro
|
||||
- [x] T037 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto`
|
||||
- [x] T038 [P] Review all screens against automotive HMI distraction guidelines — verify ≤ 2 taps for all primary actions (NFR-001)
|
||||
- [x] T039 Run constitution-required verification: `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:testGoogleDebugUnitTest`
|
||||
- [x] T040 Validate quickstart.md developer workflow documentation is accurate for the implemented module
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: UX Polish & Advanced CAL APIs
|
||||
|
||||
**Purpose**: Leverage advanced Car App Library APIs for richer UX — transient feedback, pull-to-refresh, modal alerts, responsive text, full conversation view, and safety-gated actions
|
||||
|
||||
- [x] T041 [P] [FR-023] Add CarToast feedback to ConversationScreen (voice reply sent, quick-reply sent) and HomeScreen (reconnection events) — use `CarToast.makeText(carContext, msg, LENGTH_SHORT).show()` in action callbacks
|
||||
- [x] T042 [P] [FR-024] Implement OnContentRefreshListener on HomeScreen messaging tab and NodeDashboardScreen — call `stateCoordinator.refresh()` and `invalidate()` on trigger
|
||||
- [x] T043 [P] [FR-025] Upgrade EmergencyHandler to use CAL Alert API — present modal `Alert.Builder()` for new SOS alerts requiring explicit dismiss/acknowledge, replacing passive spotlight rows for active alerts
|
||||
- [x] T044 [FR-026] Upgrade ConversationScreen to LongMessageTemplate for full conversation view — concatenate all messages into a formatted long-text body with sender/timestamp prefixes when message count exceeds list limit
|
||||
- [x] T045 [P] [FR-027] Add CarText.addVariant() responsive text to node subtitles in HomeScreen and NodeDashboardScreen — short variant (signal icon only) for narrow displays, full variant (signal + battery + last heard) for wide
|
||||
- [x] T046 [P] [FR-028] Add ParkedOnlyOnClickListener to voice reply and quick-reply actions in ConversationScreen — allows voice compose only when vehicle is parked per CAL safety guidelines
|
||||
- [x] T047 Run verification: `./gradlew spotlessApply :feature:car:spotlessCheck :feature:car:detekt :feature:car:compileFdroidDebugKotlin`
|
||||
|
||||
**Checkpoint**: Advanced CAL APIs integrated — transient feedback, pull-to-refresh, modal alerts, responsive text, safety-gated actions all functional
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No dependencies — start immediately
|
||||
- **Phase 2 (Foundational)**: Depends on Phase 1 — BLOCKS all user stories
|
||||
- **Phase 3 (US1 - Messaging)**: Depends on Phase 2 — MVP target
|
||||
- **Phase 4 (US2 - Emergency)**: Depends on Phase 2; integrates with MessagingScreen (Phase 3 T016)
|
||||
- **Phase 5 (US3 - Nodes)**: Depends on Phase 2 — independent of messaging
|
||||
- **Phase 6 (US4 - Channels)**: Depends on Phase 3 (modifies MessagingScreen)
|
||||
- **Phase 7 (US5 - Map)**: **DEFERRED** — pending NAVIGATION vs POI category decision
|
||||
- **Phase 8 (US6 - Status Panel)**: Depends on Phase 2 — independent
|
||||
- **Phase 9 (US7 - Voice)**: Depends on Phase 3 (ConversationScreen T017, FuzzyNodeNameResolver T018)
|
||||
- **Phase 10 (Polish)**: Depends on all user story phases
|
||||
- **Phase 11 (Advanced APIs)**: Depends on Phase 10 — enhances existing screens with advanced CAL APIs
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (Messaging, P1)**: Can start after Phase 2 — no other story dependencies
|
||||
- **US2 (Emergency, P1)**: Can start after Phase 2 — integrates with US1's MessagingScreen (T016) for Spotlight Section (T024)
|
||||
- **US3 (Nodes, P2)**: Can start after Phase 2 — fully independent
|
||||
- **US4 (Channels, P2)**: Depends on US1 (extends MessagingScreen)
|
||||
- **US5 (Map, DEFERRED)**: Pending NAVIGATION vs POI category decision — requires further research
|
||||
- **US6 (Status Panel, P3)**: Can start after Phase 2 — fully independent
|
||||
- **US7 (Voice, P3)**: Depends on US1 (extends ConversationScreen)
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- State models → Screen implementation → Integration logic
|
||||
- Screens before cross-screen wiring
|
||||
- Core implementation before refinement
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 1**: T004, T005, T006, T007 can all run in parallel
|
||||
- **Phase 2**: T010, T011 in parallel; T014 parallel with T010/T011
|
||||
- **After Phase 2**: US1, US3, and US6 can start simultaneously (independent)
|
||||
- **Within US1**: T016 and T017 in parallel (different files)
|
||||
- **Within US2**: T022 independent of other stories
|
||||
- **Within US3**: T026 and T027 in parallel (different files)
|
||||
- **Phase 10**: T034, T035, T036, T037, T038 all in parallel
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: After Foundational Phase
|
||||
|
||||
```bash
|
||||
# Three stories can start simultaneously:
|
||||
# Developer A: US1 (Messaging)
|
||||
Task: T016 "Create MessagingScreen"
|
||||
Task: T017 "Create ConversationScreen"
|
||||
|
||||
# Developer B: US3 (Nodes)
|
||||
Task: T026 "Create NodeDashboardScreen"
|
||||
Task: T027 "Create NodeDetailScreen"
|
||||
|
||||
# Developer C: US6 (Status Panel)
|
||||
Task: T030 "Create MeshStatusPanel"
|
||||
Task: T031 "Register panel in session"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (T001–T007)
|
||||
2. Complete Phase 2: Foundational (T008–T015)
|
||||
3. Complete Phase 3: User Story 1 — Messaging (T016–T021)
|
||||
4. **STOP and VALIDATE**: Test messaging end-to-end with DHU
|
||||
5. Deploy to internal testing track if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Module compiles and binds to Android Auto
|
||||
2. Add US1 (Messaging) → Core value delivered (MVP!)
|
||||
3. Add US2 (Emergency) → Safety-critical alerts operational
|
||||
4. Add US3 (Nodes) → Node awareness complete
|
||||
5. Add US4 (Channels) → Multi-channel workflows enabled
|
||||
6. Add US6 + US7 (Panel + Voice) → Polish and hands-free refinement
|
||||
7. Each increment is independently testable with the Desktop Head Unit (DHU)
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers after Phase 2:
|
||||
- Developer A: US1 (Messaging) → US4 (Channels) → US7 (Voice)
|
||||
- Developer B: US3 (Nodes) + US6 (Status Panel)
|
||||
- Developer C: US2 (Emergency)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All screens use `invalidate()` for refresh (never recreate Screen objects) per NFR-010
|
||||
- 300ms debounce on all invalidation triggers per NFR-010
|
||||
- CAL host enforces distraction guidelines — app provides templates only
|
||||
- Existing `core/` modules consumed read-only via Koin DI — no API changes
|
||||
- Google flavor only — F-Droid builds unaffected
|
||||
- Car API Level 8 minimum — older hosts gracefully hide the app
|
||||
Reference in New Issue
Block a user