diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d239d0530..52ffd9593 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -270,6 +270,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.kermit) implementation(libs.kotlinx.datetime) + implementation(libs.androidx.car.app) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.glance.preview) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7d2ce900..758a266e0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -153,6 +153,25 @@ android:name="google_analytics_default_allow_analytics_storage" android:value="false" /> + + + + + + + + + + + + + . + */ +package org.meshtastic.app.auto + +import android.content.Intent +import android.content.pm.ApplicationInfo +import androidx.car.app.CarAppService +import androidx.car.app.Session +import androidx.car.app.SessionInfo +import androidx.car.app.validation.HostValidator + +/** + * Entry point for the Meshtastic Android Auto experience. + * + * Registers with the Android Auto host to provide a browsable list of + * favorite contacts and active channels for messaging. + */ +class MeshtasticCarAppService : CarAppService() { + + override fun createHostValidator(): HostValidator { + return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } + } + + override fun onCreateSession(sessionInfo: SessionInfo): Session { + return MeshtasticCarSession() + } + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onCreateSession(): Session { + return MeshtasticCarSession() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt new file mode 100644 index 000000000..e44a2d621 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarScreen.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.CarIcon +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.SectionedItemList +import androidx.car.app.model.Template +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.ChannelSettings + +/** + * Root screen displayed in Android Auto. + * + * Shows three sections mirroring the iOS CarPlay implementation: + * - **Status**: Connection state and active device name + * - **Favorites**: Favorited mesh nodes with unread message counts + * - **Channels**: Active channels with unread message counts + */ +class MeshtasticCarScreen(carContext: CarContext) : + Screen(carContext), + KoinComponent, + DefaultLifecycleObserver { + + private val nodeRepository: NodeRepository by inject() + private val radioConfigRepository: RadioConfigRepository by inject() + private val packetRepository: PacketRepository by inject() + private val serviceRepository: ServiceRepository by inject() + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var observeJob: Job? = null + + private var connectionState: ConnectionState = ConnectionState.Disconnected() + private var favoriteNodes: List = emptyList() + private var channels: List = emptyList() + private var unreadCounts: Map = emptyMap() + + init { + lifecycle.addObserver(this) + } + + override fun onCreate(owner: LifecycleOwner) { + startObserving() + } + + override fun onDestroy(owner: LifecycleOwner) { + scope.cancel() + } + + private fun startObserving() { + observeJob?.cancel() + observeJob = scope.launch { + val stateFlow = serviceRepository.connectionState + .distinctUntilChanged() + + val favoritesFlow = nodeRepository.nodeDBbyNum + .map { nodes -> + val myNum = nodeRepository.myNodeInfo.value?.myNodeNum + nodes.values + .filter { it.isFavorite && !it.isIgnored && it.num != myNum } + .sortedBy { it.user.long_name } + } + .distinctUntilChanged() + + val channelsFlow = radioConfigRepository.channelSetFlow + .map { cs -> + cs.settings.filterIndexed { index, settings -> + index == 0 || settings.name.isNotEmpty() + } + } + .distinctUntilChanged() + + combine(stateFlow, favoritesFlow, channelsFlow) { state, favorites, chs -> + Triple(state, favorites, chs) + }.collect { (state, favorites, chs) -> + connectionState = state + favoriteNodes = favorites + channels = chs + + // Collect unread counts for all conversations + val counts = mutableMapOf() + for (node in favorites) { + val key = "0${node.user.id}" + counts[key] = packetRepository.getUnreadCount(key) + } + for ((index, _) in chs.withIndex()) { + val key = "${index}${DataPacket.ID_BROADCAST}" + counts[key] = packetRepository.getUnreadCount(key) + } + unreadCounts = counts + + invalidate() + } + } + } + + override fun onGetTemplate(): Template { + val listBuilder = ListTemplate.Builder() + + // Status section + listBuilder.addSectionedList( + SectionedItemList.create( + buildStatusSection(), + "Status", + ), + ) + + // Favorites section + val favoritesSection = buildFavoritesSection() + if (favoritesSection.items.isNotEmpty()) { + listBuilder.addSectionedList( + SectionedItemList.create( + favoritesSection, + "Favorites", + ), + ) + } + + // Channels section + val channelsSection = buildChannelsSection() + if (channelsSection.items.isNotEmpty()) { + listBuilder.addSectionedList( + SectionedItemList.create( + channelsSection, + "Channels", + ), + ) + } + + return listBuilder + .setTitle("Meshtastic") + .setHeaderAction(Action.APP_ICON) + .build() + } + + private fun buildStatusSection(): ItemList { + val statusText = when (val state = connectionState) { + is ConnectionState.Connected -> "Connected" + is ConnectionState.Disconnected -> "Disconnected" + is ConnectionState.DeviceSleep -> "Device Sleeping" + is ConnectionState.Connecting -> "Connecting..." + } + + val deviceName = nodeRepository.ourNodeInfo.value?.user?.long_name ?: "" + val subtitle = if (deviceName.isNotEmpty()) deviceName else null + + val row = Row.Builder() + .setTitle(statusText) + .apply { if (subtitle != null) addText(subtitle) } + .setBrowsable(false) + .build() + + return ItemList.Builder() + .addItem(row) + .build() + } + + private fun buildFavoritesSection(): ItemList { + val builder = ItemList.Builder() + + for (node in favoriteNodes) { + val contactKey = "0${node.user.id}" + val unread = unreadCounts[contactKey] ?: 0 + val name = node.user.long_name.ifEmpty { node.user.short_name } + val subtitle = buildString { + append(node.user.short_name) + if (node.hopsAway >= 0) append(" · ${node.hopsAway} hops") + if (unread > 0) append(" · $unread unread") + } + + val row = Row.Builder() + .setTitle(name) + .addText(subtitle) + .setBrowsable(false) + .build() + + builder.addItem(row) + } + + return builder.build() + } + + private fun buildChannelsSection(): ItemList { + val builder = ItemList.Builder() + + for ((index, channelSettings) in channels.withIndex()) { + val contactKey = "${index}${DataPacket.ID_BROADCAST}" + val unread = unreadCounts[contactKey] ?: 0 + val channelName = channelSettings.name.ifEmpty { "Primary Channel" } + val subtitle = if (unread > 0) "$unread unread" else "" + + val row = Row.Builder() + .setTitle(channelName) + .apply { if (subtitle.isNotEmpty()) addText(subtitle) } + .setBrowsable(false) + .build() + + builder.addItem(row) + } + + return builder.build() + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt new file mode 100644 index 000000000..4a405cafd --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/auto/MeshtasticCarSession.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.auto + +import android.content.Intent +import androidx.car.app.Screen +import androidx.car.app.Session + +/** + * Android Auto session that hosts the [MeshtasticCarScreen] root screen. + */ +class MeshtasticCarSession : Session() { + + override fun onCreateScreen(intent: Intent): Screen { + return MeshtasticCarScreen(carContext) + } +} diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 000000000..b84cd9041 --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt new file mode 100644 index 000000000..405a20f2c --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import android.content.Context +import android.content.Intent +import android.graphics.Canvas +import android.graphics.Paint +import androidx.core.app.Person +import androidx.core.content.LocusIdCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.IconCompat +import androidx.core.net.toUri +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.Node +import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.proto.ChannelSettings + +/** + * Publishes dynamic shortcuts for favorited nodes and active channels. + * + * These shortcuts enable Android Auto (and the launcher) to surface Meshtastic conversations + * as share targets and messaging destinations. Each shortcut is linked to a conversation + * via [LocusIdCompat] so that notifications and the car messaging UI can associate them. + */ +@Single +class ConversationShortcutManager( + private val context: Context, + private val nodeRepository: NodeRepository, + private val radioConfigRepository: RadioConfigRepository, + private val dispatchers: CoroutineDispatchers, +) { + + private var observeJob: Job? = null + + /** + * Starts observing favorite nodes and active channels, publishing shortcuts whenever + * the data changes. Call from [MeshService.onCreate]. + */ + fun startObserving(scope: CoroutineScope) { + observeJob?.cancel() + observeJob = scope.launch(dispatchers.io) { + val favoritesFlow = nodeRepository.nodeDBbyNum + .map { nodes -> + nodes.values.filter { it.isFavorite && !it.isIgnored } + .sortedBy { it.user.long_name } + } + .distinctUntilChanged() + + val channelsFlow = radioConfigRepository.channelSetFlow + .map { cs -> cs.settings.filter { it.name.isNotEmpty() || cs.settings.indexOf(it) == 0 } } + .distinctUntilChanged() + + combine(favoritesFlow, channelsFlow) { favorites, channels -> + favorites to channels + }.collect { (favorites, channels) -> + publishShortcuts(favorites, channels) + } + } + } + + /** Stops the observation coroutine. Call from [MeshService.onDestroy]. */ + fun stopObserving() { + observeJob?.cancel() + observeJob = null + } + + private fun publishShortcuts(favorites: List, channels: List) { + val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum + val shortcuts = mutableListOf() + + // Favorite node shortcuts (direct message conversations) + for (node in favorites) { + if (node.num == myNodeNum) continue + val contactKey = "0${node.user.id}" + val person = Person.Builder() + .setName(node.user.long_name) + .setKey(node.user.id) + .setIcon(createPersonIcon(node.user.short_name, node.colors.second, node.colors.first)) + .build() + + val shortcut = ShortcutInfoCompat.Builder(context, contactKey) + .setShortLabel(node.user.long_name.ifEmpty { node.user.short_name }) + .setLongLabel(node.user.long_name.ifEmpty { node.user.short_name }) + .setLocusId(LocusIdCompat(contactKey)) + .setPerson(person) + .setLongLived(true) + .setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION)) + .setIntent( + Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply { + setPackage(context.packageName) + }, + ) + .build() + + shortcuts.add(shortcut) + } + + // Channel shortcuts (broadcast conversations) + for ((index, channelSettings) in channels.withIndex()) { + val contactKey = "${index}${org.meshtastic.core.model.DataPacket.ID_BROADCAST}" + val channelName = channelSettings.name.ifEmpty { "Primary Channel" } + val person = Person.Builder() + .setName(channelName) + .setKey("channel-$index") + .build() + + val shortcut = ShortcutInfoCompat.Builder(context, contactKey) + .setShortLabel(channelName) + .setLongLabel(channelName) + .setLocusId(LocusIdCompat(contactKey)) + .setPerson(person) + .setLongLived(true) + .setCategories(setOf(ShortcutManagerCompat.SHORTCUT_CATEGORY_CONVERSATION)) + .setIntent( + Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply { + setPackage(context.packageName) + }, + ) + .build() + + shortcuts.add(shortcut) + } + + try { + ShortcutManagerCompat.removeAllDynamicShortcuts(context) + ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts) + Logger.d { "Published ${shortcuts.size} conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)" } + } catch (e: Exception) { + Logger.e(e) { "Failed to publish conversation shortcuts" } + } + } + + private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat { + val size = ICON_SIZE + val bitmap = createBitmap(size, size) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + paint.color = backgroundColor + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) + + paint.color = foregroundColor + paint.textSize = size * TEXT_SIZE_RATIO + paint.textAlign = Paint.Align.CENTER + val initial = if (name.isNotEmpty()) { + val codePoint = name.codePointAt(0) + String(Character.toChars(codePoint)).uppercase() + } else { + "?" + } + val xPos = canvas.width / 2f + val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f) + canvas.drawText(initial, xPos, yPos, paint) + + return IconCompat.createWithBitmap(bitmap) + } + + companion object { + private const val ICON_SIZE = 128 + private const val TEXT_SIZE_RATIO = 0.5f + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 5869ce94f..155d87839 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -76,6 +76,8 @@ class MeshService : Service() { private val notifications: MeshServiceNotifications by inject() + private val shortcutManager: ConversationShortcutManager by inject() + /** Android-typed accessor for the foreground service notification. */ private val androidNotifications: MeshServiceNotificationsImpl get() = notifications as MeshServiceNotificationsImpl @@ -118,6 +120,7 @@ class MeshService : Service() { try { orchestrator.start() + shortcutManager.startObserving(serviceScope) isServiceInitialized = true } catch (e: IllegalStateException) { // Koin throws IllegalStateException when the DI graph is not yet initialized. @@ -209,6 +212,7 @@ class MeshService : Service() { Logger.i { "Destroying mesh service" } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) if (isServiceInitialized) { + shortcutManager.stopObserving() orchestrator.stop() } serviceJob.cancel() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 211e3b9c4..d3a6dc590 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -32,6 +32,7 @@ import android.media.RingtoneManager import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.content.LocusIdCompat import androidx.core.content.getSystemService import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat @@ -622,6 +623,8 @@ class MeshServiceNotificationsImpl( .setAutoCancel(true) .setStyle(style) .setGroup(GROUP_KEY_MESSAGES) + .setShortcutId(contactKey) + .setLocusId(LocusIdCompat(contactKey)) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setWhen(lastMessage.receivedTime) .setShowWhen(true) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 668ed133a..5098b40b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ accompanist = "0.37.3" # androidx datastore = "1.2.1" +car-app = "1.7.0" glance = "1.2.0-rc01" lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha03" @@ -92,6 +93,7 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = " androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.0" } androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.18.0" } +androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" } 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" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }