mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-11 05:46:20 -04:00
fix: address 2.8.0 release-audit findings (#6189)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4
.skills/compose-ui/strings-index.txt
generated
4
.skills/compose-ui/strings-index.txt
generated
@@ -312,6 +312,10 @@ debug_store_logs_summary
|
||||
debug_store_logs_title
|
||||
debug_tab_app_logs
|
||||
debug_tab_packets
|
||||
deep_link_connect_message
|
||||
deep_link_connect_title
|
||||
deep_link_disconnect_message
|
||||
deep_link_disconnect_title
|
||||
default_
|
||||
default_mqtt_address
|
||||
### DELETE ###
|
||||
|
||||
@@ -266,7 +266,7 @@ dependencies {
|
||||
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
|
||||
implementation(libs.jetbrains.lifecycle.runtime.compose)
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
@@ -32,7 +32,7 @@ import coil3.svg.SvgDecoder
|
||||
import coil3.util.DebugLogger
|
||||
import coil3.util.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
@@ -95,9 +95,15 @@ class NetworkModule {
|
||||
.crossfade(enable = true)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Uses the OkHttp engine, not `Android`. The `Android` engine is backed by [java.net.HttpURLConnection], which on
|
||||
* device routes through the platform's bundled OkHttp 2.x fork (`com.android.okhttp`). Cancelling a request while
|
||||
* its gzip/chunked response body is still being read trips a re-entrant `AsyncTimeout.enter()` in that fork and
|
||||
* crashes with "Unbalanced enter/exit" (Crashlytics 97ae5ea1, 2.8.0 only). Square's OkHttp has no such bug.
|
||||
*/
|
||||
@Single
|
||||
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
|
||||
HttpClient(engineFactory = Android) {
|
||||
HttpClient(engineFactory = OkHttp) {
|
||||
install(plugin = ContentNegotiation) { json(json) }
|
||||
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
|
||||
install(plugin = HttpTimeout) {
|
||||
|
||||
@@ -26,12 +26,13 @@ import co.touchlab.kermit.Logger
|
||||
* the first time a secondary transport learns a `myNodeNum` that another transport already claimed.
|
||||
*
|
||||
* Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read
|
||||
* settings, nodes and their notes, per-node metadata, the audit log (each session's received-packet history — the
|
||||
* source of the telemetry timelines, position history, and traceroute results the UI reconstructs), traceroute
|
||||
* positions, and discovery sessions. Where the same row exists on both sides the destination is preferred (it will be
|
||||
* refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are brought over.
|
||||
* Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key remapping is
|
||||
* needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid collisions.
|
||||
* settings, nodes and their notes, per-node metadata, quick-chat actions, the audit log (each session's received-packet
|
||||
* history — the source of the telemetry timelines, position history, and traceroute results the UI reconstructs),
|
||||
* traceroute positions, and discovery sessions. Where the same row exists on both sides the destination is preferred
|
||||
* (it will be refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are
|
||||
* brought over. Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key
|
||||
* remapping is needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid
|
||||
* collisions.
|
||||
*/
|
||||
object DatabaseMerger {
|
||||
|
||||
@@ -49,6 +50,7 @@ object DatabaseMerger {
|
||||
mergeContactSettings(source, dest)
|
||||
mergeNodes(source, dest)
|
||||
mergeMetadata(source, dest)
|
||||
mergeQuickChat(source, dest)
|
||||
// Logs must precede traceroute positions: the latter has a CASCADE foreign key onto log.uuid.
|
||||
mergeLogs(source, dest)
|
||||
mergeTraceroutePositions(source, dest)
|
||||
@@ -110,6 +112,23 @@ object DatabaseMerger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-authored quick-chat buttons. `uuid`/`position` are per-DB, so append the source's actions after the
|
||||
* destination's last one with fresh ids, skipping any the destination already has (same name + message + mode).
|
||||
* Without this the source's buttons are lost outright when [DatabaseManager] retires it.
|
||||
*/
|
||||
private suspend fun mergeQuickChat(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
|
||||
val destActions = dest.quickChatActionDao().getAllSnapshot()
|
||||
val existing = destActions.mapTo(mutableSetOf()) { Triple(it.name, it.message, it.mode) }
|
||||
var nextPosition = (destActions.maxOfOrNull { it.position } ?: -1) + 1
|
||||
source.quickChatActionDao().getAllSnapshot().forEach { action ->
|
||||
if (existing.add(Triple(action.name, action.message, action.mode))) {
|
||||
dest.quickChatActionDao().upsert(action.copy(uuid = 0L, position = nextPosition))
|
||||
nextPosition++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The audit log holds each transport session's received-packet history — the source of the telemetry timelines,
|
||||
* position history, and node-info history the UI reconstructs per node/port. This is the only place that time
|
||||
|
||||
@@ -29,6 +29,10 @@ interface QuickChatActionDao {
|
||||
@Query("Select * from quick_chat order by position asc")
|
||||
fun getAll(): Flow<List<QuickChatAction>>
|
||||
|
||||
/** Snapshot used by DatabaseMerger to fold one transport's DB into another for the same node. */
|
||||
@Query("Select * from quick_chat order by position asc")
|
||||
suspend fun getAllSnapshot(): List<QuickChatAction>
|
||||
|
||||
@Upsert suspend fun upsert(action: QuickChatAction)
|
||||
|
||||
@Query("Delete from quick_chat")
|
||||
|
||||
@@ -42,11 +42,15 @@ abstract class BaseRadioTransportFactory(
|
||||
InterfaceId.TCP.id,
|
||||
InterfaceId.SERIAL.id,
|
||||
InterfaceId.BLUETOOTH.id,
|
||||
InterfaceId.MOCK.id,
|
||||
InterfaceId.REPLAY.id,
|
||||
'!',
|
||||
-> true
|
||||
|
||||
// Virtual transports are development aids. A release build must never bind one: `connections?address=m` is
|
||||
// reachable from any web page through the verified meshtastic.org app link.
|
||||
InterfaceId.MOCK.id,
|
||||
InterfaceId.REPLAY.id,
|
||||
-> isMockTransport()
|
||||
|
||||
else -> isPlatformAddressValid(address)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,10 @@
|
||||
<string name="debug_store_logs_title">Store mesh logs</string>
|
||||
<string name="debug_tab_app_logs">App logs</string>
|
||||
<string name="debug_tab_packets">Packets</string>
|
||||
<string name="deep_link_connect_message">A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from.</string>
|
||||
<string name="deep_link_connect_title">Connect to this device?</string>
|
||||
<string name="deep_link_disconnect_message">A link is asking this app to disconnect from your device. Only continue if you trust where the link came from.</string>
|
||||
<string name="deep_link_disconnect_title">Disconnect from your device?</string>
|
||||
<string name="default_">Default</string>
|
||||
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
|
||||
<!-- DELETE -->
|
||||
|
||||
@@ -395,10 +395,14 @@ actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntim
|
||||
@Composable
|
||||
actual fun rememberBluetoothPermissionState(): PermissionUiState {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
|
||||
// Pre-Android 12 has no runtime Bluetooth permission — BLE scanning is gated by the location permission, which
|
||||
// callers request separately (the intro Location screen, the map/Privacy location flows). Report granted here
|
||||
// so the Bluetooth surface itself is a no-op rather than masquerading as a location request.
|
||||
return rememberGrantedPermissionState()
|
||||
// Pre-Android 12 has no runtime Bluetooth permission — the platform gates BLE scanning on fine location
|
||||
// instead. Reporting "granted" here left a user who declined location with an empty device list and no way to
|
||||
// re-prompt, so surface the permission that actually blocks the scan. Fine specifically: API 29/30 return zero
|
||||
// scan results on a coarse-only grant.
|
||||
return rememberRuntimePermissionState(
|
||||
permissions = arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
|
||||
requireAll = true,
|
||||
)
|
||||
}
|
||||
return rememberRuntimePermissionState(
|
||||
permissions =
|
||||
|
||||
@@ -361,14 +361,14 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
|
||||
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
|
||||
}
|
||||
|
||||
if ((env.temperature ?: 0f) != 0f) {
|
||||
val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
|
||||
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
|
||||
} else if ((aq.co2_temperature ?: 0f) != 0f) {
|
||||
val temp = MetricFormatter.temperature(aq.co2_temperature ?: 0f, tempInFahrenheit)
|
||||
// Temperature carries presence, so `null` already means "no sensor" — testing against 0 hid an ordinary 0 °C
|
||||
// reading, which the temperature chart plots. Prefer the environment sensor, then the SCD4x CO₂ sensor's own.
|
||||
(env.temperature ?: aq.co2_temperature)?.let { temperature ->
|
||||
val temp = MetricFormatter.temperature(temperature, tempInFahrenheit)
|
||||
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
|
||||
}
|
||||
|
||||
// Humidity keeps its zero-guard: 0% RH is not physically reachable, and the humidity chart filters it too.
|
||||
if ((env.relative_humidity ?: 0f) != 0f) {
|
||||
items.add {
|
||||
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)
|
||||
|
||||
@@ -52,6 +52,9 @@ internal fun processTcpServices(
|
||||
}
|
||||
DeviceListEntry.Tcp(displayName, address)
|
||||
}
|
||||
// mDNS can resolve two services to one host:port (e.g. re-announce before the old record expires). The device
|
||||
// list keys on fullAddress, and duplicate LazyColumn keys are a hard crash.
|
||||
.distinctBy { it.fullAddress }
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,26 @@
|
||||
*/
|
||||
package org.meshtastic.feature.connections.navigation
|
||||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.ConnectionsRoute
|
||||
import org.meshtastic.core.navigation.NodesRoute
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.connect
|
||||
import org.meshtastic.core.resources.deep_link_connect_message
|
||||
import org.meshtastic.core.resources.deep_link_connect_title
|
||||
import org.meshtastic.core.resources.deep_link_disconnect_message
|
||||
import org.meshtastic.core.resources.deep_link_disconnect_title
|
||||
import org.meshtastic.core.resources.disconnect
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.ui.ConnectionsScreen
|
||||
@@ -32,13 +45,33 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<ConnectionsRoute.Connections> { key ->
|
||||
val scanModel = koinViewModel<ScannerViewModel>()
|
||||
// Lets a deep link (e.g. from AI/automation tooling) trigger a connection, or a disconnect via `n`, without
|
||||
// manual device selection.
|
||||
LaunchedEffect(key.address) {
|
||||
key.address?.takeIf(String::isNotBlank)?.let { address ->
|
||||
if (address == NO_DEVICE_SELECTED) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
|
||||
}
|
||||
|
||||
// A deep link (e.g. from AI/automation tooling) may name a device address, or `n` to disconnect. The
|
||||
// `connections` path is a verified https://meshtastic.org app link, so any web page can fire one at us —
|
||||
// always confirm before re-pointing or dropping the radio connection.
|
||||
var pendingAddress by rememberSaveable(key.address) { mutableStateOf(key.address?.takeIf(String::isNotBlank)) }
|
||||
|
||||
pendingAddress?.let { address ->
|
||||
val isDisconnect = address == NO_DEVICE_SELECTED
|
||||
MeshtasticDialog(
|
||||
titleRes =
|
||||
if (isDisconnect) Res.string.deep_link_disconnect_title else Res.string.deep_link_connect_title,
|
||||
message =
|
||||
if (isDisconnect) {
|
||||
stringResource(Res.string.deep_link_disconnect_message)
|
||||
} else {
|
||||
stringResource(Res.string.deep_link_connect_message, address)
|
||||
},
|
||||
confirmTextRes = if (isDisconnect) Res.string.disconnect else Res.string.connect,
|
||||
onConfirm = {
|
||||
if (isDisconnect) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
|
||||
pendingAddress = null
|
||||
},
|
||||
dismissTextRes = Res.string.cancel,
|
||||
onDismiss = { pendingAddress = null },
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionsScreen(
|
||||
scanModel = scanModel,
|
||||
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
|
||||
|
||||
@@ -116,6 +116,23 @@ class TcpDiscoveryHelpersTest {
|
||||
result[1].name shouldBe "Zulu"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processTcpServices dedupes services resolving to the same address`() {
|
||||
// Duplicate mDNS announcements (multi-interface, re-resolution) yield the same host/port twice
|
||||
val service =
|
||||
DiscoveredService(
|
||||
name = "Meshtastic_abcd",
|
||||
hostAddress = "192.168.1.10",
|
||||
port = 4403,
|
||||
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
|
||||
)
|
||||
|
||||
val result = processTcpServices(listOf(service, service.copy(name = "Meshtastic_abcd (2)")), emptyList())
|
||||
|
||||
result.size shouldBe 1
|
||||
result[0].fullAddress shouldBe "t192.168.1.10"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchDiscoveredTcpNodes matches node by device id`() {
|
||||
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
|
||||
|
||||
@@ -129,7 +129,11 @@ internal fun isValidFirmwareFile(filename: String, target: String, fileExtension
|
||||
|
||||
val targetToken = Regex.escape(target)
|
||||
val extensionToken = Regex.escape(fileExtension)
|
||||
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
|
||||
// What follows the target must be a dotted version (`-2.7.17`, `-v2.7.17`), the literal `firmware`, or the
|
||||
// extension itself. Accepting a bare digit here let digit-led sibling variants through — `tlora-v2` matched
|
||||
// `firmware-tlora-v2-1-1_6-2.7.14.bin`, which belongs to `tlora-v2-1-1_6` — and flashed the wrong variant.
|
||||
val versionOrFirmware = "([\\-_]v?\\d+\\.\\d+|[\\-_.]firmware)"
|
||||
val targetPattern = Regex("(^|.*[\\-_])$targetToken($versionOrFirmware.*$extensionToken$|$extensionToken$)")
|
||||
return targetPattern.matches(filename)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,21 @@ class IsValidFirmwareFileTest {
|
||||
assertTrue(isValidFirmwareFile("firmware-nano-g1-explorer-2.8.0.bin", "nano-g1-explorer", ".bin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects digit-led sibling variants`() {
|
||||
// The variant suffix starts with a digit, so it used to be mistaken for the version and flash the wrong board.
|
||||
assertFalse(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2", ".bin"))
|
||||
assertFalse(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1", ".bin"))
|
||||
// The variants' own targets still match.
|
||||
assertTrue(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2-1-1_6", ".bin"))
|
||||
assertTrue(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1-3", ".bin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts a v-prefixed version after the target`() {
|
||||
assertTrue(isValidFirmwareFile("firmware-heltec-v3-v2.7.17.bin", "heltec-v3", ".bin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts older local firmware filename shapes`() {
|
||||
// Older naming where "firmware" appears as a segment after the target.
|
||||
|
||||
@@ -63,9 +63,11 @@ import org.meshtastic.core.resources.air_quality_metrics_log
|
||||
import org.meshtastic.core.resources.co2
|
||||
import org.meshtastic.core.resources.co2_humidity
|
||||
import org.meshtastic.core.resources.co2_temperature
|
||||
import org.meshtastic.core.resources.micrograms_per_cubic_meter
|
||||
import org.meshtastic.core.resources.pm10
|
||||
import org.meshtastic.core.resources.pm1_0
|
||||
import org.meshtastic.core.resources.pm2_5
|
||||
import org.meshtastic.core.resources.ppm
|
||||
import org.meshtastic.core.ui.component.Co2Severity
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Blue
|
||||
@@ -304,18 +306,25 @@ private fun AirQualityMetricsCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val ugm3 = stringResource(Res.string.micrograms_per_cubic_meter)
|
||||
val ppmUnit = stringResource(Res.string.ppm)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column {
|
||||
// Present-and-zero is a valid clean-air reading; only `?.` (absent field) hides a row.
|
||||
aq.pm10_standard?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
|
||||
aq.pm25_standard?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
|
||||
aq.pm100_standard?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
|
||||
listOfNotNull(
|
||||
aq.pm10_standard?.let { Res.string.pm1_0 to it },
|
||||
aq.pm25_standard?.let { Res.string.pm2_5 to it },
|
||||
aq.pm100_standard?.let { Res.string.pm10 to it },
|
||||
)
|
||||
.forEach { (label, value) ->
|
||||
Text("${stringResource(label)}: $value $ugm3", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
aq.co2?.let { co2 ->
|
||||
val severity = Co2Severity.fromPpm(co2)
|
||||
Text(
|
||||
text = "CO₂: $co2 ppm",
|
||||
text = "${stringResource(Res.string.co2)}: $co2 $ppmUnit",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = severity?.color ?: MaterialTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -246,13 +246,16 @@ private fun PowerMetricsChart(
|
||||
->
|
||||
val currentColor = PowerMetric.CURRENT.color
|
||||
val voltageColor = PowerMetric.VOLTAGE.color
|
||||
// The formatter runs outside composition, so resolve the labels here.
|
||||
val currentLabel = stringResource(Res.string.current)
|
||||
val voltageLabel = stringResource(Res.string.voltage)
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
when (color) {
|
||||
currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}"
|
||||
voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V"
|
||||
currentColor -> "$currentLabel: ${MetricFormatter.current(value.toFloat(), 0)}"
|
||||
voltageColor -> "$voltageLabel: ${MetricFormatter.voltage(value.toFloat(), 1)}"
|
||||
else -> NumberFormatter.format(value.toFloat(), 1)
|
||||
}
|
||||
},
|
||||
@@ -322,7 +325,7 @@ private fun PowerMetricsChart(
|
||||
if (voltageData.isNotEmpty()) {
|
||||
VerticalAxis.rememberEnd(
|
||||
label = ChartStyling.rememberAxisLabel(color = voltageColor),
|
||||
valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
|
||||
valueFormatter = { _, value, _ -> MetricFormatter.voltage(value.toFloat(), 1) },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -224,11 +224,11 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
||||
kotlinx-serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization" }
|
||||
|
||||
# Networking
|
||||
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
|
||||
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-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user