chore(deps): bump deps to take advantage of new functionality (#4658)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-26 07:26:50 -06:00 committed by GitHub
parent 46b32f1cce
commit 145cde9393
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 230 additions and 237 deletions

View file

@ -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<RefreshLocalStatsAction>(),
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<String>()
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,
)

View file

@ -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<Int, Int>? = 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<String>()
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,
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
},
)
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
},
)
}
}
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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)

View file

@ -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

View file

@ -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)
},
)
}

View file

@ -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)
},

View file

@ -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" }