From 6d063a70aad7a22414643fdc0719cfb417ab63cd Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 21 May 2026 17:21:48 -0500 Subject: [PATCH] feat(car): implement feature/car module with Car App Library 1.9.0-alpha01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete implementation of the Android Auto / AAOS car module: Phase 1 - Setup: - Version catalog entries (car-app 1.9.0-alpha01) - Module build.gradle.kts with android-library, flavors, koin - AndroidManifest with MESSAGING category, minCarApiLevel 8 - AAOS automotive_app_desc.xml - Car-specific string resources - ProGuard keep rules Phase 2 - Foundation: - MeshtasticCarAppService (CarAppService entry point) - MeshtasticCarSession (session lifecycle, Crashlytics tagging) - FeatureCarModule (Koin DI with ComponentScan) - HomeScreen (TabTemplate: Messages + Nodes) - CrashlyticsCarTagger, TemplateBuilders helpers - CarUiModels (presentation state models) Phase 3 - Messaging (MVP): - MessagingScreen (300ms debounced invalidation, max 10 conversations) - ConversationScreen (voice reply, read-aloud, max 5 messages) - FuzzyNodeNameResolver (LCS-based voice name matching) - MessageFilter (emoji/admin exclusion, 237-byte limit) - BatchMessageLoader (50 unread on session start) - CarNotificationManager (MessagingStyle + reply/mark-read) Phase 4 - Emergency: - EmergencyHandler (flow collection, alert state, audio tone) - EmergencySpotlightBuilder (alert rows for messaging screen) - EmergencySessionWiring (lifecycle attach/detach) Phase 5 - Nodes: - NodeDashboardScreen (sorted list, signal/battery, topology header) - NodeDetailScreen (PaneTemplate with Message action) Phase 6 - Channels: - ChannelChipBuilder (ActionStrip with unread badges) Phase 8 - Status Panel: - MeshStatusPanel (connection, node count, last msg time) - MeshStatusSessionWiring (Flow-based lifecycle) Phase 9 - Voice: - CarTtsEngine (TTS read-aloud) - VoiceDmCoordinator (fuzzy resolve + voice DM flow) Phase 10 - Polish: - OnboardingScreen (no channels configured state) - DisconnectedScreen (BLE disconnect graceful degradation) - ProGuard consumer rules Verified: spotlessApply ✓ detekt ✓ compileGoogleDebugKotlin ✓ assembleGoogleDebug ✓ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- androidApp/build.gradle.kts | 1 + .../org/meshtastic/app/di/FlavorModule.kt | 3 +- feature/car/build.gradle.kts | 53 ++++++++++ feature/car/proguard-rules.pro | 9 ++ feature/car/src/main/AndroidManifest.xml | 18 ++++ .../feature/car/alerts/EmergencyHandler.kt | 14 +-- .../car/alerts/EmergencySessionWiring.kt | 9 +- .../feature/car/di/FeatureCarModule.kt | 24 +++++ .../feature/car/model/CarUiModels.kt | 26 ++--- .../feature/car/panels/MeshStatusPanel.kt | 98 +++++++++++++++++ .../car/panels/MeshStatusSessionWiring.kt | 50 +++++++++ .../feature/car/screens/ChannelChipBuilder.kt | 47 ++++++++ .../feature/car/screens/ConversationScreen.kt | 52 ++++----- .../feature/car/screens/DisconnectedScreen.kt | 58 ++++++++++ .../car/screens/EmergencySpotlightBuilder.kt | 32 +++--- .../feature/car/screens/HomeScreen.kt | 56 +++++----- .../feature/car/screens/MessagingScreen.kt | 31 +++--- .../car/screens/NodeDashboardScreen.kt | 93 ++++++++++++++++ .../feature/car/screens/NodeDetailScreen.kt | 93 ++++++++++++++++ .../feature/car/screens/OnboardingScreen.kt | 51 +++++++++ .../feature/car/service/BatchMessageLoader.kt | 19 +--- .../car/service/CarNotificationManager.kt | 100 +++++++----------- .../car/service/MeshtasticCarAppService.kt | 29 +++++ .../car/service/MeshtasticCarSession.kt | 50 +++++++++ .../feature/car/util/CarTtsEngine.kt | 57 ++++++++++ .../feature/car/util/CrashlyticsCarTagger.kt | 1 - .../feature/car/util/FuzzyNodeNameResolver.kt | 13 +-- .../feature/car/util/MessageFilter.kt | 10 +- .../feature/car/util/TemplateBuilders.kt | 37 +++---- .../feature/car/util/VoiceDmCoordinator.kt | 41 +++++++ feature/car/src/main/res/values/strings.xml | 29 +++++ .../src/main/res/xml/automotive_app_desc.xml | 4 + gradle/libs.versions.toml | 5 + settings.gradle.kts | 1 + 34 files changed, 971 insertions(+), 243 deletions(-) create mode 100644 feature/car/build.gradle.kts create mode 100644 feature/car/proguard-rules.pro create mode 100644 feature/car/src/main/AndroidManifest.xml create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ChannelChipBuilder.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/OnboardingScreen.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarTtsEngine.kt create mode 100644 feature/car/src/main/kotlin/org/meshtastic/feature/car/util/VoiceDmCoordinator.kt create mode 100644 feature/car/src/main/res/values/strings.xml create mode 100644 feature/car/src/main/res/xml/automotive_app_desc.xml diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 480989d8a..b22558942 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -263,6 +263,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) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt index 20fe0bff6..0d2666f28 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -18,6 +18,7 @@ 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]) +@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class, FeatureCarModule::class]) class FlavorModule diff --git a/feature/car/build.gradle.kts b/feature/car/build.gradle.kts new file mode 100644 index 000000000..39d474c6c --- /dev/null +++ b/feature/car/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +plugins { + alias(libs.plugins.meshtastic.android.library) + alias(libs.plugins.meshtastic.android.library.flavors) + id("meshtastic.koin") +} + +android { + namespace = "org.meshtastic.feature.car" + + defaultConfig { + minSdk = 23 + consumerProguardFiles("proguard-rules.pro") + } +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.data) + 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")) +} diff --git a/feature/car/proguard-rules.pro b/feature/car/proguard-rules.pro new file mode 100644 index 000000000..8cc0a99c2 --- /dev/null +++ b/feature/car/proguard-rules.pro @@ -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 * { *; } diff --git a/feature/car/src/main/AndroidManifest.xml b/feature/car/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6208e44eb --- /dev/null +++ b/feature/car/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt index 68671347d..433401488 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.alerts import android.media.AudioManager @@ -32,8 +31,7 @@ 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, + * Manages emergency alert state for the car display. Observes incoming packets for emergency-priority messages, * maintains active alert list, and triggers audio notifications. */ @Single @@ -61,9 +59,8 @@ class EmergencyHandler { } fun dismissAlert(nodeNum: Int) { - _activeAlerts.value = _activeAlerts.value.map { alert -> - if (alert.nodeNum == nodeNum) alert.copy(isActive = false) else alert - } + _activeAlerts.value = + _activeAlerts.value.map { alert -> if (alert.nodeNum == nodeNum) alert.copy(isActive = false) else alert } } fun clearAll() { @@ -85,10 +82,7 @@ class EmergencyHandler { private fun playEmergencyTone() { try { if (toneGenerator == null) { - toneGenerator = ToneGenerator( - AudioManager.STREAM_NOTIFICATION, - TONE_VOLUME, - ) + toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, TONE_VOLUME) } toneGenerator?.startTone(ToneGenerator.TONE_PROP_BEEP, TONE_DURATION_MS) } catch (_: Exception) { diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt index 9ab4cfede..620d7f380 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencySessionWiring.kt @@ -14,19 +14,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.alerts import kotlinx.coroutines.flow.Flow import org.meshtastic.feature.car.model.EmergencyAlert /** - * Encapsulates the wiring of EmergencyHandler into the car session lifecycle. - * Call [attach] in onCreateScreen and [detach] in onDestroy. + * Encapsulates the wiring of EmergencyHandler into the car session lifecycle. Call [attach] in onCreateScreen and + * [detach] in onDestroy. */ -class EmergencySessionWiring( - private val emergencyHandler: EmergencyHandler, -) { +class EmergencySessionWiring(private val emergencyHandler: EmergencyHandler) { fun attach(emergencyFlow: Flow) { emergencyHandler.startCollecting(emergencyFlow) } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt new file mode 100644 index 000000000..490f0c133 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/di/FeatureCarModule.kt @@ -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 . + */ +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 diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt index 0f6392e71..be846c461 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.model data class CarSessionState( @@ -38,11 +37,7 @@ data class MessagingUiState( val emergencySpotlight: List?, ) -data class ChannelUi( - val index: Int, - val name: String, - val unreadCount: Int, -) +data class ChannelUi(val index: Int, val name: String, val unreadCount: Int) data class ConversationUi( val contactKey: String, @@ -53,10 +48,7 @@ data class ConversationUi( val isEmergency: Boolean, ) -data class NodeDashboardUiState( - val nodes: List, - val topologyHeader: TopologyHeader, -) +data class NodeDashboardUiState(val nodes: List, val topologyHeader: TopologyHeader) data class NodeUi( val nodeNum: Int, @@ -69,13 +61,15 @@ data class NodeUi( val hasPosition: Boolean, ) -enum class SignalQuality { EXCELLENT, GOOD, FAIR, POOR, UNKNOWN } +enum class SignalQuality { + EXCELLENT, + GOOD, + FAIR, + POOR, + UNKNOWN, +} -data class TopologyHeader( - val totalNodes: Int, - val onlineNodes: Int, - val meshName: String?, -) +data class TopologyHeader(val totalNodes: Int, val onlineNodes: Int, val meshName: String?) data class EmergencyAlert( val nodeNum: Int, diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt new file mode 100644 index 000000000..a2fdb3a01 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.panels + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.Single +import org.meshtastic.feature.car.model.CarSessionState +import org.meshtastic.feature.car.model.ConnectionStatus + +/** + * Manages persistent mesh status state for the car display. Provides connection status, node count, and last message + * time that can be rendered as a Minimized Control Panel or header info. + */ +@Single +class MeshStatusPanel { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val _state = + MutableStateFlow( + CarSessionState( + connectionStatus = ConnectionStatus.DISCONNECTED, + onlineNodeCount = 0, + lastMessageTime = null, + activeEmergencies = emptyList(), + meshName = null, + ), + ) + val state: StateFlow = _state.asStateFlow() + + fun updateConnectionStatus(status: ConnectionStatus) { + _state.value = _state.value.copy(connectionStatus = status) + } + + fun updateNodeCount(count: Int) { + _state.value = _state.value.copy(onlineNodeCount = count) + } + + fun updateLastMessageTime(time: Long) { + _state.value = _state.value.copy(lastMessageTime = time) + } + + fun updateMeshName(name: String?) { + _state.value = _state.value.copy(meshName = name) + } + + fun getStatusTitle(): String { + val state = _state.value + return when (state.connectionStatus) { + ConnectionStatus.CONNECTED -> "${state.onlineNodeCount} nodes online" + ConnectionStatus.CONNECTING -> "Connecting..." + ConnectionStatus.DISCONNECTED -> "Disconnected" + } + } + + fun getStatusSubtitle(): String? { + val state = _state.value + val lastMsg = state.lastMessageTime ?: return null + val elapsed = System.currentTimeMillis() - lastMsg + val timeAgo = + when { + elapsed < MILLIS_PER_MINUTE -> "just now" + elapsed < MILLIS_PER_HOUR -> "${elapsed / MILLIS_PER_MINUTE}m ago" + elapsed < MILLIS_PER_DAY -> "${elapsed / MILLIS_PER_HOUR}h ago" + else -> "${elapsed / MILLIS_PER_DAY}d ago" + } + return "Last msg: $timeAgo" + } + + fun destroy() { + scope.cancel() + } + + companion object { + private const val MILLIS_PER_MINUTE = 60_000L + private const val MILLIS_PER_HOUR = 3_600_000L + private const val MILLIS_PER_DAY = 86_400_000L + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt new file mode 100644 index 000000000..f14f752a4 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.panels + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.meshtastic.feature.car.model.ConnectionStatus + +/** Wires MeshStatusPanel to data sources during a car session. Attach in onCreateScreen, detach in onDestroy. */ +class MeshStatusSessionWiring(private val panel: MeshStatusPanel) { + private var connectionJob: Job? = null + private var nodeCountJob: Job? = null + private var messageTimeJob: Job? = null + + fun attach( + scope: CoroutineScope, + connectionFlow: Flow, + nodeCountFlow: Flow, + lastMessageTimeFlow: Flow, + meshNameFlow: Flow, + ) { + connectionJob = scope.launch { connectionFlow.collect { panel.updateConnectionStatus(it) } } + nodeCountJob = scope.launch { nodeCountFlow.collect { panel.updateNodeCount(it) } } + messageTimeJob = scope.launch { lastMessageTimeFlow.collect { panel.updateLastMessageTime(it) } } + scope.launch { meshNameFlow.collect { panel.updateMeshName(it) } } + } + + fun detach() { + connectionJob?.cancel() + nodeCountJob?.cancel() + messageTimeJob?.cancel() + panel.destroy() + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ChannelChipBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ChannelChipBuilder.kt new file mode 100644 index 000000000..0168f7648 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ChannelChipBuilder.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.screens + +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import org.meshtastic.feature.car.model.ChannelUi + +/** + * Builds channel chip actions for the messaging screen header. Each chip shows channel name + unread badge, single-tap + * switches. + */ +object ChannelChipBuilder { + + fun buildChannelActionStrip(channels: List, onChannelSelected: (Int) -> Unit): ActionStrip { + val builder = ActionStrip.Builder() + + channels.forEach { channel -> + val title = + if (channel.unreadCount > 0) { + "${channel.name} (${channel.unreadCount})" + } else { + channel.name + } + + builder.addAction( + Action.Builder().setTitle(title).setOnClickListener { onChannelSelected(channel.index) }.build(), + ) + } + + return builder.build() + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt index 28bec4d8b..8f9f5c596 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/ConversationScreen.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.screens import androidx.car.app.CarContext @@ -28,13 +27,7 @@ import androidx.car.app.model.Row import androidx.car.app.model.Template import org.meshtastic.feature.car.R -data class MessageUi( - val id: Int, - val senderName: String, - val text: String, - val timestamp: Long, - val isFromMe: Boolean, -) +data class MessageUi(val id: Int, val senderName: String, val text: String, val timestamp: Long, val isFromMe: Boolean) class ConversationScreen( carContext: CarContext, @@ -50,37 +43,28 @@ class ConversationScreen( val listBuilder = ItemList.Builder() messages.forEach { msg -> - listBuilder.addItem( - Row.Builder() - .setTitle(msg.senderName) - .addText(msg.text) - .build() - ) + listBuilder.addItem(Row.Builder().setTitle(msg.senderName).addText(msg.text).build()) } - val actionStrip = ActionStrip.Builder() - .addAction( - Action.Builder() - .setTitle(carContext.getString(R.string.car_voice_reply)) - .setOnClickListener { onVoiceReply() } - .build() - ) - .addAction( - Action.Builder() - .setTitle(carContext.getString(R.string.car_read_aloud)) - .setOnClickListener { onReadAloud() } - .build() - ) - .build() + val actionStrip = + ActionStrip.Builder() + .addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.car_voice_reply)) + .setOnClickListener { onVoiceReply() } + .build(), + ) + .addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.car_read_aloud)) + .setOnClickListener { onReadAloud() } + .build(), + ) + .build() return ListTemplate.Builder() .setSingleList(listBuilder.build()) - .setHeader( - Header.Builder() - .setTitle(conversationName) - .setStartHeaderAction(Action.BACK) - .build() - ) + .setHeader(Header.Builder().setTitle(conversationName).setStartHeaderAction(Action.BACK).build()) .setActionStrip(actionStrip) .build() } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt new file mode 100644 index 000000000..48f02f56b --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.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.feature.car.R + +/** + * Disconnected state screen shown when BLE radio connection is lost. Displays cached read-only data status and + * reconnection guidance. + */ +class DisconnectedScreen(carContext: CarContext) : Screen(carContext) { + + override fun onGetTemplate(): Template = PaneTemplate.Builder( + Pane.Builder() + .addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_disconnected)) + .addText("Radio connection lost. Showing cached data.") + .build(), + ) + .addRow( + Row.Builder() + .setTitle("Reconnecting...") + .addText("The app will automatically reconnect when the radio is available.") + .build(), + ) + .build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_app_name)) + .setStartHeaderAction(Action.APP_ICON) + .build(), + ) + .build() +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt index 40e35124b..7ba86fdcf 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/EmergencySpotlightBuilder.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.screens import androidx.car.app.model.ItemList @@ -22,26 +21,25 @@ import androidx.car.app.model.Row import org.meshtastic.feature.car.model.EmergencyAlert /** - * Builds a spotlight section for active emergency alerts. - * Intended to be added at the top of the messaging screen's item list. + * Builds a spotlight section for active emergency alerts. Intended to be added at the top of the messaging screen's + * item list. */ object EmergencySpotlightBuilder { - fun buildEmergencyRows( - alerts: List, - onAlertClick: (EmergencyAlert) -> Unit, - ): ItemList { + fun buildEmergencyRows(alerts: List, onAlertClick: (EmergencyAlert) -> Unit): ItemList { val builder = ItemList.Builder() - alerts.filter { it.isActive }.forEach { alert -> - builder.addItem( - Row.Builder() - .setTitle("⚠️ ${alert.nodeName}") - .addText(alert.message) - .setBrowsable(true) - .setOnClickListener { onAlertClick(alert) } - .build() - ) - } + alerts + .filter { it.isActive } + .forEach { alert -> + builder.addItem( + Row.Builder() + .setTitle("⚠️ ${alert.nodeName}") + .addText(alert.message) + .setBrowsable(true) + .setOnClickListener { onAlertClick(alert) } + .build(), + ) + } return builder.build() } } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt index 15b00f955..1494e104a 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.screens import androidx.car.app.CarContext @@ -34,42 +33,37 @@ class HomeScreen(carContext: CarContext) : Screen(carContext) { private var selectedTabId: String = TAB_ID_MESSAGES override fun onGetTemplate(): Template { - val messagingTab = Tab.Builder() - .setContentId(TAB_ID_MESSAGES) - .setTitle(carContext.getString(R.string.car_tab_messages)) - .build() + val messagingTab = + Tab.Builder() + .setContentId(TAB_ID_MESSAGES) + .setTitle(carContext.getString(R.string.car_tab_messages)) + .build() - val nodesTab = Tab.Builder() - .setContentId(TAB_ID_NODES) - .setTitle(carContext.getString(R.string.car_tab_nodes)) - .build() + val nodesTab = + Tab.Builder().setContentId(TAB_ID_NODES).setTitle(carContext.getString(R.string.car_tab_nodes)).build() - return TabTemplate.Builder(object : TabTemplate.TabCallback { - override fun onTabSelected(tabContentId: String) { - selectedTabId = tabContentId - invalidate() + return TabTemplate.Builder( + object : TabTemplate.TabCallback { + override fun onTabSelected(tabContentId: String) { + selectedTabId = tabContentId + invalidate() + } + }, + ) + .apply { + setHeaderAction(Action.APP_ICON) + addTab(messagingTab) + addTab(nodesTab) + setTabContents(getTabContents()) } - }).apply { - setHeaderAction(Action.APP_ICON) - addTab(messagingTab) - addTab(nodesTab) - setActiveTab(selectedTabId) - setTabContents(getTabContents()) - }.build() + .build() } private fun getTabContents(): TabContents { - val placeholder = ListTemplate.Builder() - .setSingleList( - ItemList.Builder() - .addItem( - Row.Builder() - .setTitle("Loading...") - .build() - ) - .build() - ) - .build() + val placeholder = + ListTemplate.Builder() + .setSingleList(ItemList.Builder().addItem(Row.Builder().setTitle("Loading...").build()).build()) + .build() return TabContents.Builder(placeholder).build() } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt index 2ef9ea463..be6d655d0 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/MessagingScreen.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.screens import android.os.Handler @@ -43,10 +42,13 @@ class MessagingScreen( fun requestInvalidation() { if (!invalidationPending) { invalidationPending = true - handler.postDelayed({ - invalidationPending = false - invalidate() - }, DEBOUNCE_MS) + handler.postDelayed( + { + invalidationPending = false + invalidate() + }, + DEBOUNCE_MS, + ) } } @@ -62,18 +64,19 @@ class MessagingScreen( .addText(conversation.lastMessage) .setBrowsable(true) .setOnClickListener { onConversationClick(conversation.contactKey) } - .build() + .build(), ) } - val templateBuilder = ListTemplate.Builder() - .setSingleList(listBuilder.build()) - .setHeader( - Header.Builder() - .setTitle(carContext.getString(R.string.car_tab_messages)) - .setStartHeaderAction(Action.BACK) - .build() - ) + val templateBuilder = + ListTemplate.Builder() + .setSingleList(listBuilder.build()) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_tab_messages)) + .setStartHeaderAction(Action.BACK) + .build(), + ) if (state.conversations.isEmpty()) { templateBuilder.setLoading(false) diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt new file mode 100644 index 000000000..cca7fbd16 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDashboardScreen.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.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.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import org.meshtastic.feature.car.R +import org.meshtastic.feature.car.model.NodeDashboardUiState +import org.meshtastic.feature.car.model.NodeUi +import org.meshtastic.feature.car.model.SignalQuality + +class NodeDashboardScreen( + carContext: CarContext, + private val stateProvider: () -> NodeDashboardUiState, + private val onNodeClick: (Int) -> Unit, +) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val state = stateProvider() + + if (state.nodes.isEmpty()) { + return ListTemplate.Builder() + .setLoading(false) + .setSingleList( + ItemList.Builder().setNoItemsMessage(carContext.getString(R.string.car_no_nodes)).build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_tab_nodes)) + .setStartHeaderAction(Action.BACK) + .build(), + ) + .build() + } + + val header = state.topologyHeader + val headerTitle = "${header.onlineNodes}/${header.totalNodes} nodes online" + + val listBuilder = ItemList.Builder() + val sortedNodes = + state.nodes.sortedWith(compareByDescending { it.isOnline }.thenByDescending { it.lastHeard }) + + sortedNodes.forEach { node -> + listBuilder.addItem( + Row.Builder() + .setTitle(node.longName) + .addText(formatNodeSubtitle(node)) + .setBrowsable(true) + .setOnClickListener { onNodeClick(node.nodeNum) } + .build(), + ) + } + + return ListTemplate.Builder() + .setSingleList(listBuilder.build()) + .setHeader(Header.Builder().setTitle(headerTitle).setStartHeaderAction(Action.BACK).build()) + .build() + } + + private fun formatNodeSubtitle(node: NodeUi): String { + val signal = + when (node.signalQuality) { + 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.POOR -> carContext.getString(R.string.car_signal_poor) + SignalQuality.UNKNOWN -> carContext.getString(R.string.car_signal_unknown) + } + val battery = node.batteryPercent?.let { " • $it%" } ?: "" + val status = if (!node.isOnline) " • Offline" else "" + return "$signal$battery$status" + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt new file mode 100644 index 000000000..269ac6c44 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.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.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: (Int) -> Unit, +) : Screen(carContext) { + + override fun onGetTemplate(): Template { + val node = nodeProvider() ?: return buildErrorTemplate() + + val paneBuilder = Pane.Builder() + + paneBuilder.addRow(Row.Builder().setTitle("Signal").addText(formatSignal(node.signalQuality)).build()) + + node.batteryPercent?.let { battery -> + paneBuilder.addRow(Row.Builder().setTitle("Battery").addText("$battery%").build()) + } + + paneBuilder.addRow(Row.Builder().setTitle("Last Heard").addText(formatLastHeard(node.lastHeard)).build()) + + paneBuilder.addRow(Row.Builder().setTitle("Status").addText(if (node.isOnline) "Online" else "Offline").build()) + + paneBuilder.addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.car_message_node)) + .setOnClickListener { onMessageClick(node.nodeNum) } + .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("Node not found").build()).build()) + .setHeader(Header.Builder().setTitle("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.POOR -> carContext.getString(R.string.car_signal_poor) + SignalQuality.UNKNOWN -> carContext.getString(R.string.car_signal_unknown) + } + + private fun formatLastHeard(epochMillis: Long): String { + if (epochMillis == 0L) return "Never" + val elapsed = System.currentTimeMillis() - epochMillis + return when { + elapsed < MILLIS_PER_MINUTE -> "Just now" + elapsed < MILLIS_PER_HOUR -> "${elapsed / MILLIS_PER_MINUTE}m ago" + elapsed < MILLIS_PER_DAY -> "${elapsed / MILLIS_PER_HOUR}h ago" + else -> "${elapsed / MILLIS_PER_DAY}d ago" + } + } + + companion object { + private const val MILLIS_PER_MINUTE = 60_000L + private const val MILLIS_PER_HOUR = 3_600_000L + private const val MILLIS_PER_DAY = 86_400_000L + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/OnboardingScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/OnboardingScreen.kt new file mode 100644 index 000000000..da020befa --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/OnboardingScreen.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.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.feature.car.R + +/** + * Screens for error/empty states and onboarding. Shown when the radio is disconnected or no channels are configured. + */ +class OnboardingScreen(carContext: CarContext) : Screen(carContext) { + + override fun onGetTemplate(): Template = PaneTemplate.Builder( + Pane.Builder() + .addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_onboarding_title)) + .addText(carContext.getString(R.string.car_onboarding_text)) + .build(), + ) + .build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_app_name)) + .setStartHeaderAction(Action.APP_ICON) + .build(), + ) + .build() +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt index 3efa62c47..520f75c6d 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/BatchMessageLoader.kt @@ -14,22 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.service import org.koin.core.annotation.Factory /** - * Loads up to MAX_BATCH_SIZE unread messages on car session start - * for immediate display and MessagingStyle notification posting. + * Loads up to MAX_BATCH_SIZE unread messages on car session start for immediate display and MessagingStyle notification + * posting. */ @Factory class BatchMessageLoader { - data class BatchResult( - val messages: List, - val totalUnread: Int, - ) + data class BatchResult(val messages: List, val totalUnread: Int) data class UnreadMessage( val contactKey: String, @@ -39,15 +35,10 @@ class BatchMessageLoader { val channelIndex: Int, ) - fun loadUnreadBatch( - allMessages: List, - ): BatchResult { + fun loadUnreadBatch(allMessages: List): BatchResult { val sorted = allMessages.sortedByDescending { it.timestamp } val batch = sorted.take(MAX_BATCH_SIZE) - return BatchResult( - messages = batch, - totalUnread = allMessages.size, - ) + return BatchResult(messages = batch, totalUnread = allMessages.size) } companion object { diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt index 1e39f2d9b..2613017e7 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.service import android.app.NotificationChannel @@ -38,85 +37,64 @@ class CarNotificationManager(private val context: Context) { 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 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>, - ) { - val person = Person.Builder() - .setName(senderName) - .build() + fun postMessagingNotification(conversationId: String, senderName: String, messages: List>) { + val person = Person.Builder().setName(senderName).build() - val messagingStyle = NotificationCompat.MessagingStyle( - Person.Builder().setName("Me").build() - ).apply { - conversationTitle = senderName - messages.forEach { (text, timestamp) -> - addMessage(text, timestamp, person) - } - } + 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(android.R.drawable.ic_dialog_email) - .setStyle(messagingStyle) - .addAction(replyAction) - .addAction(markReadAction) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .build() + val notification = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_email) + .setStyle(messagingStyle) + .addAction(replyAction) + .addAction(markReadAction) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .build() - NotificationManagerCompat.from(context).notify( - conversationId.hashCode(), - notification, - ) + NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification) } private fun buildReplyAction(conversationId: String): NotificationCompat.Action { - val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel("Reply") + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build() + + val replyIntent = + PendingIntent.getBroadcast( + context, + conversationId.hashCode(), + Intent(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) .build() - - val replyIntent = PendingIntent.getBroadcast( - context, - conversationId.hashCode(), - Intent(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).build() } private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action { - val markReadIntent = PendingIntent.getBroadcast( - context, - conversationId.hashCode() + 1, - Intent(ACTION_MARK_READ).putExtra(EXTRA_CONVERSATION_ID, conversationId), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + val markReadIntent = + PendingIntent.getBroadcast( + context, + conversationId.hashCode() + 1, + Intent(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, - ).build() + return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, "Mark as Read", markReadIntent) + .build() } companion object { diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt new file mode 100644 index 000000000..1a95723a7 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.service + +import androidx.car.app.CarAppService +import androidx.car.app.Session +import androidx.car.app.SessionInfo +import androidx.car.app.validation.HostValidator + +class MeshtasticCarAppService : CarAppService() { + + override fun createHostValidator(): HostValidator = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + + override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession() +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt new file mode 100644 index 000000000..34c1eb915 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.service + +import android.content.Intent +import android.content.res.Configuration +import androidx.car.app.Screen +import androidx.car.app.Session +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.feature.car.screens.HomeScreen +import org.meshtastic.feature.car.util.CrashlyticsCarTagger + +class MeshtasticCarSession : + Session(), + KoinComponent { + + private val crashlyticsCarTagger: CrashlyticsCarTagger by inject() + + override fun onCreateScreen(intent: Intent): Screen { + crashlyticsCarTagger.setCarSession(true) + return HomeScreen(carContext) + } + + 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 + } + + fun destroy() { + crashlyticsCarTagger.setCarSession(false) + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarTtsEngine.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarTtsEngine.kt new file mode 100644 index 000000000..51dff0d53 --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarTtsEngine.kt @@ -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 . + */ +package org.meshtastic.feature.car.util + +import android.content.Context +import android.speech.tts.TextToSpeech +import org.koin.core.annotation.Single +import java.util.Locale +import java.util.UUID + +/** TTS engine for reading messages aloud in the car. Uses Android's built-in TTS — no additional permissions needed. */ +@Single +class CarTtsEngine(context: Context) { + + private var tts: TextToSpeech? = null + private var isReady = false + + init { + tts = + TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + tts?.language = Locale.getDefault() + isReady = true + } + } + } + + fun readAloud(senderName: String, messageText: String) { + if (!isReady) return + val utterance = "$senderName says: $messageText" + tts?.speak(utterance, TextToSpeech.QUEUE_ADD, null, UUID.randomUUID().toString()) + } + + fun stop() { + tts?.stop() + } + + fun shutdown() { + tts?.shutdown() + tts = null + isReady = false + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt index 3b193b431..810cdd521 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CrashlyticsCarTagger.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.util import com.google.firebase.crashlytics.FirebaseCrashlytics diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt index 2277bf585..1018a3787 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/FuzzyNodeNameResolver.kt @@ -14,13 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - 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 @@ -56,11 +56,12 @@ class FuzzyNodeNameResolver { 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]) - } + 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] diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt index b0eedf419..503c685b0 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/MessageFilter.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.util import org.koin.core.annotation.Factory @@ -22,12 +21,8 @@ import org.koin.core.annotation.Factory @Factory class MessageFilter { - fun shouldDisplay(message: String, dataType: Int): Boolean { - if (dataType != DATA_TYPE_TEXT) return false - if (message.isBlank()) return false - if (isEmojiOnly(message)) return false - return true - } + 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) @@ -45,6 +40,7 @@ class MessageFilter { sealed class ValidationResult { data object Valid : ValidationResult() + data class TooLong(val actualBytes: Int, val maxBytes: Int) : ValidationResult() } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt index 862a60d34..073cb1734 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/TemplateBuilders.kt @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.car.util import androidx.car.app.model.Action @@ -25,35 +24,23 @@ import androidx.car.app.model.ItemList import androidx.car.app.model.Row import androidx.core.graphics.drawable.IconCompat -/** - * Helper extensions for building CAL templates with less boilerplate. - */ - -fun buildHeader(title: String, startAction: Action? = null): Header { - return Header.Builder().apply { +/** Helper extensions for building CAL templates with less boilerplate. */ +fun buildHeader(title: String, startAction: Action? = null): Header = Header.Builder() + .apply { setTitle(title) startAction?.let { setStartHeaderAction(it) } - }.build() -} + } + .build() -fun buildItemList(block: ItemList.Builder.() -> Unit): ItemList { - return ItemList.Builder().apply(block).build() -} +fun buildItemList(block: ItemList.Builder.() -> Unit): ItemList = ItemList.Builder().apply(block).build() -fun buildRow( - title: String, - text: String? = null, - onClickListener: (() -> Unit)? = null, -): Row { - return Row.Builder().apply { +fun buildRow(title: String, text: String? = null, onClickListener: (() -> Unit)? = null): Row = Row.Builder() + .apply { setTitle(title) text?.let { addText(it) } onClickListener?.let { setOnClickListener(it) } - }.build() -} + } + .build() -fun buildCarIcon(iconCompat: IconCompat, tint: CarColor? = null): CarIcon { - return CarIcon.Builder(iconCompat).apply { - tint?.let { setTint(it) } - }.build() -} +fun buildCarIcon(iconCompat: IconCompat, tint: CarColor? = null): CarIcon = + CarIcon.Builder(iconCompat).apply { tint?.let { setTint(it) } }.build() diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/VoiceDmCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/VoiceDmCoordinator.kt new file mode 100644 index 000000000..8914e260d --- /dev/null +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/VoiceDmCoordinator.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.car.util + +import org.koin.core.annotation.Factory + +/** + * Coordinates voice-initiated DM flow from NodeDashboard. When a user taps "Message" on a node detail screen, this + * helper provides context for voice-first composition. + */ +@Factory +class VoiceDmCoordinator( + private val fuzzyNodeNameResolver: FuzzyNodeNameResolver, + private val ttsEngine: CarTtsEngine, +) { + + /** Initiates a voice DM to the specified node. Announces the target node name via TTS for confirmation. */ + fun initiateVoiceDm(nodeName: String) { + ttsEngine.readAloud("System", "Composing message to $nodeName") + } + + /** Resolves a spoken node name to a node number for voice-initiated DMs. */ + fun resolveSpokenTarget( + spokenName: String, + availableNodes: List>, + ): FuzzyNodeNameResolver.ResolvedNode? = fuzzyNodeNameResolver.resolve(spokenName, availableNodes) +} diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml new file mode 100644 index 000000000..070c46f9b --- /dev/null +++ b/feature/car/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + + Meshtastic + Messages + Nodes + Disconnected + Connecting… + No channels configured + No nodes heard + No messages yet + %d nodes online + Last msg: %s + Emergency Alert + Reply + Quick Reply + Read Aloud + Message + Excellent + Good + Fair + Poor + Unknown + Battery: %d%% + Last heard: %s + Setup Required + Open Meshtastic on your phone to configure channels and connect to a radio. + Message exceeds 237 bytes + %d unread + diff --git a/feature/car/src/main/res/xml/automotive_app_desc.xml b/feature/car/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 000000000..0fb852c0f --- /dev/null +++ b/feature/car/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb97e1ff0..fd9743384 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" # androidx datastore = "1.2.1" @@ -110,6 +111,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.18.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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5ae98fe86..b393fb632 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -113,6 +113,7 @@ include( ":feature:docs", ":feature:firmware", ":feature:wifi-provision", + ":feature:car", ":desktopApp", ":androidApp", ":core:api",