diff --git a/.pr5167.diff b/.pr5167.diff deleted file mode 100644 index d0a809449..000000000 --- a/.pr5167.diff +++ /dev/null @@ -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 . -+ */ -+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.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 . -+ */ -+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) = -- 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 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}" } - } - } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index de2b3144c..b1fbcdace 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { (); } +-keep class org.meshtastic.feature.auto.MeshtasticCarSession { (); } + # ---- Networking (transitive references from Ktor on Android) ---------------- -dontwarn org.conscrypt.** 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 index db6fd37d4..d6393a4e8 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutManager.kt @@ -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) } 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 37764047f..0e390ed0a 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 @@ -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 diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt new file mode 100644 index 000000000..84a015d20 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt @@ -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 . + */ +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) + } +} diff --git a/feature/auto/build.gradle.kts b/feature/auto/build.gradle.kts index 692285118..c9da00795 100644 --- a/feature/auto/build.gradle.kts +++ b/feature/auto/build.gradle.kts @@ -15,10 +15,7 @@ * along with this program. If not, see . */ -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) diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt index d84ab2ad2..f8e80c667 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarScreen.kt @@ -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() } diff --git a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt index 13b3c5d3b..d9d42afbe 100644 --- a/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt +++ b/feature/auto/src/main/kotlin/org/meshtastic/feature/auto/MeshtasticCarSession.kt @@ -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/`) and delegates to - * [MeshtasticCarScreen.selectContactKey] so the correct tab is pre-selected. + * The deep-link URI (`meshtastic://meshtastic/messages/`) 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() } } diff --git a/feature/auto/src/main/res/xml/auto_app_desc.xml b/feature/auto/src/main/res/xml/auto_app_desc.xml deleted file mode 100644 index b84cd9041..000000000 --- a/feature/auto/src/main/res/xml/auto_app_desc.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - -