mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 23:01:22 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
53
feature/car/build.gradle.kts
Normal file
53
feature/car/build.gradle.kts
Normal 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
9
feature/car/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Car App Library ProGuard/R8 rules
|
||||
|
||||
# CarAppService must not be obfuscated (resolved by android:exported="true" in manifest,
|
||||
# but keep rule ensures R8 doesn't remove it during aggressive shrinking)
|
||||
-keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; }
|
||||
|
||||
# Keep Koin-annotated classes for runtime DI resolution
|
||||
-keep @org.koin.core.annotation.Single class * { *; }
|
||||
-keep @org.koin.core.annotation.Factory class * { *; }
|
||||
18
feature/car/src/main/AndroidManifest.xml
Normal file
18
feature/car/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
29
feature/car/src/main/res/values/strings.xml
Normal file
29
feature/car/src/main/res/values/strings.xml
Normal 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>
|
||||
4
feature/car/src/main/res/xml/automotive_app_desc.xml
Normal file
4
feature/car/src/main/res/xml/automotive_app_desc.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="template" />
|
||||
</automotiveApp>
|
||||
@@ -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" }
|
||||
|
||||
@@ -113,6 +113,7 @@ include(
|
||||
":feature:docs",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":feature:car",
|
||||
":desktopApp",
|
||||
":androidApp",
|
||||
":core:api",
|
||||
|
||||
Reference in New Issue
Block a user