refactor(ui): compose resources, domain layer (#4628)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-22 21:39:50 -06:00
committed by GitHub
parent 96adc70401
commit 2676a51647
322 changed files with 3031 additions and 2790 deletions

View File

@@ -32,13 +32,13 @@ This file serves as a comprehensive guide for AI agents and developers working o
- **Material 3:** The app uses Material 3. Look for ways to use **Material 3 Expressive** components where appropriate.
- **Strings:**
- Do **not** use `app/src/main/res/values/strings.xml` for UI strings.
- Use the **Compose Multiplatform Resource** library in `core/strings`.
- **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
- Use the **Compose Multiplatform Resource** library in `core:resources`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.your_string_key
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
@@ -102,7 +102,7 @@ This file serves as a comprehensive guide for AI agents and developers working o
1. **Explore First:** Before making changes, read `gradle/libs.versions.toml` and the relevant `build.gradle.kts` to understand the environment.
2. **Plan:** Identify which modules (`core` or `feature`) need modification.
3. **Implement:**
- If adding a string, modify `core/strings`.
- If adding a string, modify `core:resources`.
- If adding a dependency, modify `libs.versions.toml` first.
4. **Verify:**
- Run `./gradlew spotlessApply` (Essential!).
@@ -118,8 +118,14 @@ This file serves as a comprehensive guide for AI agents and developers working o
## 7. Troubleshooting
- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.strings.Res` and the specific string property, and that you have run a build to generate the resources.
- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.resources.Res` and the specific string property, and that you have run a build to generate the resources.
- **Build Errors:** Check `gradle/libs.versions.toml` for version conflicts. Use `build-logic` conventions to ensure plugins are applied correctly.
---
*Refer to `CONTRIBUTING.md` for human-centric processes like Code of Conduct and Pull Request etiquette.*
### E. Resources and Assets
- **Centralization:** All global app resources (Strings, Drawables, Fonts, raw files) should be placed in `:core:resources`.
- **Module Path:** `core/resources/src/commonMain/composeResources/`
- **Decentralization:** Feature-specific strings and assets can (and should) be housed in their respective feature module's `composeResources` directory to maintain modular boundaries and clean architectural dependency graphs. Crowdin localization handles globbing `/**/composeResources/values/strings.xml` perfectly.
- **Drawables:** Use `painterResource(Res.drawable.your_icon)` to access cross-platform drawables. Name them consistently (`ic_` for icons, `img_` for artwork). Avoid putting standard Drawables or Vectors in legacy Android `res/drawable` folders unless strictly required by a legacy library (like `OsmDroid` map markers) or the OS layer (like `app_icon.xml`).

View File

@@ -19,14 +19,14 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
- Write clear, descriptive variable and function names.
- Add comments where necessary, especially for complex logic.
- Keep methods and classes focused and concise.
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:strings`.
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`.
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
- **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.your_string_key
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```

View File

@@ -39,7 +39,7 @@ graph TB
:app -.-> :core:prefs
:app -.-> :core:proto
:app -.-> :core:service
:app -.-> :core:strings
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :feature:intro

View File

@@ -188,11 +188,9 @@ secrets {
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor ->
variant.applicationId = "com.geeksville.mesh.$flavor.debug"
}
variant.flavorName?.let { flavor -> variant.applicationId = "com.geeksville.mesh.$flavor.debug" }
}
onVariants(selector().withBuildType("release")) { variant ->
if (variant.flavorName == "google") {
val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() }
@@ -226,7 +224,7 @@ dependencies {
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
implementation(projects.feature.intro)

View File

@@ -50,8 +50,8 @@ import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast

View File

@@ -0,0 +1,202 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.domain.usecase
import android.hardware.usb.UsbManager
import android.net.nsd.NsdServiceInfo
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.meshtastic
import java.util.Locale
import javax.inject.Inject
data class DiscoveredDevices(
val bleDevices: List<DeviceListEntry>,
val usbDevices: List<DeviceListEntry>,
val discoveredTcpDevices: List<DeviceListEntry>,
val recentTcpDevices: List<DeviceListEntry>,
)
@Suppress("LongParameterList")
class GetDiscoveredDevicesUseCase
@Inject
constructor(
private val bluetoothRepository: BluetoothRepository,
private val networkRepository: NetworkRepository,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val usbRepository: UsbRepository,
private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: dagger.Lazy<UsbManager>,
) {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.attributes
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
val usbDevicesFlow =
usbRepository.serialDevices.map { usb ->
usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) }
}
return combine(
nodeDb,
bondedBleFlow,
processedTcpFlow,
usbDevicesFlow,
networkRepository.resolvedList,
recentAddressesDataSource.recentAddresses,
) { args: Array<Any> ->
@Suppress("UNCHECKED_CAST", "MagicNumber")
val db = args[0] as Map<Int, Node>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val bondedBle = args[1] as List<DeviceListEntry.Ble>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val processedTcp = args[2] as List<DeviceListEntry.Tcp>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val usbDevices = args[3] as List<DeviceListEntry.Usb>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val resolved = args[4] as List<NsdServiceInfo>
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
val bleForUi =
bondedBle
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val usbForUi =
(usbDevices + if (showMock) listOf(DeviceListEntry.Mock("Demo Mode")) else emptyList()).map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
DiscoveredDevices(
bleDevices = bleForUi,
usbDevices = usbForUi,
discoveredTcpDevices = discoveredTcpForUi,
recentTcpDevices = recentTcpForUi,
)
}
}
}

View File

@@ -53,13 +53,13 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent

View File

@@ -46,16 +46,16 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.environment
import org.meshtastic.core.strings.host
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.position_log
import org.meshtastic.core.strings.power
import org.meshtastic.core.strings.signal
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.environment
import org.meshtastic.core.resources.host
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel

View File

@@ -36,14 +36,14 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected_count
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.getString
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry

View File

@@ -43,15 +43,15 @@ import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.error_duty_cycle
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.waypoint_received
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount

View File

@@ -21,10 +21,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale

View File

@@ -107,7 +107,19 @@ class MeshService : Service() {
}
override fun onCreate() {
super.onCreate()
try {
super.onCreate()
} catch (e: IllegalStateException) {
// Hilt can throw IllegalStateException in tests if the component is not created.
// This can happen if the service is started by the system (e.g. after a crash or on boot)
// before the test rule has a chance to create the component.
if (e.message?.contains("HiltAndroidRule") == true) {
Logger.w(e) { "MeshService created before Hilt component was ready in test. Stopping service." }
stopSelf()
return
}
throw e
}
Logger.i { "Creating mesh service" }
serviceNotifications.initChannels()

View File

@@ -53,27 +53,27 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.meshtastic_broadcast_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
import org.meshtastic.core.resources.meshtastic_messages_notifications
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
import org.meshtastic.core.resources.meshtastic_service_notifications
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.low_battery_message
import org.meshtastic.core.strings.low_battery_title
import org.meshtastic.core.strings.mark_as_read
import org.meshtastic.core.strings.meshtastic_alerts_notifications
import org.meshtastic.core.strings.meshtastic_app_name
import org.meshtastic.core.strings.meshtastic_broadcast_notifications
import org.meshtastic.core.strings.meshtastic_low_battery_notifications
import org.meshtastic.core.strings.meshtastic_low_battery_temporary_remote_notifications
import org.meshtastic.core.strings.meshtastic_messages_notifications
import org.meshtastic.core.strings.meshtastic_new_nodes_notifications
import org.meshtastic.core.strings.meshtastic_service_notifications
import org.meshtastic.core.strings.meshtastic_waypoints_notifications
import org.meshtastic.core.strings.new_node_seen
import org.meshtastic.core.strings.no_local_stats
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.you
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats

View File

@@ -26,14 +26,14 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.traceroute_duration
import org.meshtastic.core.resources.traceroute_route_back_to_us
import org.meshtastic.core.resources.traceroute_route_towards_dest
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject

View File

@@ -106,26 +106,26 @@ import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.firmware_old
import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.map
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.firmware_old
import org.meshtastic.core.strings.firmware_too_old
import org.meshtastic.core.strings.map
import org.meshtastic.core.strings.must_update
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations

View File

@@ -64,17 +64,17 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connected_device
import org.meshtastic.core.resources.connected_sleeping
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.connections
import org.meshtastic.core.resources.must_set_region
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.not_connected
import org.meshtastic.core.resources.set_your_region
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connected_device
import org.meshtastic.core.strings.connected_sleeping
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.no_device_selected
import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.set_your_region
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard

View File

@@ -18,16 +18,13 @@ package com.geeksville.mesh.ui.connections
import android.app.Application
import android.content.Context
import android.hardware.usb.UsbManager
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.domain.usecase.GetDiscoveredDevicesUseCase
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.service.MeshService
@@ -36,25 +33,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.meshtastic
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@@ -66,12 +56,9 @@ constructor(
private val serviceRepository: ServiceRepository,
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
private val usbManagerLazy: dagger.Lazy<UsbManager>,
private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
) : ViewModel() {
private val context: Context
get() = application.applicationContext
@@ -81,142 +68,32 @@ constructor(
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val nodeDb: StateFlow<Map<Int, Node>> = nodeRepository.nodeDBbyNum
private val bondedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
bluetoothRepository.state
.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Flow for discovered TCP devices, using recent addresses for potential name enrichment
private val processedDiscoveredTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.attributes // Map<String, ByteArray?>
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val discoveredDevicesFlow =
showMockInterface
.flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
/** A combined list of bonded BLE devices for the UI. */
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(bondedBleDevicesFlow, nodeDb) { bonded, db ->
bonded
.map { entry: DeviceListEntry.Ble ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
}
.stateInWhileSubscribed(initialValue = emptyList())
private val usbDevicesFlow: StateFlow<List<DeviceListEntry.Usb>> =
usbRepository.serialDevices
.map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val mockDevice = DeviceListEntry.Mock("Demo Mode")
discoveredDevicesFlow
.map { it?.bleDevices ?: emptyList() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
/** UI StateFlow for USB devices. */
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
val all: List<DeviceListEntry> = usb + if (showMock) listOf(mockDevice) else emptyList()
all.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
nodeDb.value.values.find { node ->
// Hard to match USB to node without connection, but we can try matching by name if it
// follows Meshtastic pattern
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList())
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
private val filteredRecentTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
recentList,
discoveredDevices,
->
val discoveredDeviceAddresses = discoveredDevices.map { it.fullAddress }.toSet()
recentList
.filterNot { recentAddress -> discoveredDeviceAddresses.contains(recentAddress.address) }
.map { recentAddress -> DeviceListEntry.Tcp(recentAddress.name, recentAddress.address) }
.sortedBy { it.name }
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val suffixLength = 4
discoveredDevicesFlow.map { it?.usbDevices ?: emptyList() }.stateInWhileSubscribed(initialValue = emptyList())
/** UI StateFlow for discovered TCP devices. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(processedDiscoveredTcpDevicesFlow, networkRepository.resolvedList, nodeDb) { devices, resolved, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
discoveredDevicesFlow
.map { it?.discoveredTcpDevices ?: emptyList() }
.stateInWhileSubscribed(initialValue = emptyList())
/** UI StateFlow for recently connected TCP devices that are not currently discovered. */
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(filteredRecentTcpDevicesFlow, nodeDb) { devices, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
// For recent TCP, we don't have the TXT records, but maybe the name contains the ID
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
discoveredDevicesFlow
.map { it?.recentTcpDevices ?: emptyList() }
.stateInWhileSubscribed(initialValue = emptyList())
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow

View File

@@ -37,9 +37,9 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_available_devices
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.bluetooth_available_devices
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth

View File

@@ -35,9 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.disconnect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@OptIn(ExperimentalMaterial3ExpressiveApi::class)

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 com.geeksville.mesh.ui.connections.components
import androidx.compose.material.icons.Icons
@@ -34,10 +33,10 @@ import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.ui.connections.DeviceType
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("LambdaParameterEventTrailing")

View File

@@ -48,9 +48,9 @@ import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disconnect
import org.meshtastic.core.strings.firmware_version
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.resources.firmware_version
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme

View File

@@ -56,12 +56,12 @@ import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L

View File

@@ -53,17 +53,17 @@ import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.isValidAddress
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.confirm_forget_connection
import org.meshtastic.core.resources.discovered_network_devices
import org.meshtastic.core.resources.forget_connection
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_network_device
import org.meshtastic.core.strings.address
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.confirm_forget_connection
import org.meshtastic.core.strings.discovered_network_devices
import org.meshtastic.core.strings.forget_connection
import org.meshtastic.core.strings.ip_port
import org.meshtastic.core.strings.no_network_devices
import org.meshtastic.core.strings.recent_network_devices
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme

View File

@@ -27,9 +27,9 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_usb_devices
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.no_usb_devices
@Composable
fun UsbDevices(

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 com.geeksville.mesh.ui.contact
import androidx.activity.compose.BackHandler
@@ -49,8 +48,8 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons

View File

@@ -53,10 +53,10 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.Contact
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.strings.some_username
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ChannelSet

View File

@@ -70,27 +70,27 @@ import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.close_selection
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.strings.currently
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.delete_messages
import org.meshtastic.core.strings.delete_selection
import org.meshtastic.core.strings.mute_1_week
import org.meshtastic.core.strings.mute_8_hours
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_status_always
import org.meshtastic.core.strings.mute_status_muted_for_days
import org.meshtastic.core.strings.mute_status_muted_for_hours
import org.meshtastic.core.strings.mute_status_unmuted
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.select_all
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.close_selection
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.currently
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_selection
import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_status_always
import org.meshtastic.core.resources.mute_status_muted_for_days
import org.meshtastic.core.resources.mute_status_muted_for_hours
import org.meshtastic.core.resources.mute_status_unmuted
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB

View File

@@ -39,9 +39,9 @@ import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.getShortDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_name
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_name
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject

View File

@@ -48,8 +48,8 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes

View File

@@ -72,21 +72,21 @@ import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.apply
import org.meshtastic.core.resources.are_you_sure_change_default
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.cant_change_no_radio
import org.meshtastic.core.resources.edit
import org.meshtastic.core.resources.generate_qr_code
import org.meshtastic.core.resources.modem_preset
import org.meshtastic.core.resources.navigate_into_label
import org.meshtastic.core.resources.replace
import org.meshtastic.core.resources.reset
import org.meshtastic.core.resources.reset_to_defaults
import org.meshtastic.core.resources.share_channels_qr
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.apply
import org.meshtastic.core.strings.are_you_sure_change_default
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.cant_change_no_radio
import org.meshtastic.core.strings.edit
import org.meshtastic.core.strings.generate_qr_code
import org.meshtastic.core.strings.modem_preset
import org.meshtastic.core.strings.navigate_into_label
import org.meshtastic.core.strings.replace
import org.meshtastic.core.strings.reset
import org.meshtastic.core.strings.reset_to_defaults
import org.meshtastic.core.strings.share_channels_qr
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar

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 com.geeksville.mesh.ui.sharing
import androidx.compose.foundation.layout.Column
@@ -43,12 +42,12 @@ import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.ui.contact.ContactItem
import com.geeksville.mesh.ui.contact.ContactsViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.strings.share
import org.meshtastic.core.strings.share_to
import org.meshtastic.core.strings.some_username
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.share
import org.meshtastic.core.resources.share_to
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright (c) 2025 Meshtastic LLC
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="app_name" translatable="false">Meshtastic</string>
</resources>

View File

@@ -1,161 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
import io.mockk.clearMocks
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.mock.mock
import no.nordicsemi.kotlin.ble.client.mock.ConnectionResult
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.client.mock.ReadResponse
import no.nordicsemi.kotlin.ble.client.mock.WriteResponse
import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Test
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceDrainTest {
private val testDispatcher = StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
@Test
fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) {
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromRadioHandle: Int = -1
var fromNumHandle: Int = -1
val packetsToRead = mutableListOf(byteArrayOf(0x01), byteArrayOf(0x02), byteArrayOf(0x03))
val eventHandler =
object : PeripheralSpecEventHandler {
override fun onConnectionRequest(preferredPhy: List<no.nordicsemi.kotlin.ble.core.Phy>) =
ConnectionResult.Accept
override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse {
if (characteristic.instanceId == fromRadioHandle) {
return if (packetsToRead.isNotEmpty()) {
val p = packetsToRead.removeAt(0)
println("Mock: Returning packet ${p.contentToString()}")
ReadResponse.Success(p)
} else {
println("Mock: Queue empty, returning empty")
ReadResponse.Success(byteArrayOf())
}
}
return ReadResponse.Success(byteArrayOf())
}
override fun onWriteRequest(characteristic: MockRemoteCharacteristic, value: ByteArray) =
WriteResponse.Success
}
val peripheralSpec =
PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) {
advertising(
parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds),
) {
CompleteLocalName("Meshtastic_Drain")
}
connectable(
name = "Meshtastic_Drain",
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
),
permission = Permission.WRITE,
)
fromNumHandle =
Characteristic(
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
}
},
)
}
centralManager.simulatePeripherals(listOf(peripheralSpec))
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
service = service,
address = address,
)
// Wait for connection
delay(2000.milliseconds)
verify(timeout = 5000) { service.onConnect() }
clearMocks(service, answers = false, recordedCalls = true)
// Trigger drain
println("Simulating FromNum notification...")
peripheralSpec.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01))
// Wait for all packets to be processed
delay(2000.milliseconds)
// Verify handleFromRadio was called 3 times
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x01)) }
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x02)) }
verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x03)) }
assert(packetsToRead.isEmpty()) { "All packets should have been read" }
nordicInterface.close()
}
}

View File

@@ -33,7 +33,7 @@ fun Project.configureDokka() {
suppress.set(true)
}
perPackageOption {
matchingRegex.set("org.meshtastic.core.strings.*")
matchingRegex.set("org.meshtastic.core.resources.*")
suppress.set(true)
}

View File

@@ -57,7 +57,7 @@ fun Project.configureKover() {
// Suppress generated code
packages("hilt_aggregated_deps")
packages("org.meshtastic.core.strings")
packages("org.meshtastic.core.resources")
}
}
}

View File

@@ -34,7 +34,7 @@ BarcodeScanner(
```mermaid
graph TB
:core:barcode[barcode]:::android-library
:core:barcode -.-> :core:strings
:core:barcode -.-> :core:resources
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -29,7 +29,7 @@ configure<LibraryExtension> {
}
dependencies {
implementation(project(":core:strings"))
implementation(project(":core:resources"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)

View File

@@ -64,8 +64,8 @@ import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import java.nio.ByteBuffer
import java.util.concurrent.Executors

View File

@@ -64,8 +64,8 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import java.util.concurrent.Executors
@Composable

View File

@@ -53,7 +53,8 @@ constructor(
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
val state: StateFlow<BluetoothState>
get() = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {

View File

@@ -67,9 +67,11 @@ constructor(
// 1. Emit cached data first, regardless of staleness.
// This gives the UI something to show immediately.
val cachedRelease = localDataSource.getLatestRelease(releaseType)
cachedRelease?.let {
Logger.d { "Emitting cached firmware for $releaseType (isStale=${it.isStale()})" }
emit(it.asExternalModel())
if (cachedRelease != null) {
Logger.d { "Emitting cached firmware for $releaseType (isStale=${cachedRelease.isStale()})" }
emit(cachedRelease.asExternalModel())
} else {
emit(null)
}
// 2. If the cache was fresh and we are not forcing a refresh, we're done.

View File

@@ -30,7 +30,7 @@ graph TB
:core:database -.-> :core:di
:core:database -.-> :core:model
:core:database -.-> :core:proto
:core:database -.-> :core:strings
:core:database -.-> :core:resources
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -36,7 +36,7 @@ dependencies {
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.strings)
implementation(projects.core.resources)
implementation(libs.androidx.room.paging)
implementation(libs.kotlinx.serialization.json)

View File

@@ -19,33 +19,33 @@ package org.meshtastic.core.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.delivery_confirmed
import org.meshtastic.core.strings.error
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.message_status_enroute
import org.meshtastic.core.strings.message_status_queued
import org.meshtastic.core.strings.message_status_sfpp_confirmed
import org.meshtastic.core.strings.message_status_sfpp_routing
import org.meshtastic.core.strings.routing_error_admin_bad_session_key
import org.meshtastic.core.strings.routing_error_admin_public_key_unauthorized
import org.meshtastic.core.strings.routing_error_bad_request
import org.meshtastic.core.strings.routing_error_duty_cycle_limit
import org.meshtastic.core.strings.routing_error_got_nak
import org.meshtastic.core.strings.routing_error_max_retransmit
import org.meshtastic.core.strings.routing_error_no_channel
import org.meshtastic.core.strings.routing_error_no_interface
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.routing_error_no_route
import org.meshtastic.core.strings.routing_error_none
import org.meshtastic.core.strings.routing_error_not_authorized
import org.meshtastic.core.strings.routing_error_pki_failed
import org.meshtastic.core.strings.routing_error_pki_send_fail_public_key
import org.meshtastic.core.strings.routing_error_pki_unknown_pubkey
import org.meshtastic.core.strings.routing_error_rate_limit_exceeded
import org.meshtastic.core.strings.routing_error_timeout
import org.meshtastic.core.strings.routing_error_too_large
import org.meshtastic.core.strings.unrecognized
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.message_status_enroute
import org.meshtastic.core.resources.message_status_queued
import org.meshtastic.core.resources.message_status_sfpp_confirmed
import org.meshtastic.core.resources.message_status_sfpp_routing
import org.meshtastic.core.resources.routing_error_admin_bad_session_key
import org.meshtastic.core.resources.routing_error_admin_public_key_unauthorized
import org.meshtastic.core.resources.routing_error_bad_request
import org.meshtastic.core.resources.routing_error_duty_cycle_limit
import org.meshtastic.core.resources.routing_error_got_nak
import org.meshtastic.core.resources.routing_error_max_retransmit
import org.meshtastic.core.resources.routing_error_no_channel
import org.meshtastic.core.resources.routing_error_no_interface
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.routing_error_no_route
import org.meshtastic.core.resources.routing_error_none
import org.meshtastic.core.resources.routing_error_not_authorized
import org.meshtastic.core.resources.routing_error_pki_failed
import org.meshtastic.core.resources.routing_error_pki_send_fail_public_key
import org.meshtastic.core.resources.routing_error_pki_unknown_pubkey
import org.meshtastic.core.resources.routing_error_rate_limit_exceeded
import org.meshtastic.core.resources.routing_error_timeout
import org.meshtastic.core.resources.routing_error_too_large
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Routing

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,18 +14,17 @@
* 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.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.node_sort_alpha
import org.meshtastic.core.strings.node_sort_channel
import org.meshtastic.core.strings.node_sort_distance
import org.meshtastic.core.strings.node_sort_hops_away
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.strings.node_sort_via_favorite
import org.meshtastic.core.strings.node_sort_via_mqtt
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_sort_alpha
import org.meshtastic.core.resources.node_sort_channel
import org.meshtastic.core.resources.node_sort_distance
import org.meshtastic.core.resources.node_sort_hops_away
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.resources.node_sort_via_favorite
import org.meshtastic.core.resources.node_sort_via_mqtt
enum class NodeSortOption(val sqlValue: String, val stringRes: StringResource) {
LAST_HEARD("last_heard", Res.string.node_sort_last_heard),

View File

@@ -1,7 +1,7 @@
# `:core:strings`
# `:core:resources`
## Overview
The `:core:strings` module is the centralized source for all UI strings and localizable resources. It uses the **Compose Multiplatform Resource** library to provide a type-safe way to access strings.
The `:core:resources` module is the centralized source for all UI strings and localizable resources. It uses the **Compose Multiplatform Resource** library to provide a type-safe way to access strings.
## Key Features
@@ -13,8 +13,8 @@ The library provides a standard way to access strings in Jetpack Compose.
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.your_string_key
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
@@ -24,7 +24,7 @@ Text(text = stringResource(Res.string.your_string_key))
<!--region graph-->
```mermaid
graph TB
:core:strings[strings]:::kmp-library
:core:resources[strings]:::kmp-library
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -29,5 +29,5 @@ kotlin {
compose.resources {
publicResClass = true
packageOfResClass = "org.meshtastic.core.strings"
packageOfResClass = "org.meshtastic.core.resources"
}

View File

@@ -14,7 +14,7 @@
* 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.strings
package org.meshtastic.core.resources
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource

View File

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 690 B

Some files were not shown because too many files have changed in this diff Show More