diff --git a/feature/car/build.gradle.kts b/feature/car/build.gradle.kts index 980da6fa3..43b532bf7 100644 --- a/feature/car/build.gradle.kts +++ b/feature/car/build.gradle.kts @@ -24,6 +24,8 @@ plugins { android { namespace = "org.meshtastic.feature.car" + buildFeatures { buildConfig = true } + defaultConfig { minSdk = 23 consumerProguardFiles("proguard-rules.pro") diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt index 433401488..1f0d14b67 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/alerts/EmergencyHandler.kt @@ -37,23 +37,28 @@ import org.meshtastic.feature.car.model.EmergencyAlert @Single class EmergencyHandler { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var scope: CoroutineScope? = null private val _activeAlerts = MutableStateFlow>(emptyList()) val activeAlerts: StateFlow> = _activeAlerts.asStateFlow() private var toneGenerator: ToneGenerator? = null fun startCollecting(emergencyFlow: Flow) { - scope.launch { - emergencyFlow.collect { alert -> - addAlert(alert) - playEmergencyTone() + scope?.cancel() + scope = + CoroutineScope(SupervisorJob() + Dispatchers.Main).also { newScope -> + newScope.launch { + emergencyFlow.collect { alert -> + addAlert(alert) + playEmergencyTone() + } + } } - } } fun stopCollecting() { - scope.cancel() + scope?.cancel() + scope = null toneGenerator?.release() toneGenerator = null } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt index a2fdb3a01..afda6c2f8 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusPanel.kt @@ -16,10 +16,6 @@ */ 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 @@ -34,7 +30,6 @@ import org.meshtastic.feature.car.model.ConnectionStatus @Single class MeshStatusPanel { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val _state = MutableStateFlow( CarSessionState( @@ -86,10 +81,6 @@ class MeshStatusPanel { 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 diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt index f14f752a4..4ea6192c6 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/panels/MeshStatusSessionWiring.kt @@ -45,6 +45,5 @@ class MeshStatusSessionWiring(private val panel: MeshStatusPanel) { connectionJob?.cancel() nodeCountJob?.cancel() messageTimeJob?.cancel() - panel.destroy() } } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt index 48f02f56b..4848f03ac 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/DisconnectedScreen.kt @@ -37,13 +37,13 @@ class DisconnectedScreen(carContext: CarContext) : Screen(carContext) { .addRow( Row.Builder() .setTitle(carContext.getString(R.string.car_disconnected)) - .addText("Radio connection lost. Showing cached data.") + .addText(carContext.getString(R.string.car_disconnected_body)) .build(), ) .addRow( Row.Builder() - .setTitle("Reconnecting...") - .addText("The app will automatically reconnect when the radio is available.") + .setTitle(carContext.getString(R.string.car_reconnecting_title)) + .addText(carContext.getString(R.string.car_reconnecting_body)) .build(), ) .build(), diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt index 269ac6c44..864b41b4a 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt @@ -39,15 +39,38 @@ class NodeDetailScreen( val paneBuilder = Pane.Builder() - paneBuilder.addRow(Row.Builder().setTitle("Signal").addText(formatSignal(node.signalQuality)).build()) + paneBuilder.addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_status_signal)) + .addText(formatSignal(node.signalQuality)) + .build(), + ) node.batteryPercent?.let { battery -> - paneBuilder.addRow(Row.Builder().setTitle("Battery").addText("$battery%").build()) + paneBuilder.addRow( + Row.Builder().setTitle(carContext.getString(R.string.car_status_battery)).addText("$battery%").build(), + ) } - paneBuilder.addRow(Row.Builder().setTitle("Last Heard").addText(formatLastHeard(node.lastHeard)).build()) + paneBuilder.addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_status_last_heard)) + .addText(formatLastHeard(node.lastHeard)) + .build(), + ) - paneBuilder.addRow(Row.Builder().setTitle("Status").addText(if (node.isOnline) "Online" else "Offline").build()) + paneBuilder.addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.car_status_status)) + .addText( + if (node.isOnline) { + carContext.getString(R.string.car_status_online) + } else { + carContext.getString(R.string.car_status_offline) + }, + ) + .build(), + ) paneBuilder.addAction( Action.Builder() @@ -61,10 +84,18 @@ class NodeDetailScreen( .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 buildErrorTemplate(): Template = PaneTemplate.Builder( + Pane.Builder() + .addRow(Row.Builder().setTitle(carContext.getString(R.string.car_node_not_found)).build()) + .build(), + ) + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.car_error)) + .setStartHeaderAction(Action.BACK) + .build(), + ) + .build() private fun formatSignal(quality: SignalQuality): String = when (quality) { SignalQuality.EXCELLENT -> carContext.getString(R.string.car_signal_excellent) @@ -75,13 +106,18 @@ class NodeDetailScreen( } private fun formatLastHeard(epochMillis: Long): String { - if (epochMillis == 0L) return "Never" + if (epochMillis == 0L) return carContext.getString(R.string.car_time_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" + elapsed < MILLIS_PER_MINUTE -> carContext.getString(R.string.car_time_just_now) + + elapsed < MILLIS_PER_HOUR -> + carContext.getString(R.string.car_time_minutes_ago, (elapsed / MILLIS_PER_MINUTE).toInt()) + + elapsed < MILLIS_PER_DAY -> + carContext.getString(R.string.car_time_hours_ago, (elapsed / MILLIS_PER_HOUR).toInt()) + + else -> carContext.getString(R.string.car_time_days_ago, (elapsed / MILLIS_PER_DAY).toInt()) } } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt index cbcd30d61..edfc585be 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt @@ -49,6 +49,7 @@ import org.meshtastic.feature.car.model.NodeUi import org.meshtastic.feature.car.model.SignalQuality import org.meshtastic.feature.car.model.TopologyHeader import org.meshtastic.feature.car.util.CarTtsEngine +import java.util.concurrent.ConcurrentHashMap /** Snapshot of a message for car display (avoids leaking domain models to UI). */ data class MessageSnapshot( @@ -106,7 +107,7 @@ class CarStateCoordinator( private val _quickChatActions = MutableStateFlow>(emptyList()) val quickChatActions: StateFlow> = _quickChatActions.asStateFlow() - private var selectedChannelIndex = 0 + private val selectedChannel = MutableStateFlow(0) fun start() { collectConnectionState() @@ -116,7 +117,7 @@ class CarStateCoordinator( } fun selectChannel(index: Int) { - selectedChannelIndex = index + selectedChannel.value = index _messagingState.value = _messagingState.value.copy(selectedChannelIndex = index) } @@ -145,7 +146,7 @@ class CarStateCoordinator( to = contactKey, bytes = text.encodeToByteArray().toByteString(), dataType = DATA_TYPE_TEXT, - channel = selectedChannelIndex, + channel = selectedChannel.value, ) commandSender.sendData(packet) } @@ -159,7 +160,7 @@ class CarStateCoordinator( } } - private val messagesCache = mutableMapOf>() + private val messagesCache = ConcurrentHashMap>() fun cacheMessages(contactKey: String, messages: List) { messagesCache[contactKey] = messages @@ -172,6 +173,7 @@ class CarStateCoordinator( fun destroy() { scope.cancel() + ttsEngine.shutdown() } private fun collectConnectionState() { @@ -243,7 +245,7 @@ class CarStateCoordinator( _messagingState.value = MessagingUiState( channels = channels, - selectedChannelIndex = selectedChannelIndex, + selectedChannelIndex = selectedChannel.value, conversations = conversations, emergencySpotlight = null, ) diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt index 1a95723a7..d44f70263 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarAppService.kt @@ -20,10 +20,16 @@ import androidx.car.app.CarAppService import androidx.car.app.Session import androidx.car.app.SessionInfo import androidx.car.app.validation.HostValidator +import org.meshtastic.feature.car.BuildConfig +import org.meshtastic.feature.car.R class MeshtasticCarAppService : CarAppService() { - override fun createHostValidator(): HostValidator = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + override fun createHostValidator(): HostValidator = if (BuildConfig.DEBUG) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(applicationContext).addAllowedHosts(R.array.car_hosts_allowlist).build() + } override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession() } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt index c02f90e1e..3ea234c31 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt @@ -20,6 +20,8 @@ import android.content.Intent import android.content.res.Configuration import androidx.car.app.Screen import androidx.car.app.Session +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.flow.emptyFlow import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -42,6 +44,15 @@ class MeshtasticCarSession : stateCoordinator.start() // Emergency flow wired to emptyFlow() until emergency packet detection is implemented emergencyWiring.attach(emptyFlow()) + + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + destroy() + } + }, + ) + return HomeScreen(carContext, stateCoordinator) } @@ -53,7 +64,7 @@ class MeshtasticCarSession : // Handle theme/density changes — templates auto-update } - fun destroy() { + private fun destroy() { emergencyWiring.detach() stateCoordinator.destroy() crashlyticsCarTagger.setCarSession(false) diff --git a/feature/car/src/main/res/values/hosts_allowlist.xml b/feature/car/src/main/res/values/hosts_allowlist.xml new file mode 100644 index 000000000..b623ae442 --- /dev/null +++ b/feature/car/src/main/res/values/hosts_allowlist.xml @@ -0,0 +1,11 @@ + + + + + com.google.android.projection.gearhead + + com.android.car.carlauncher + + com.google.android.apps.auto + + diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml index f6708db10..b0a99500e 100644 --- a/feature/car/src/main/res/values/strings.xml +++ b/feature/car/src/main/res/values/strings.xml @@ -1,30 +1,46 @@ Meshtastic - Messages - Nodes - Disconnected - Radio connection lost. Will reconnect automatically. + Battery: %d%% Connecting… - No channels configured - No nodes heard - No messages yet - %d nodes online - Last msg: %s + Disconnected + Radio connection lost. Showing cached data. Emergency Alert - Reply + Error + Last heard: %s + Last msg: %s + Message + Message exceeds 237 bytes + No channels configured + No messages yet + No nodes heard + Node not found + %d nodes online + Open Meshtastic on your phone to configure channels and connect to a radio. + Setup Required Quick Reply Read Aloud - Message + Radio connection lost. Will reconnect automatically. + Reconnecting… + The app will automatically reconnect when the radio is available. Excellent - Good Fair + Good Poor Unknown - Battery: %d%% - Last heard: %s - Setup Required - Open Meshtastic on your phone to configure channels and connect to a radio. - Message exceeds 237 bytes + Battery + Last Heard + Offline + Online + Signal + Status + Messages + Nodes + %dd ago + %dh ago + Just now + %dm ago + Never %d unread + Reply