feat(car): implement feature/car module with Car App Library 1.9.0-alpha01

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>
This commit is contained in:
James Rich
2026-05-21 17:21:48 -05:00
parent d6cd581201
commit 6d063a70aa
34 changed files with 971 additions and 243 deletions

View File

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

View File

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

View File

@@ -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 <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"
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"))
}

9
feature/car/proguard-rules.pro vendored Normal file
View 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 * { *; }

View File

@@ -0,0 +1,18 @@
<?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">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.MESSAGING" />
</intent-filter>
</service>
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="8" />
</application>
</manifest>

View File

@@ -14,7 +14,6 @@
* 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
@@ -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) {

View File

@@ -14,19 +14,16 @@
* 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 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<EmergencyAlert>) {
emergencyHandler.startCollecting(emergencyFlow)
}

View File

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

View File

@@ -14,7 +14,6 @@
* 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
data class CarSessionState(
@@ -38,11 +37,7 @@ data class MessagingUiState(
val emergencySpotlight: List<EmergencyAlert>?,
)
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<NodeUi>,
val topologyHeader: TopologyHeader,
)
data class NodeDashboardUiState(val nodes: List<NodeUi>, 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,

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<CarSessionState> = _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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ConnectionStatus>,
nodeCountFlow: Flow<Int>,
lastMessageTimeFlow: Flow<Long>,
meshNameFlow: Flow<String?>,
) {
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ChannelUi>, 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()
}
}

View File

@@ -14,7 +14,6 @@
* 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
@@ -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()
}

View File

@@ -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 <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.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()
}

View File

@@ -14,7 +14,6 @@
* 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.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<EmergencyAlert>,
onAlertClick: (EmergencyAlert) -> Unit,
): ItemList {
fun buildEmergencyRows(alerts: List<EmergencyAlert>, 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()
}
}

View File

@@ -14,7 +14,6 @@
* 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
@@ -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()
}

View File

@@ -14,7 +14,6 @@
* 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 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)

View File

@@ -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 <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.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<NodeUi> { 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"
}
}

View File

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

View File

@@ -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 <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.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()
}

View File

@@ -14,22 +14,18 @@
* 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 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<UnreadMessage>,
val totalUnread: Int,
)
data class BatchResult(val messages: List<UnreadMessage>, val totalUnread: Int)
data class UnreadMessage(
val contactKey: String,
@@ -39,15 +35,10 @@ class BatchMessageLoader {
val channelIndex: Int,
)
fun loadUnreadBatch(
allMessages: List<UnreadMessage>,
): BatchResult {
fun loadUnreadBatch(allMessages: List<UnreadMessage>): 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 {

View File

@@ -14,7 +14,6 @@
* 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
@@ -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<Pair<String, Long>>,
) {
val person = Person.Builder()
.setName(senderName)
.build()
fun postMessagingNotification(conversationId: String, senderName: String, messages: List<Pair<String, Long>>) {
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 {

View File

@@ -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 <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
class MeshtasticCarAppService : CarAppService() {
override fun createHostValidator(): HostValidator = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
}

View File

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

View 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/>.
*/
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
}
}

View File

@@ -14,7 +14,6 @@
* 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

View File

@@ -14,13 +14,13 @@
* 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
@@ -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]

View File

@@ -14,7 +14,6 @@
* 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
@@ -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()
}

View File

@@ -14,7 +14,6 @@
* 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 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()

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Pair<Int, String>>,
): FuzzyNodeNameResolver.ResolvedNode? = fuzzyNodeNameResolver.resolve(spokenName, availableNodes)
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="car_app_name">Meshtastic</string>
<string name="car_tab_messages">Messages</string>
<string name="car_tab_nodes">Nodes</string>
<string name="car_disconnected">Disconnected</string>
<string name="car_connecting">Connecting…</string>
<string name="car_no_channels">No channels configured</string>
<string name="car_no_nodes">No nodes heard</string>
<string name="car_no_messages">No messages yet</string>
<string name="car_nodes_online">%d nodes online</string>
<string name="car_last_message">Last msg: %s</string>
<string name="car_emergency_alert">Emergency Alert</string>
<string name="car_voice_reply">Reply</string>
<string name="car_quick_reply">Quick Reply</string>
<string name="car_read_aloud">Read Aloud</string>
<string name="car_message_node">Message</string>
<string name="car_signal_excellent">Excellent</string>
<string name="car_signal_good">Good</string>
<string name="car_signal_fair">Fair</string>
<string name="car_signal_poor">Poor</string>
<string name="car_signal_unknown">Unknown</string>
<string name="car_battery_level">Battery: %d%%</string>
<string name="car_last_heard">Last heard: %s</string>
<string name="car_onboarding_title">Setup Required</string>
<string name="car_onboarding_text">Open Meshtastic on your phone to configure channels and connect to a radio.</string>
<string name="car_message_too_long">Message exceeds 237 bytes</string>
<string name="car_unread_badge">%d unread</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="template" />
</automotiveApp>

View File

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

View File

@@ -113,6 +113,7 @@ include(
":feature:docs",
":feature:firmware",
":feature:wifi-provision",
":feature:car",
":desktopApp",
":androidApp",
":core:api",