Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e3f4860
refactor(messaging): Improve message list performance and fix unread …
jamesarich Feb 22, 2026
7da4b52
feat(messaging): Optimize message list operations with Paging snapshots
jamesarich Feb 22, 2026
12009ee
perf(messaging): Optimize MessageList recomposition and performance
jamesarich Feb 22, 2026
51faaa7
refactor(messaging): Replace itemSnapshotList with direct paged list …
jamesarich Feb 22, 2026
e5bb03d
refactor(node): Centralize node detail logic with GetNodeDetailsUseCase
jamesarich Feb 22, 2026
10e2d14
refact(domain): Extract business logic into use cases
jamesarich Feb 22, 2026
91c3f8d
feat(testing): Add unit tests for domain use cases
jamesarich Feb 22, 2026
dbafda9
feat(i18n): Update Crowdin source path for Compose Resources
jamesarich Feb 22, 2026
9995ea8
chore: migrate core:strings to core:resources
jamesarich Feb 22, 2026
38a35a3
chore: migrate drawables to core:resources
jamesarich Feb 22, 2026
3ab20a8
chore: migrate feature node resources to core:resources
jamesarich Feb 22, 2026
c11deff
chore: remove duplicate string resource and finalize map view fixes
jamesarich Feb 22, 2026
2720685
docs: update AGENTS.md and CONTRIBUTING.md with new core:resources ar…
jamesarich Feb 22, 2026
a1ca127
refactor(testing): Remove obsolete `GetDiscoveredDevicesUseCaseTest` …
jamesarich Feb 23, 2026
b6b4247
spotless
jamesarich Feb 23, 2026
fb5d0d2
refactor(core): Centralize UiText utility and improve node name resol…
jamesarich Feb 23, 2026
297d09d
fix(ui): improve KMP compose resource usage, fix UiText stability, si…
jamesarich Feb 23, 2026
a456df8
fix(test): Prevent service crash in tests before Hilt is ready
jamesarich Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
```
Expand Down Expand Up @@ -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!).
Expand All @@ -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`).
8 changes: 4 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
```
Expand Down
2 changes: 1 addition & 1 deletion app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/geeksville/mesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
6 changes: 3 additions & 3 deletions app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading