From 145cde93930d1d246d64769566c92a163c1cf9ec Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 26 Feb 2026 07:26:50 -0600 Subject: [PATCH] chore(deps): bump deps to take advantage of new functionality (#4658) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/widget/LocalStatsWidget.kt | 156 ++++++++++++----- .../mesh/widget/LocalStatsWidgetState.kt | 159 +++++------------- .../core/network/di/GoogleNetworkModule.kt | 23 +-- .../core/network/di/NetworkModule.kt | 25 ++- .../meshtastic/core/resources/ContextExt.kt | 8 +- .../core/ui/util/ContextExtensions.kt | 6 +- feature/firmware/build.gradle.kts | 2 + .../feature/firmware/FirmwareFileHandler.kt | 31 ++-- .../map/component/NodeClusterMarkers.kt | 32 ++-- .../feature/settings/debugging/Debug.kt | 22 ++- gradle/libs.versions.toml | 3 +- 11 files changed, 230 insertions(+), 237 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt index 2be3f1878..7de8359eb 100644 --- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt +++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt @@ -67,13 +67,28 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_utilization import org.meshtastic.core.resources.battery import org.meshtastic.core.resources.channel_utilization -import org.meshtastic.core.resources.getStringSuspend +import org.meshtastic.core.resources.connecting +import org.meshtastic.core.resources.device_sleeping +import org.meshtastic.core.resources.disconnected +import org.meshtastic.core.resources.local_stats_bad +import org.meshtastic.core.resources.local_stats_diagnostics_prefix +import org.meshtastic.core.resources.local_stats_dropped +import org.meshtastic.core.resources.local_stats_heap +import org.meshtastic.core.resources.local_stats_heap_value +import org.meshtastic.core.resources.local_stats_noise +import org.meshtastic.core.resources.local_stats_relays +import org.meshtastic.core.resources.local_stats_traffic +import org.meshtastic.core.resources.local_stats_updated_at import org.meshtastic.core.resources.meshtastic_app_name import org.meshtastic.core.resources.nodes +import org.meshtastic.core.resources.powered import org.meshtastic.core.resources.refresh import org.meshtastic.core.resources.updated import org.meshtastic.core.resources.uptime @@ -129,11 +144,11 @@ class LocalStatsWidget : GlanceAppWidget() { titleBar = { TitleBar( startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon), - title = state.appName, + title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh), - contentDescription = state.refreshLabel, + contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, ) @@ -154,6 +169,7 @@ class LocalStatsWidget : GlanceAppWidget() { } @Composable + @Suppress("LongMethod", "MagicNumber") private fun FullStatsContent(state: LocalStatsWidgetUiState) { val size = LocalSize.current val isNarrow = size.width < 160.dp @@ -168,13 +184,20 @@ class LocalStatsWidget : GlanceAppWidget() { state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) } } Spacer(GlanceModifier.width(8.dp)) - StatRow( - label = state.batteryLabel, - value = state.batteryValue, - progress = state.batteryProgress, - isSmall = isSmall, - modifier = GlanceModifier.defaultWeight(), - ) + if (state.hasBattery) { + val isPowered = state.batteryLevel > 100 + val batteryValue = + if (isPowered) stringResource(Res.string.powered) else "${state.batteryLevel}%" + StatRow( + label = stringResource(Res.string.battery), + value = batteryValue, + progress = state.batteryProgress, + isSmall = isSmall, + modifier = GlanceModifier.defaultWeight(), + ) + } else { + Spacer(GlanceModifier.defaultWeight()) + } } Spacer(GlanceModifier.height(2.dp)) @@ -183,15 +206,15 @@ class LocalStatsWidget : GlanceAppWidget() { Row(modifier = GlanceModifier.fillMaxWidth()) { StatRow( - label = state.channelUtilizationLabel, - value = state.channelUtilizationValue, + label = stringResource(Res.string.channel_utilization), + value = "%.1f%%".format(state.channelUtilization), progress = state.channelUtilizationProgress, isSmall = isSmall, modifier = GlanceModifier.defaultWeight().padding(end = 4.dp), ) StatRow( - label = state.airUtilizationLabel, - value = state.airUtilizationValue, + label = stringResource(Res.string.air_utilization), + value = "%.1f%%".format(state.airUtilization), progress = state.airUtilizationProgress, isSmall = isSmall, modifier = GlanceModifier.defaultWeight().padding(start = 4.dp), @@ -201,17 +224,53 @@ class LocalStatsWidget : GlanceAppWidget() { // Detailed Traffic/Relay Stats Spacer(GlanceModifier.height(2.dp)) Column(modifier = GlanceModifier.fillMaxWidth()) { - state.trafficText?.let { StatText(it, isSmall) } - state.relayText?.let { StatText(it, isSmall) } - state.diagnosticsText?.let { StatText(it, isSmall) } - state.heapText?.let { + if (state.hasStats) { + StatText( + stringResource( + Res.string.local_stats_traffic, + state.numPacketsTx, + state.numPacketsRx, + state.numRxDupe, + ), + isSmall, + ) + if (state.numTxRelay > 0 || state.numTxRelayCanceled > 0) { + StatText( + stringResource( + Res.string.local_stats_relays, + state.numTxRelay, + state.numTxRelayCanceled, + ), + isSmall, + ) + } + + val diag = mutableListOf() + if (state.noiseFloor != 0) { + diag.add(stringResource(Res.string.local_stats_noise, state.noiseFloor)) + } + if (state.numPacketsRxBad > 0) { + diag.add(stringResource(Res.string.local_stats_bad, state.numPacketsRxBad)) + } + if (state.numTxDropped > 0) { + diag.add(stringResource(Res.string.local_stats_dropped, state.numTxDropped)) + } + if (diag.isNotEmpty()) { + StatText( + stringResource(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | ")), + isSmall, + ) + } + val heapProgress = if (state.heapTotalBytes > 0) { state.heapFreeBytes.toFloat() / state.heapTotalBytes } else { 0f } - StatRow(it, state.heapValue, heapProgress, isSmall) + val heapValue = + stringResource(Res.string.local_stats_heap_value, state.heapFreeBytes, state.heapTotalBytes) + StatRow(stringResource(Res.string.local_stats_heap), heapValue, heapProgress, isSmall) } } } @@ -246,8 +305,15 @@ class LocalStatsWidget : GlanceAppWidget() { modifier = GlanceModifier.size(32.dp), ) } + val statusText = + when (state.connectionState) { + is ConnectionState.Disconnected -> stringResource(Res.string.disconnected) + is ConnectionState.Connecting -> stringResource(Res.string.connecting) + is ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping) + is ConnectionState.Connected -> "" + } Text( - text = state.statusText, + text = statusText, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, @@ -258,6 +324,7 @@ class LocalStatsWidget : GlanceAppWidget() { } } + @Suppress("LongMethod") @Composable private fun Footer(state: LocalStatsWidgetUiState) { Column(modifier = GlanceModifier.fillMaxWidth()) { @@ -267,11 +334,11 @@ class LocalStatsWidget : GlanceAppWidget() { ) { Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) { Text( - text = state.nodesLabel, + text = stringResource(Res.string.nodes), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), ) Text( - text = state.nodeCountText, + text = "${state.onlineNodes}/${state.totalNodes}", maxLines = 1, style = TextStyle( @@ -283,11 +350,11 @@ class LocalStatsWidget : GlanceAppWidget() { } Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) { Text( - text = state.uptimeLabel, + text = stringResource(Res.string.uptime), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), ) Text( - text = state.uptimeText, + text = formatUptime(state.uptimeSecs.toInt()), maxLines = 1, style = TextStyle( @@ -299,11 +366,17 @@ class LocalStatsWidget : GlanceAppWidget() { } } Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + val updatedLabel = stringResource(Res.string.updated) + val updatedText = + stringResource( + Res.string.local_stats_updated_at, + DateFormatter.formatShortDate(state.updateTimeMillis), + ) val footerText = - if (state.updatedLabel.isNotEmpty()) { - "${state.updatedLabel} ${state.updatedText}" + if (updatedLabel.isNotEmpty()) { + "$updatedLabel $updatedText" } else { - state.updatedText + updatedText } Text( text = footerText, @@ -386,27 +459,24 @@ class LocalStatsWidget : GlanceAppWidget() { } } -internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState( +internal fun createMockWidgetState() = LocalStatsWidgetUiState( connectionState = ConnectionState.Connected, showContent = true, - appName = getStringSuspend(Res.string.meshtastic_app_name), - nodesLabel = getStringSuspend(Res.string.nodes), - uptimeLabel = getStringSuspend(Res.string.uptime), - updatedLabel = getStringSuspend(Res.string.updated), - refreshLabel = getStringSuspend(Res.string.refresh), nodeShortName = "ME", nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(), - batteryLabel = getStringSuspend(Res.string.battery), - batteryValue = "85%", + batteryLevel = 85, + hasBattery = true, batteryProgress = 0.85f, - channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization), - channelUtilizationValue = "18.5%", + channelUtilization = 18.5f, channelUtilizationProgress = 0.185f, - airUtilizationLabel = getStringSuspend(Res.string.air_utilization), - airUtilizationValue = "3.2%", + airUtilization = 3.2f, airUtilizationProgress = 0.032f, - trafficText = "TX: 145 | RX: 892 | D: 42", - nodeCountText = "2/3", - uptimeText = "2d 0h", - updatedText = "5m ago", + hasStats = true, + numPacketsTx = 145, + numPacketsRx = 892, + numRxDupe = 42, + totalNodes = 3, + onlineNodes = 2, + uptimeSecs = 172800L, + updateTimeMillis = System.currentTimeMillis() - 300000L, ) diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt index 7d6dea60b..75dc02cd1 100644 --- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt +++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt @@ -27,35 +27,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.model.Node -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.air_utilization -import org.meshtastic.core.resources.battery -import org.meshtastic.core.resources.channel_utilization -import org.meshtastic.core.resources.connecting -import org.meshtastic.core.resources.device_sleeping -import org.meshtastic.core.resources.disconnected -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.local_stats_bad -import org.meshtastic.core.resources.local_stats_diagnostics_prefix -import org.meshtastic.core.resources.local_stats_dropped -import org.meshtastic.core.resources.local_stats_heap -import org.meshtastic.core.resources.local_stats_heap_value -import org.meshtastic.core.resources.local_stats_noise -import org.meshtastic.core.resources.local_stats_relays -import org.meshtastic.core.resources.local_stats_traffic -import org.meshtastic.core.resources.local_stats_updated_at -import org.meshtastic.core.resources.meshtastic_app_name -import org.meshtastic.core.resources.nodes -import org.meshtastic.core.resources.powered -import org.meshtastic.core.resources.refresh -import org.meshtastic.core.resources.updated -import org.meshtastic.core.resources.uptime import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.service.ServiceRepository import org.meshtastic.proto.LocalStats @@ -64,48 +39,42 @@ import javax.inject.Singleton data class LocalStatsWidgetUiState( val connectionState: ConnectionState = ConnectionState.Disconnected, - // Rendering data - val statusText: String = "", val isConnecting: Boolean = false, val showContent: Boolean = false, - // Static Strings (Resolved in provider for Glance stability) - val appName: String = "", - val nodesLabel: String = "", - val uptimeLabel: String = "", - val updatedLabel: String = "", - val refreshLabel: String = "", - // Node Identity val nodeShortName: String? = null, val nodeColors: Pair? = null, // Battery - val batteryLabel: String = "", - val batteryValue: String = "", + val batteryLevel: Int = 0, + val hasBattery: Boolean = false, val batteryProgress: Float = 0f, // Utilization - val channelUtilizationLabel: String = "", - val channelUtilizationValue: String = "", + val channelUtilization: Float = 0f, val channelUtilizationProgress: Float = 0f, - val airUtilizationLabel: String = "", - val airUtilizationValue: String = "", + val airUtilization: Float = 0f, val airUtilizationProgress: Float = 0f, - // Packet Stats Lines - val trafficText: String? = null, - val relayText: String? = null, - val diagnosticsText: String? = null, + // Stats + val hasStats: Boolean = false, + val numPacketsTx: Int = 0, + val numPacketsRx: Int = 0, + val numRxDupe: Int = 0, + val numTxRelay: Int = 0, + val numTxRelayCanceled: Int = 0, + val noiseFloor: Int = 0, + val numPacketsRxBad: Int = 0, + val numTxDropped: Int = 0, val heapFreeBytes: Int = 0, val heapTotalBytes: Int = 0, - val heapValue: String? = null, - val heapText: String? = null, // Footer - val nodeCountText: String = "", - val uptimeText: String = "", - val updatedText: String = "", + val totalNodes: Int = 0, + val onlineNodes: Int = 0, + val uptimeSecs: Long = 0, + val updateTimeMillis: Long = 0, ) @Singleton @@ -151,101 +120,49 @@ constructor( ) @Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") - private suspend fun mapToUiState( + private fun mapToUiState( connectionState: ConnectionState, totalNodes: Int, onlineNodes: Int, stats: LocalStats, localNode: Node?, ): LocalStatsWidgetUiState { - val statusText = - when (connectionState) { - is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected) - is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting) - is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping) - is ConnectionState.Connected -> "" - } - val metrics = localNode?.deviceMetrics val batteryLevel = metrics?.battery_level ?: 0 - val isPowered = batteryLevel > 100 - val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%" val hasStats = stats.uptime_seconds != 0 val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f - - val diag = mutableListOf() - if (hasStats) { - if (stats.noise_floor != 0) { - diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor)) - } - if (stats.num_packets_rx_bad > 0) { - diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad)) - } - if (stats.num_tx_dropped > 0) { - diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped)) - } - } - val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L return LocalStatsWidgetUiState( connectionState = connectionState, - statusText = statusText, isConnecting = connectionState is ConnectionState.Connecting, showContent = connectionState is ConnectionState.Connected, - appName = getStringSuspend(Res.string.meshtastic_app_name), - nodesLabel = getStringSuspend(Res.string.nodes), - uptimeLabel = getStringSuspend(Res.string.uptime), - updatedLabel = getStringSuspend(Res.string.updated), - refreshLabel = getStringSuspend(Res.string.refresh), nodeShortName = localNode?.user?.short_name, nodeColors = localNode?.colors, - batteryLabel = getStringSuspend(Res.string.battery), - batteryValue = batteryValue, + batteryLevel = batteryLevel, + hasBattery = metrics?.battery_level != null, batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f), - channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization), - channelUtilizationValue = "%.1f%%".format(channelUtil), + channelUtilization = channelUtil, channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f), - airUtilizationLabel = getStringSuspend(Res.string.air_utilization), - airUtilizationValue = "%.1f%%".format(airUtilTx), + airUtilization = airUtilTx, airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f), - trafficText = - if (hasStats) { - getStringSuspend( - Res.string.local_stats_traffic, - stats.num_packets_tx, - stats.num_packets_rx, - stats.num_rx_dupe, - ) - } else { - null - }, - relayText = - stats - .takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) } - ?.let { - getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled) - }, - diagnosticsText = - if (diag.isNotEmpty()) { - getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | ")) - } else { - null - }, - heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0, - heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0, - heapValue = - if (hasStats) { - getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes) - } else { - null - }, - heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null, - nodeCountText = "$onlineNodes/$totalNodes", - uptimeText = formatUptime(uptimeSecs.toInt()), - updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)), + hasStats = hasStats, + numPacketsTx = stats.num_packets_tx, + numPacketsRx = stats.num_packets_rx, + numRxDupe = stats.num_rx_dupe, + numTxRelay = stats.num_tx_relay, + numTxRelayCanceled = stats.num_tx_relay_canceled, + noiseFloor = stats.noise_floor, + numPacketsRxBad = stats.num_packets_rx_bad, + numTxDropped = stats.num_tx_dropped, + heapFreeBytes = stats.heap_free_bytes, + heapTotalBytes = stats.heap_total_bytes, + totalNodes = totalNodes, + onlineNodes = onlineNodes, + uptimeSecs = uptimeSecs, + updateTimeMillis = nowMillis, ) } } diff --git a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt b/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt index 0cc16e66c..abeef17a0 100644 --- a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt +++ b/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.network.di import android.content.Context @@ -26,11 +25,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -71,20 +65,5 @@ interface GoogleNetworkModule { ) .eventListenerFactory(eventListenerFactory = DatadogEventListener.Factory()) .build() - - @Provides - @Singleton - fun provideHttpClient(okHttpClient: OkHttpClient): HttpClient = HttpClient(engineFactory = OkHttp) { - engine { preconfigured = okHttpClient } - - install(plugin = ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) - } - } } } diff --git a/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt index c866857ef..354487614 100644 --- a/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.network.di import android.content.Context @@ -31,6 +30,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.meshtastic.core.network.BuildConfig import javax.inject.Singleton @@ -49,7 +53,7 @@ class NetworkModule { return ImageLoader.Builder(context = application) .components { add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttp })) - add(SvgDecoder.Factory()) + add(SvgDecoder.Factory(scaleToDensity = true)) } .memoryCache { MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build() @@ -59,4 +63,19 @@ class NetworkModule { .crossfade(enable = true) .build() } + + @Provides + @Singleton + fun provideHttpClient(okHttpClient: OkHttpClient): HttpClient = HttpClient(engineFactory = OkHttp) { + engine { preconfigured = okHttpClient } + + install(plugin = ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + }, + ) + } + } } diff --git a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt index 0b95a5a79..ad3f4c9a2 100644 --- a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt +++ b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt @@ -25,7 +25,7 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet /** Retrieves a formatted string from the [StringResource] in a blocking manner. */ fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { - getStringSuspend(stringResource, *formatArgs) + composeGetString(stringResource, *formatArgs) } /** Retrieves a string from the [StringResource] in a suspending manner. */ @@ -37,7 +37,6 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: formatArgs .map { arg -> if (arg is StringResource) { - // Resolve nested StringResources recursively getStringSuspend(arg) } else { arg @@ -45,9 +44,6 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: } .toTypedArray() - // Compose Multiplatform doesn't fully support complex formatting like %.2f - // Fetch the raw string and format it using standard Java String.format. - val rawString = composeGetString(stringResource) @Suppress("SpreadOperator") - return String.format(java.util.Locale.getDefault(), rawString, *resolvedArgs) + return composeGetString(stringResource, *resolvedArgs) } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt index 6c300f4a9..babb05fb3 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt @@ -25,12 +25,8 @@ import android.widget.Toast import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString -suspend fun Context.showToast(stringResource: StringResource) { - showToast(getString(stringResource)) -} - suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs: Any) { - Toast.makeText(this, getString(stringResource, formatArgs), Toast.LENGTH_SHORT).show() + Toast.makeText(this, getString(stringResource, *formatArgs), Toast.LENGTH_SHORT).show() } suspend fun Context.showToast(text: String) { diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index db489e53c..265d4334c 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.core.datastore) implementation(projects.core.model) implementation(projects.core.navigation) + implementation(projects.core.network) implementation(projects.core.prefs) implementation(projects.core.proto) implementation(projects.core.service) @@ -48,6 +49,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.collections.immutable) implementation(libs.kermit) + implementation(libs.ktor.client.core) implementation(libs.nordic.client.android) implementation(libs.nordic.dfu) diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index 981d7e5cc..75985a0ed 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -20,12 +20,17 @@ import android.content.Context import android.net.Uri import co.touchlab.kermit.Logger import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.contentLength +import io.ktor.http.isSuccess +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request import org.meshtastic.core.model.DeviceHardware import java.io.File import java.io.FileInputStream @@ -45,7 +50,7 @@ class FirmwareFileHandler @Inject constructor( @ApplicationContext private val context: Context, - private val client: OkHttpClient, + private val client: HttpClient, ) { private val tempDir = File(context.cacheDir, "firmware_update") @@ -60,10 +65,9 @@ constructor( } suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) { - val request = Request.Builder().url(url).head().build() try { - client.newCall(request).execute().use { response -> response.isSuccessful } - } catch (e: IOException) { + client.head(url).status.isSuccess() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { Logger.w(e) { "Failed to check URL existence: $url" } false } @@ -71,28 +75,27 @@ constructor( suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File? = withContext(Dispatchers.IO) { - val request = Request.Builder().url(url).build() val response = try { - client.newCall(request).execute() - } catch (e: IOException) { + client.get(url) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { Logger.w(e) { "Download failed for $url" } return@withContext null } - if (!response.isSuccessful) { - Logger.w { "Download failed: ${response.code} for $url" } + if (!response.status.isSuccess()) { + Logger.w { "Download failed: ${response.status.value} for $url" } return@withContext null } - val body = response.body ?: return@withContext null - val contentLength = body.contentLength() + val body = response.bodyAsChannel() + val contentLength = response.contentLength() ?: -1L if (!tempDir.exists()) tempDir.mkdirs() val targetFile = File(tempDir, fileName) - body.byteStream().use { input -> + body.toInputStream().use { input -> FileOutputStream(targetFile).use { output -> val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) var bytesRead: Int diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt index 08789a2ba..64f31d832 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.map.component import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.key import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.lifecycle.compose.LocalLifecycleOwner @@ -32,6 +31,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.Circle import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.clustering.Clustering +import com.google.maps.android.compose.clustering.ClusteringMarkerProperties import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.model.NodeClusterItem @@ -63,10 +63,19 @@ fun NodeClusterMarkers( } } - if (mapFilterState.showPrecisionCircle) { - nodeClusterItems.forEach { clusterItem -> - key(clusterItem.node.num) { - // Add a stable key for each circle + Clustering( + items = nodeClusterItems, + onClusterClick = onClusterClick, + onClusterItemInfoWindowClick = { item -> + navigateToNodeDetails(item.node.num) + false + }, + clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, + onClusterManager = { clusterManager -> + (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 + }, + clusterItemDecoration = { clusterItem -> + if (mapFilterState.showPrecisionCircle) { clusterItem.getPrecisionMeters()?.let { precisionMeters -> if (precisionMeters > 0) { Circle( @@ -80,18 +89,7 @@ fun NodeClusterMarkers( } } } - } - } - Clustering( - items = nodeClusterItems, - onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> - navigateToNodeDetails(item.node.num) - false - }, - clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, - onClusterManager = { clusterManager -> - (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 + ClusteringMarkerProperties(zIndex = 1f) }, ) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 7a6278169..ea91f78fe 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -81,11 +81,14 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_decoded_payload @@ -113,8 +116,7 @@ import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import java.io.IOException import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets -import java.text.SimpleDateFormat -import java.util.Locale +import kotlin.time.Instant.Companion.fromEpochMilliseconds private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) @@ -201,8 +203,18 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo filterMode = filterMode, onFilterModeChange = { filterMode = it }, onExportLogs = { + val format = + LocalDateTime.Format { + year() + monthNumber() + day() + char('_') + hour() + minute() + second() + } val timestamp = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate()) + fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format) val fileName = "meshtastic_debug_$timestamp.txt" exportLogsLauncher.launch(fileName) }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97b07a05e..737094bca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ mockk = "1.14.9" testRetry = "1.6.4" # Compose Multiplatform -compose-multiplatform = "1.10.1" +compose-multiplatform = "1.11.0-alpha03" # Google hilt = "2.59.2" @@ -165,6 +165,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa # Networking ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.2" }