mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(auto): extract Android Auto into feature:auto module
- Move MeshtasticCarAppService, CarSession, CarScreen from app/auto to the new :feature:auto module (meshtastic.android.library) - Move automotive_app_desc.xml → auto_app_desc.xml (respects resourcePrefix) - Move manifest entries (service + meta-data) into feature module so they merge into app rather than living in the app manifest directly - Fix HostValidator: use ApplicationInfo.FLAG_DEBUGGABLE instead of BuildConfig.DEBUG (library modules don't ship their own BuildConfig) - Fix stale unread counts: replace point-in-time getUnreadCount() with flatMapLatest + per-conversation getUnreadCountFlow() so the car screen invalidates on new messages, not just topology changes - Fix ConversationShortcutManager: replace removeAllDynamicShortcuts + addDynamicShortcuts with pushDynamicShortcut per conversation to preserve usage/ranking history; remove stale shortcuts individually; respect getMaxShortcutCountPerActivity() limit - Fix SHORTCUT_CATEGORY_CONVERSATION: constant lives on ShortcutInfo, not ShortcutManagerCompat - Remove androidx.car.app dependency from :app (now owned by :feature:auto) - Add :feature:auto to settings.gradle.kts and app dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
41b99fd079
commit
0df6d70317
10 changed files with 259 additions and 194 deletions
|
|
@ -235,6 +235,7 @@ dependencies {
|
|||
implementation(projects.feature.settings)
|
||||
implementation(projects.feature.firmware)
|
||||
implementation(projects.feature.wifiProvision)
|
||||
implementation(projects.feature.auto)
|
||||
implementation(projects.feature.widget)
|
||||
|
||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||
|
|
@ -270,7 +271,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -153,25 +153,6 @@
|
|||
android:name="google_analytics_default_allow_analytics_storage"
|
||||
android:value="false" />
|
||||
|
||||
<!-- Android Auto: declare as a messaging/communications app -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- Android Auto Car App Service for browsable messaging UI -->
|
||||
<service
|
||||
android:name="org.meshtastic.app.auto.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="1" />
|
||||
|
||||
<!-- This is the public API for doing mesh radio operations from android apps -->
|
||||
<service
|
||||
android:name="org.meshtastic.core.service.MeshService"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.meshtastic.core.service
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import androidx.core.app.Person
|
||||
|
|
@ -46,9 +47,9 @@ 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.
|
||||
* 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(
|
||||
|
|
@ -61,33 +62,30 @@ class ConversationShortcutManager(
|
|||
private var observeJob: Job? = null
|
||||
|
||||
/**
|
||||
* Starts observing favorite nodes and active channels, publishing shortcuts whenever
|
||||
* the data changes. Call from [MeshService.onCreate].
|
||||
* 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()
|
||||
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.filterIndexed { index, settings ->
|
||||
settings.name.isNotEmpty() || index == 0
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
val channelsFlow =
|
||||
radioConfigRepository.channelSetFlow
|
||||
.map { cs ->
|
||||
cs.settings.filterIndexed { index, settings -> settings.name.isNotEmpty() || index == 0 }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
combine(favoritesFlow, channelsFlow) { favorites, channels ->
|
||||
favorites to channels
|
||||
}.collect { (favorites, channels) ->
|
||||
publishShortcuts(favorites, channels)
|
||||
combine(favoritesFlow, channelsFlow) { favorites, channels -> favorites to channels }
|
||||
.collect { (favorites, channels) -> publishShortcuts(favorites, channels) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops the observation coroutine. Call from [MeshService.onDestroy]. */
|
||||
|
|
@ -104,25 +102,27 @@ class ConversationShortcutManager(
|
|||
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 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()
|
||||
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(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
|
||||
setPackage(context.packageName)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
shortcuts.add(shortcut)
|
||||
}
|
||||
|
|
@ -131,32 +131,44 @@ class ConversationShortcutManager(
|
|||
for ((index, channelSettings) in channels.withIndex()) {
|
||||
val contactKey = "${index}${DataPacket.ID_BROADCAST}"
|
||||
val channelName = channelSettings.name.ifEmpty { "Primary Channel" }
|
||||
val person = Person.Builder()
|
||||
.setName(channelName)
|
||||
.setKey("channel-$index")
|
||||
.build()
|
||||
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()
|
||||
val shortcut =
|
||||
ShortcutInfoCompat.Builder(context, contactKey)
|
||||
.setShortLabel(channelName)
|
||||
.setLongLabel(channelName)
|
||||
.setLocusId(LocusIdCompat(contactKey))
|
||||
.setPerson(person)
|
||||
.setLongLived(true)
|
||||
.setCategories(setOf(ShortcutInfo.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)" }
|
||||
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||
// Remove shortcuts for conversations that are no longer in favorites/channels,
|
||||
// so stale entries don't clutter the share sheet.
|
||||
val currentKeys = shortcuts.map { it.id }.toSet()
|
||||
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
|
||||
if (stale.isNotEmpty()) {
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
|
||||
}
|
||||
// Push each shortcut individually to preserve usage/ranking history.
|
||||
// pushDynamicShortcut upserts without wiping other shortcuts.
|
||||
for (shortcut in shortcuts.take(limit)) {
|
||||
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
|
||||
}
|
||||
val published = shortcuts.size.coerceAtMost(limit)
|
||||
Logger.d {
|
||||
"Published $published conversation shortcuts (${favorites.size} favorites, ${channels.size} channels)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to publish conversation shortcuts" }
|
||||
}
|
||||
|
|
@ -174,12 +186,13 @@ class ConversationShortcutManager(
|
|||
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 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)
|
||||
|
|
|
|||
41
feature/auto/build.gradle.kts
Normal file
41
feature/auto/build.gradle.kts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.koin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.auto"
|
||||
resourcePrefix = "auto_"
|
||||
|
||||
// Car App Library requires API 23+; bump above the app's default minSdk
|
||||
// so we can use conversation shortcuts and LocusId APIs cleanly.
|
||||
defaultConfig { minSdk = 23 }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.repository)
|
||||
|
||||
implementation(libs.androidx.car.app)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.koin.annotations)
|
||||
}
|
||||
41
feature/auto/src/main/AndroidManifest.xml
Normal file
41
feature/auto/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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/>.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Android Auto: declare as a messaging/communications app -->
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/auto_app_desc" />
|
||||
|
||||
<!-- Android Auto Car App Service for browsable messaging UI -->
|
||||
<service
|
||||
android:name="org.meshtastic.feature.auto.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="1" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
* 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.app.auto
|
||||
package org.meshtastic.feature.auto
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import androidx.car.app.CarAppService
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.SessionInfo
|
||||
|
|
@ -24,14 +25,21 @@ 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.
|
||||
* 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 =
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
override fun createHostValidator(): HostValidator {
|
||||
val isDebug = applicationContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||
return if (isDebug) {
|
||||
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
|
||||
} else {
|
||||
HostValidator.Builder(applicationContext)
|
||||
.addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateSession(sessionInfo: SessionInfo): Session =
|
||||
MeshtasticCarSession()
|
||||
override fun onCreateSession(sessionInfo: SessionInfo): Session = MeshtasticCarSession()
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* 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.app.auto
|
||||
package org.meshtastic.feature.auto
|
||||
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
|
|
@ -33,6 +33,8 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
|
@ -86,110 +88,97 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
|||
|
||||
private fun startObserving() {
|
||||
observeJob?.cancel()
|
||||
observeJob = scope.launch {
|
||||
val stateFlow = serviceRepository.connectionState
|
||||
.distinctUntilChanged()
|
||||
observeJob =
|
||||
scope.launch {
|
||||
// serviceRepository.connectionState is a StateFlow — distinctUntilChanged is a no-op on it.
|
||||
val stateFlow = serviceRepository.connectionState
|
||||
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (state, favorites, chs) ->
|
||||
// Build per-conversation unread flows so the car screen invalidates
|
||||
// on new messages, not just on topology/channel changes.
|
||||
val contactKeys =
|
||||
favorites.map { "0${it.user.id}" } +
|
||||
chs.mapIndexed { i, _ -> "${i}${DataPacket.ID_BROADCAST}" }
|
||||
|
||||
val channelsFlow = radioConfigRepository.channelSetFlow
|
||||
.map { cs ->
|
||||
cs.settings.filterIndexed { index, settings ->
|
||||
index == 0 || settings.name.isNotEmpty()
|
||||
if (contactKeys.isEmpty()) {
|
||||
flowOf(Triple(state, favorites, chs) to emptyMap())
|
||||
} else {
|
||||
val unreadFlows =
|
||||
contactKeys.map { key ->
|
||||
packetRepository.getUnreadCountFlow(key).map { count -> key to count }
|
||||
}
|
||||
combine(unreadFlows) { pairs -> Triple(state, favorites, chs) to pairs.toMap() }
|
||||
}
|
||||
}
|
||||
.collect { (triple, counts) ->
|
||||
val (state, favorites, chs) = triple
|
||||
connectionState = state
|
||||
favoriteNodes = favorites
|
||||
channels = chs
|
||||
unreadCounts = counts
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
.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<String, Int>()
|
||||
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",
|
||||
),
|
||||
)
|
||||
listBuilder.addSectionedList(SectionedItemList.create(buildStatusSection(), "Status"))
|
||||
|
||||
// Favorites section
|
||||
val favoritesSection = buildFavoritesSection()
|
||||
if (favoritesSection.items.isNotEmpty()) {
|
||||
listBuilder.addSectionedList(
|
||||
SectionedItemList.create(
|
||||
favoritesSection,
|
||||
"Favorites",
|
||||
),
|
||||
)
|
||||
listBuilder.addSectionedList(SectionedItemList.create(favoritesSection, "Favorites"))
|
||||
}
|
||||
|
||||
// Channels section
|
||||
val channelsSection = buildChannelsSection()
|
||||
if (channelsSection.items.isNotEmpty()) {
|
||||
listBuilder.addSectionedList(
|
||||
SectionedItemList.create(
|
||||
channelsSection,
|
||||
"Channels",
|
||||
),
|
||||
)
|
||||
listBuilder.addSectionedList(SectionedItemList.create(channelsSection, "Channels"))
|
||||
}
|
||||
|
||||
return listBuilder
|
||||
.setTitle("Meshtastic")
|
||||
.setHeaderAction(Action.APP_ICON)
|
||||
.build()
|
||||
return listBuilder.setTitle("Meshtastic").setHeaderAction(Action.APP_ICON).build()
|
||||
}
|
||||
|
||||
private fun buildStatusSection(): ItemList {
|
||||
val statusText = when (connectionState) {
|
||||
is ConnectionState.Connected -> "Connected"
|
||||
is ConnectionState.Disconnected -> "Disconnected"
|
||||
is ConnectionState.DeviceSleep -> "Device Sleeping"
|
||||
is ConnectionState.Connecting -> "Connecting..."
|
||||
}
|
||||
val statusText =
|
||||
when (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()
|
||||
val row =
|
||||
Row.Builder()
|
||||
.setTitle(statusText)
|
||||
.apply { if (subtitle != null) addText(subtitle) }
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
return ItemList.Builder()
|
||||
.addItem(row)
|
||||
.build()
|
||||
return ItemList.Builder().addItem(row).build()
|
||||
}
|
||||
|
||||
private fun buildFavoritesSection(): ItemList {
|
||||
|
|
@ -205,12 +194,7 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
|||
if (unread > 0) append(" · $unread unread")
|
||||
}
|
||||
|
||||
val row = Row.Builder()
|
||||
.setTitle(name)
|
||||
.addText(subtitle)
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
val row = Row.Builder().setTitle(name).addText(subtitle).setBrowsable(false).build()
|
||||
builder.addItem(row)
|
||||
}
|
||||
|
||||
|
|
@ -226,11 +210,12 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
|||
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()
|
||||
val row =
|
||||
Row.Builder()
|
||||
.setTitle(channelName)
|
||||
.apply { if (subtitle.isNotEmpty()) addText(subtitle) }
|
||||
.setBrowsable(false)
|
||||
.build()
|
||||
|
||||
builder.addItem(row)
|
||||
}
|
||||
|
|
@ -240,10 +225,9 @@ class MeshtasticCarScreen(carContext: CarContext) :
|
|||
|
||||
companion object {
|
||||
/**
|
||||
* Android Auto enforces a maximum item count per [ListTemplate] section.
|
||||
* Car API level 1 supports up to 6 items per section.
|
||||
* Android Auto enforces a maximum item count per [ListTemplate] section. Car API level 1 supports up to 6 items
|
||||
* per section.
|
||||
*/
|
||||
private const val MAX_LIST_ITEMS = 6
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -14,18 +14,14 @@
|
|||
* 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.app.auto
|
||||
package org.meshtastic.feature.auto
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.Session
|
||||
|
||||
/**
|
||||
* Android Auto session that hosts the [MeshtasticCarScreen] root screen.
|
||||
*/
|
||||
/** Android Auto session that hosts the [MeshtasticCarScreen] root screen. */
|
||||
class MeshtasticCarSession : Session() {
|
||||
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
return MeshtasticCarScreen(carContext)
|
||||
}
|
||||
override fun onCreateScreen(intent: Intent): Screen = MeshtasticCarScreen(carContext)
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ include(
|
|||
":feature:settings",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":feature:auto",
|
||||
":feature:widget",
|
||||
":desktop",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue