fix(auto): address branch review — cleanup, ProGuard, dedupe, API trim

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Android/sessions/ade4e6ae-8e8e-4651-a985-a8042f86e7c6

Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-17 20:16:24 +00:00 committed by GitHub
parent 67e300da96
commit cb5f11f996
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 102 additions and 412 deletions

View file

@ -1,295 +0,0 @@
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..2a27b96906
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.core.common.di
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
+
+/**
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
+ *
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
+ *
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
+ * and should be used sparingly.
+ */
+interface ApplicationCoroutineScope : CoroutineScope
+
+@Single(binds = [ApplicationCoroutineScope::class])
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
+ override val coroutineContext = SupervisorJob() + ioDispatcher
+}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 231c84d401..5365ab95e2 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import com.eygraber.uri.toKmpUri
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.net.URLEncoder
@Composable
@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
val context = LocalContext.current
return remember(context) {
{ uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val androidUri = uri.toAndroidUri()
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 031e1fe35d..a938f92ea6 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
/** JVM — Reads text from a file URI. */
@Composable
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val file = File(URI(uri.toString()))
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index dc1c459716..f8ff9fcac8 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
+ private val applicationScope: ApplicationCoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
override fun onCleared() {
super.onCleared()
- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
- // is cancelled concurrently.
- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
+ // running even if something tries to cancel it mid-flight.
+ applicationScope.launch(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 4c48a1ced5..030d84effd 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index 7032ed4088..a8eddff838 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..3ef5c44ef4
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.firmware
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+
+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
+ ApplicationCoroutineScope,
+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index acb1545bdd..23a0d03ab2 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
// -----------------------------------------------------------------------
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index c251b4d5ef..315ad1da85 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_export_failed
import org.meshtastic.core.resources.debug_export_success
@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
try {
if (logs.isEmpty()) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9afde85e5f..a28a576788 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
return { fileName -> exportLauncher.launch(fileName) }
}
-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
try {
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
Logger.i { "TAK data package exported successfully to $targetUri" }
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index 5b63cc90a3..a9a7285593 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
return@launch
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
// Run file dialog to ask user where to save
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
fileDialog.file = fileName
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9fb71379fc..bfbb85bc0d 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
if (directory != null && file != null) {
val targetFile = File(directory, file)
val data = dataPackageProvider()
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
}
}

View file

@ -34,6 +34,14 @@
# for auditing. Inspect this file after a release build to see what libraries inject.
-printconfiguration build/outputs/mapping/r8-merged-config.txt
# ---- Android Auto / Car App Library -----------------------------------------
# MeshtasticCarAppService and MeshtasticCarSession are instantiated by class name
# by the Android Auto host. Keep both classes (and their no-arg constructors) so
# release builds aren't broken by R8 tree-shaking.
-keep class org.meshtastic.feature.auto.MeshtasticCarAppService { <init>(); }
-keep class org.meshtastic.feature.auto.MeshtasticCarSession { <init>(); }
# ---- Networking (transitive references from Ktor on Android) ----------------
-dontwarn org.conscrypt.**

View file

@ -19,13 +19,10 @@ 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
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
@ -186,34 +183,6 @@ class ConversationShortcutManager(
}
}
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
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
PersonIconFactory.create(name, backgroundColor, foregroundColor)
}

View file

@ -24,9 +24,7 @@ import android.app.TaskStackBuilder
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
@ -34,7 +32,6 @@ 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
import androidx.core.net.toUri
import kotlinx.coroutines.flow.first
@ -122,8 +119,6 @@ class MeshServiceNotificationsImpl(
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
private const val PERSON_ICON_SIZE = 128
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = ""
@ -537,9 +532,11 @@ class MeshServiceNotificationsImpl(
}
.first()
val unread = history.filter { !it.read }
if (unread.isEmpty()) return
val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed()
// For the brief outgoing-reply confirmation we don't gate on unread state — the
// user just sent something and Android Auto needs to reflect that in the
// MessagingStyle notification regardless of whether other unread messages remain.
// We still cap the displayed history so the notification stays compact.
val displayHistory = history.take(MAX_HISTORY_MESSAGES).reversed()
val dest = if (contactKey.isNotEmpty()) contactKey.substring(1) else contactKey
val isBroadcast = dest == DataPacket.ID_BROADCAST
@ -939,32 +936,8 @@ class MeshServiceNotificationsImpl(
.setContentIntent(contentIntent ?: openAppIntent)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Draw background circle
paint.color = backgroundColor
canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
// Draw initials
paint.color = foregroundColor
paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_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)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
PersonIconFactory.create(name, backgroundColor, foregroundColor)
// endregion

View file

@ -0,0 +1,62 @@
/*
* 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.core.service
import android.graphics.Canvas
import android.graphics.Paint
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
/**
* Renders a circular avatar with a single uppercase initial used for [androidx.core.app.Person]
* icons in MessagingStyle notifications and for conversation shortcut avatars.
*
* Shared by [MeshServiceNotificationsImpl] and [ConversationShortcutManager] to keep the avatar
* appearance consistent across the notification shade and the launcher / Android Auto.
*/
internal object PersonIconFactory {
private const val ICON_SIZE = 128
private const val TEXT_SIZE_RATIO = 0.5f
fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Background circle.
paint.color = backgroundColor
canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
// Single uppercase initial centered on the circle.
paint.color = foregroundColor
paint.textSize = ICON_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)
}
}

View file

@ -15,10 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.koin)
}
plugins { alias(libs.plugins.meshtastic.android.library) }
android {
namespace = "org.meshtastic.feature.auto"
@ -33,7 +30,10 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.kermit)
implementation(libs.koin.annotations)
// KoinComponent for service-locator-style injection inside Car App Library callbacks.
// No @Single/@Factory bindings live in this module, so the meshtastic.koin convention
// plugin (which adds koin-annotations + the KSP compiler) is not needed here.
implementation(libs.koin.core)
testImplementation(kotlin("test"))
testImplementation(libs.kotest.assertions)

View file

@ -74,7 +74,7 @@ import org.meshtastic.core.repository.ServiceRepository
*
* When the user taps a [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification in the Android Auto notification shade the host calls
* [MeshtasticCarSession.onNewIntent] which delegates to [selectContactKey] to switch to the
* [MeshtasticCarSession.onNewIntent] which delegates to [selectMessagesTab] to switch to the
* Messages tab.
*/
class MeshtasticCarScreen(carContext: CarContext) :
@ -252,14 +252,15 @@ class MeshtasticCarScreen(carContext: CarContext) :
}
/**
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation notification
* in the Android Auto notification shade. Switches to [TAB_MESSAGES] regardless of whether
* the originating contact is a channel broadcast or a DM, because both appear in the same tab.
* Called by [MeshtasticCarSession.onNewIntent] when the user taps a conversation
* notification in the Android Auto notification shade. Switches to [TAB_MESSAGES]
* channels and DMs both live in the same tab, so no per-key handling is required.
*
* The [contactKey] parameter is accepted for API symmetry with the session and may be used in
* the future to scroll the Messages list to the tapped conversation.
* `androidx.car.app.model.ListTemplate` does not currently expose a programmatic scroll
* API, so we cannot focus a specific conversation row. If/when a scroll API is added,
* the contactKey can be threaded through `MeshtasticCarSession.onNewIntent`.
*/
fun selectContactKey(@Suppress("UNUSED_PARAMETER") contactKey: String) {
fun selectMessagesTab() {
activeTabId = TAB_MESSAGES
invalidate()
}

View file

@ -25,31 +25,25 @@ class MeshtasticCarSession : Session() {
override fun onCreateScreen(intent: Intent): Screen {
val screen = MeshtasticCarScreen(carContext)
handleIntent(intent, screen)
applyIntent(intent, screen)
return screen
}
/**
* Called by the Android Auto host when the session is re-activated from an
* existing [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle]
* notification tap or a launcher shortcut.
* Called by the Android Auto host when the session is re-activated from an existing
* [MessagingStyle][androidx.core.app.NotificationCompat.MessagingStyle] notification tap or a
* launcher shortcut. Switches the root screen to the Messages tab.
*
* Parses the conversation [contactKey] from the deep-link URI
* (`meshtastic://messages/<contactKey>`) and delegates to
* [MeshtasticCarScreen.selectContactKey] so the correct tab is pre-selected.
* The deep-link URI (`meshtastic://meshtastic/messages/<contactKey>`) carries the originating
* contact key, but `androidx.car.app.model.ListTemplate` does not currently expose a
* programmatic scroll API, so we cannot focus a specific conversation row.
*/
override fun onNewIntent(intent: Intent) {
val screen = screenManager.top as? MeshtasticCarScreen ?: return
handleIntent(intent, screen)
applyIntent(intent, screen)
}
private fun handleIntent(intent: Intent, screen: MeshtasticCarScreen) {
// Deep-link URIs from MessagingStyle notifications look like:
// meshtastic://messages/0!abcd1234 (DM: channel=0, nodeId=!abcd1234)
// meshtastic://messages/2^all (channel broadcast, e.g. contactKey "2^all")
// Both channels and DMs now live in the same Messages tab, so we simply
// switch to that tab regardless of the contact type.
val contactKey = intent.data?.lastPathSegment ?: return
screen.selectContactKey(contactKey)
private fun applyIntent(intent: Intent, screen: MeshtasticCarScreen) {
if (intent.data?.lastPathSegment != null) screen.selectMessagesTab()
}
}

View file

@ -1,22 +0,0 @@
<?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/>.
-->
<automotiveApp>
<uses name="notification" />
<uses name="template" />
</automotiveApp>