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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
322 changed files with 3031 additions and 2790 deletions

View file

@ -16,7 +16,7 @@ graph TB
:feature:firmware -.-> :core:prefs
:feature:firmware -.-> :core:proto
:feature:firmware -.-> :core:service
:feature:firmware -.-> :core:strings
:feature:firmware -.-> :core:resources
:feature:firmware -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -36,7 +36,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(libs.accompanist.permissions)

View file

@ -24,9 +24,9 @@ import kotlinx.coroutines.runBlocking
import no.nordicsemi.android.dfu.DfuBaseService
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.model.BuildConfig
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_channel_description
import org.meshtastic.core.strings.firmware_update_channel_name
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_channel_description
import org.meshtastic.core.resources.firmware_update_channel_name
class FirmwareDfuService : DfuBaseService() {
override fun onCreate() {

View file

@ -91,50 +91,51 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.back
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.chirpy
import org.meshtastic.core.strings.dont_show_again_for_device
import org.meshtastic.core.strings.firmware_update_almost_there
import org.meshtastic.core.strings.firmware_update_alpha
import org.meshtastic.core.strings.firmware_update_checking
import org.meshtastic.core.strings.firmware_update_currently_installed
import org.meshtastic.core.strings.firmware_update_device
import org.meshtastic.core.strings.firmware_update_disclaimer_chirpy_says
import org.meshtastic.core.strings.firmware_update_disclaimer_text
import org.meshtastic.core.strings.firmware_update_disclaimer_title
import org.meshtastic.core.strings.firmware_update_disconnect_warning
import org.meshtastic.core.strings.firmware_update_do_not_close
import org.meshtastic.core.strings.firmware_update_done
import org.meshtastic.core.strings.firmware_update_error
import org.meshtastic.core.strings.firmware_update_hang_tight
import org.meshtastic.core.strings.firmware_update_keep_device_close
import org.meshtastic.core.strings.firmware_update_latest
import org.meshtastic.core.strings.firmware_update_local_file
import org.meshtastic.core.strings.firmware_update_method_detail
import org.meshtastic.core.strings.firmware_update_rak4631_bootloader_hint
import org.meshtastic.core.strings.firmware_update_release_notes
import org.meshtastic.core.strings.firmware_update_retry
import org.meshtastic.core.strings.firmware_update_save_dfu_file
import org.meshtastic.core.strings.firmware_update_select_file
import org.meshtastic.core.strings.firmware_update_source_local
import org.meshtastic.core.strings.firmware_update_stable
import org.meshtastic.core.strings.firmware_update_success
import org.meshtastic.core.strings.firmware_update_taking_a_while
import org.meshtastic.core.strings.firmware_update_target
import org.meshtastic.core.strings.firmware_update_title
import org.meshtastic.core.strings.firmware_update_unknown_release
import org.meshtastic.core.strings.firmware_update_usb_bootloader_warning
import org.meshtastic.core.strings.firmware_update_usb_instruction_text
import org.meshtastic.core.strings.firmware_update_usb_instruction_title
import org.meshtastic.core.strings.firmware_update_verification_failed
import org.meshtastic.core.strings.firmware_update_verifying
import org.meshtastic.core.strings.firmware_update_waiting_reconnect
import org.meshtastic.core.strings.i_know_what_i_m_doing
import org.meshtastic.core.strings.learn_more
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.save
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.chirpy
import org.meshtastic.core.resources.dont_show_again_for_device
import org.meshtastic.core.resources.firmware_update_almost_there
import org.meshtastic.core.resources.firmware_update_alpha
import org.meshtastic.core.resources.firmware_update_checking
import org.meshtastic.core.resources.firmware_update_currently_installed
import org.meshtastic.core.resources.firmware_update_device
import org.meshtastic.core.resources.firmware_update_disclaimer_chirpy_says
import org.meshtastic.core.resources.firmware_update_disclaimer_text
import org.meshtastic.core.resources.firmware_update_disclaimer_title
import org.meshtastic.core.resources.firmware_update_disconnect_warning
import org.meshtastic.core.resources.firmware_update_do_not_close
import org.meshtastic.core.resources.firmware_update_done
import org.meshtastic.core.resources.firmware_update_error
import org.meshtastic.core.resources.firmware_update_hang_tight
import org.meshtastic.core.resources.firmware_update_keep_device_close
import org.meshtastic.core.resources.firmware_update_latest
import org.meshtastic.core.resources.firmware_update_local_file
import org.meshtastic.core.resources.firmware_update_method_detail
import org.meshtastic.core.resources.firmware_update_rak4631_bootloader_hint
import org.meshtastic.core.resources.firmware_update_release_notes
import org.meshtastic.core.resources.firmware_update_retry
import org.meshtastic.core.resources.firmware_update_save_dfu_file
import org.meshtastic.core.resources.firmware_update_select_file
import org.meshtastic.core.resources.firmware_update_source_local
import org.meshtastic.core.resources.firmware_update_stable
import org.meshtastic.core.resources.firmware_update_success
import org.meshtastic.core.resources.firmware_update_taking_a_while
import org.meshtastic.core.resources.firmware_update_target
import org.meshtastic.core.resources.firmware_update_title
import org.meshtastic.core.resources.firmware_update_unknown_release
import org.meshtastic.core.resources.firmware_update_usb_bootloader_warning
import org.meshtastic.core.resources.firmware_update_usb_instruction_text
import org.meshtastic.core.resources.firmware_update_usb_instruction_title
import org.meshtastic.core.resources.firmware_update_verification_failed
import org.meshtastic.core.resources.firmware_update_verifying
import org.meshtastic.core.resources.firmware_update_waiting_reconnect
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.img_chirpy
import org.meshtastic.core.resources.learn_more
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.CheckCircle
@ -336,7 +337,9 @@ private fun FirmwareUpdateContent(
is FirmwareUpdateState.Verifying -> VerifyingState()
is FirmwareUpdateState.VerificationFailed ->
VerificationFailedState(onRetry = actions.onStartUpdate, onIgnore = actions.onDone)
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry)
is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone)
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile)
}
@ -493,7 +496,7 @@ private fun ChirpyCard() {
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(org.meshtastic.core.ui.R.drawable.chirpy)
.data(Res.drawable.img_chirpy)
.crossfade(true)
.build(),
contentScale = ContentScale.Fit,

View file

@ -49,30 +49,30 @@ import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_battery_low
import org.meshtastic.core.resources.firmware_update_copying
import org.meshtastic.core.resources.firmware_update_dfu_aborted
import org.meshtastic.core.resources.firmware_update_dfu_error
import org.meshtastic.core.resources.firmware_update_disconnecting
import org.meshtastic.core.resources.firmware_update_enabling_dfu
import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.resources.firmware_update_failed
import org.meshtastic.core.resources.firmware_update_flashing
import org.meshtastic.core.resources.firmware_update_local_failed
import org.meshtastic.core.resources.firmware_update_method_ble
import org.meshtastic.core.resources.firmware_update_method_usb
import org.meshtastic.core.resources.firmware_update_method_wifi
import org.meshtastic.core.resources.firmware_update_no_device
import org.meshtastic.core.resources.firmware_update_node_info_missing
import org.meshtastic.core.resources.firmware_update_starting_dfu
import org.meshtastic.core.resources.firmware_update_unknown_error
import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_updating
import org.meshtastic.core.resources.firmware_update_validating
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_battery_low
import org.meshtastic.core.strings.firmware_update_copying
import org.meshtastic.core.strings.firmware_update_dfu_aborted
import org.meshtastic.core.strings.firmware_update_dfu_error
import org.meshtastic.core.strings.firmware_update_disconnecting
import org.meshtastic.core.strings.firmware_update_enabling_dfu
import org.meshtastic.core.strings.firmware_update_extracting
import org.meshtastic.core.strings.firmware_update_failed
import org.meshtastic.core.strings.firmware_update_flashing
import org.meshtastic.core.strings.firmware_update_local_failed
import org.meshtastic.core.strings.firmware_update_method_ble
import org.meshtastic.core.strings.firmware_update_method_usb
import org.meshtastic.core.strings.firmware_update_method_wifi
import org.meshtastic.core.strings.firmware_update_no_device
import org.meshtastic.core.strings.firmware_update_node_info_missing
import org.meshtastic.core.strings.firmware_update_starting_dfu
import org.meshtastic.core.strings.firmware_update_unknown_error
import org.meshtastic.core.strings.firmware_update_unknown_hardware
import org.meshtastic.core.strings.firmware_update_updating
import org.meshtastic.core.strings.firmware_update_validating
import org.meshtastic.core.strings.unknown
import java.io.File
import javax.inject.Inject

View file

@ -33,12 +33,12 @@ import no.nordicsemi.android.dfu.DfuServiceListenerHelper
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_nordic_failed
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_starting_service
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_downloading_percent
import org.meshtastic.core.strings.firmware_update_nordic_failed
import org.meshtastic.core.strings.firmware_update_not_found_in_release
import org.meshtastic.core.strings.firmware_update_starting_service
import java.io.File
import javax.inject.Inject

View file

@ -23,12 +23,12 @@ import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_rebooting
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_usb_failed
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_downloading_percent
import org.meshtastic.core.strings.firmware_update_rebooting
import org.meshtastic.core.strings.firmware_update_retrieval_failed
import org.meshtastic.core.strings.firmware_update_usb_failed
import java.io.File
import javax.inject.Inject

View file

@ -29,18 +29,18 @@ import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_connecting_attempt
import org.meshtastic.core.resources.firmware_update_downloading_percent
import org.meshtastic.core.resources.firmware_update_erasing
import org.meshtastic.core.resources.firmware_update_hash_rejected
import org.meshtastic.core.resources.firmware_update_loading
import org.meshtastic.core.resources.firmware_update_ota_failed
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_starting_ota
import org.meshtastic.core.resources.firmware_update_uploading
import org.meshtastic.core.resources.firmware_update_waiting_reboot
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_connecting_attempt
import org.meshtastic.core.strings.firmware_update_downloading_percent
import org.meshtastic.core.strings.firmware_update_erasing
import org.meshtastic.core.strings.firmware_update_hash_rejected
import org.meshtastic.core.strings.firmware_update_loading
import org.meshtastic.core.strings.firmware_update_ota_failed
import org.meshtastic.core.strings.firmware_update_retrieval_failed
import org.meshtastic.core.strings.firmware_update_starting_ota
import org.meshtastic.core.strings.firmware_update_uploading
import org.meshtastic.core.strings.firmware_update_waiting_reboot
import org.meshtastic.feature.firmware.FirmwareRetriever
import org.meshtastic.feature.firmware.FirmwareUpdateHandler
import org.meshtastic.feature.firmware.FirmwareUpdateState

View file

@ -20,7 +20,7 @@ Dedicated screens for explaining and requesting specific permissions:
```mermaid
graph TB
:feature:intro[intro]:::android-feature
:feature:intro -.-> :core:strings
:feature:intro -.-> :core:resources
:feature:intro -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -27,7 +27,7 @@ plugins {
configure<LibraryExtension> { namespace = "org.meshtastic.feature.intro" }
dependencies {
implementation(projects.core.strings)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.androidx.compose.material.iconsExtended)

View file

@ -29,13 +29,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.analytics_notice
import org.meshtastic.core.strings.analytics_platforms
import org.meshtastic.core.strings.datadog_link
import org.meshtastic.core.strings.firebase_link
import org.meshtastic.core.strings.for_more_information_see_our_privacy_policy
import org.meshtastic.core.strings.privacy_url
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.analytics_notice
import org.meshtastic.core.resources.analytics_platforms
import org.meshtastic.core.resources.datadog_link
import org.meshtastic.core.resources.firebase_link
import org.meshtastic.core.resources.for_more_information_see_our_privacy_policy
import org.meshtastic.core.resources.privacy_url
import org.meshtastic.core.ui.component.AutoLinkText
@Composable

View file

@ -33,9 +33,9 @@ import kotlinx.serialization.Serializable
import no.nordicsemi.android.common.permissions.ble.RequireBluetooth
import no.nordicsemi.android.common.permissions.ble.RequireLocation
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.permission_denied
import org.meshtastic.core.strings.permission_granted
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.permission_denied
import org.meshtastic.core.resources.permission_granted
import org.meshtastic.core.ui.util.showToast
/**

View file

@ -25,16 +25,16 @@ import androidx.compose.material.icons.outlined.SettingsInputAntenna
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.bluetooth_feature_config
import org.meshtastic.core.strings.bluetooth_feature_config_description
import org.meshtastic.core.strings.bluetooth_feature_discovery
import org.meshtastic.core.strings.bluetooth_feature_discovery_description
import org.meshtastic.core.strings.bluetooth_permission
import org.meshtastic.core.strings.configure_bluetooth_permissions
import org.meshtastic.core.strings.next
import org.meshtastic.core.strings.permission_missing_31
import org.meshtastic.core.strings.settings
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_feature_config
import org.meshtastic.core.resources.bluetooth_feature_config_description
import org.meshtastic.core.resources.bluetooth_feature_discovery
import org.meshtastic.core.resources.bluetooth_feature_discovery_description
import org.meshtastic.core.resources.bluetooth_permission
import org.meshtastic.core.resources.configure_bluetooth_permissions
import org.meshtastic.core.resources.next
import org.meshtastic.core.resources.permission_missing_31
import org.meshtastic.core.resources.settings
/**
* Screen for configuring Bluetooth permissions during the app introduction. It explains why Bluetooth permissions are

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.compose.foundation.layout.Column
@ -34,11 +33,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.configure_critical_alerts
import org.meshtastic.core.strings.critical_alerts
import org.meshtastic.core.strings.critical_alerts_dnd_request_text
import org.meshtastic.core.strings.skip
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.configure_critical_alerts
import org.meshtastic.core.resources.critical_alerts
import org.meshtastic.core.resources.critical_alerts_dnd_request_text
import org.meshtastic.core.resources.skip
/**
* Screen for explaining and guiding the user to configure critical alert settings. This screen is part of the app

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import android.content.Intent
@ -26,20 +25,20 @@ import androidx.compose.material.icons.outlined.Router
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.configure_location_permissions
import org.meshtastic.core.strings.distance_filters
import org.meshtastic.core.strings.distance_filters_description
import org.meshtastic.core.strings.distance_measurements
import org.meshtastic.core.strings.distance_measurements_description
import org.meshtastic.core.strings.mesh_map_location
import org.meshtastic.core.strings.mesh_map_location_description
import org.meshtastic.core.strings.next
import org.meshtastic.core.strings.phone_location
import org.meshtastic.core.strings.phone_location_description
import org.meshtastic.core.strings.settings
import org.meshtastic.core.strings.share_location
import org.meshtastic.core.strings.share_location_description
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.configure_location_permissions
import org.meshtastic.core.resources.distance_filters
import org.meshtastic.core.resources.distance_filters_description
import org.meshtastic.core.resources.distance_measurements
import org.meshtastic.core.resources.distance_measurements_description
import org.meshtastic.core.resources.mesh_map_location
import org.meshtastic.core.resources.mesh_map_location_description
import org.meshtastic.core.resources.next
import org.meshtastic.core.resources.phone_location
import org.meshtastic.core.resources.phone_location_description
import org.meshtastic.core.resources.settings
import org.meshtastic.core.resources.share_location
import org.meshtastic.core.resources.share_location_description
/**
* Screen for configuring location permissions during the app introduction. It explains why location permissions are

View file

@ -26,18 +26,18 @@ import androidx.compose.material.icons.outlined.SpeakerPhone
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_notifications
import org.meshtastic.core.strings.configure_notification_permissions
import org.meshtastic.core.strings.incoming_messages
import org.meshtastic.core.strings.low_battery
import org.meshtastic.core.strings.new_nodes
import org.meshtastic.core.strings.next
import org.meshtastic.core.strings.notification_permissions_description
import org.meshtastic.core.strings.notifications_for_channel_and_direct_messages
import org.meshtastic.core.strings.notifications_for_low_battery_alerts
import org.meshtastic.core.strings.notifications_for_newly_discovered_nodes
import org.meshtastic.core.strings.settings
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_notifications
import org.meshtastic.core.resources.configure_notification_permissions
import org.meshtastic.core.resources.incoming_messages
import org.meshtastic.core.resources.low_battery
import org.meshtastic.core.resources.new_nodes
import org.meshtastic.core.resources.next
import org.meshtastic.core.resources.notification_permissions_description
import org.meshtastic.core.resources.notifications_for_channel_and_direct_messages
import org.meshtastic.core.resources.notifications_for_low_battery_alerts
import org.meshtastic.core.resources.notifications_for_newly_discovered_nodes
import org.meshtastic.core.resources.settings
/**
* Screen for configuring notification permissions during the app introduction. It explains why notification permissions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.compose.foundation.gestures.detectTapGestures
@ -43,8 +42,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.skip
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.skip
/**
* A generic layout for screens within the app introduction flow. It typically presents a headline, a descriptive text

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.compose.foundation.layout.Column
@ -40,16 +39,16 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.communicate_off_the_grid
import org.meshtastic.core.strings.create_your_own_networks
import org.meshtastic.core.strings.easily_set_up_private_mesh_networks
import org.meshtastic.core.strings.get_started
import org.meshtastic.core.strings.intro_welcome
import org.meshtastic.core.strings.meshtastic
import org.meshtastic.core.strings.share_your_location_in_real_time
import org.meshtastic.core.strings.stay_connected_anywhere
import org.meshtastic.core.strings.track_and_share_locations
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.communicate_off_the_grid
import org.meshtastic.core.resources.create_your_own_networks
import org.meshtastic.core.resources.easily_set_up_private_mesh_networks
import org.meshtastic.core.resources.get_started
import org.meshtastic.core.resources.intro_welcome
import org.meshtastic.core.resources.meshtastic
import org.meshtastic.core.resources.share_your_location_in_real_time
import org.meshtastic.core.resources.stay_connected_anywhere
import org.meshtastic.core.resources.track_and_share_locations
/**
* The initial welcome screen for the app introduction flow. It displays a brief overview of the app's key features.

View file

@ -36,7 +36,7 @@ graph TB
:feature:map -.-> :core:prefs
:feature:map -.-> :core:proto
:feature:map -.-> :core:service
:feature:map -.-> :core:strings
:feature:map -.-> :core:resources
:feature:map -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -35,7 +35,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(libs.accompanist.permissions)

View file

@ -89,38 +89,38 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.calculating
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.clear
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete_for_everyone
import org.meshtastic.core.strings.delete_for_me
import org.meshtastic.core.strings.expires
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.location_disabled
import org.meshtastic.core.strings.map_cache_info
import org.meshtastic.core.strings.map_cache_manager
import org.meshtastic.core.strings.map_cache_size
import org.meshtastic.core.strings.map_cache_tiles
import org.meshtastic.core.strings.map_clear_tiles
import org.meshtastic.core.strings.map_download_complete
import org.meshtastic.core.strings.map_download_errors
import org.meshtastic.core.strings.map_download_region
import org.meshtastic.core.strings.map_filter
import org.meshtastic.core.strings.map_node_popup_details
import org.meshtastic.core.strings.map_offline_manager
import org.meshtastic.core.strings.map_purge_fail
import org.meshtastic.core.strings.map_purge_success
import org.meshtastic.core.strings.map_style_selection
import org.meshtastic.core.strings.map_subDescription
import org.meshtastic.core.strings.map_tile_source
import org.meshtastic.core.strings.only_favorites
import org.meshtastic.core.strings.show_precision_circle
import org.meshtastic.core.strings.show_waypoints
import org.meshtastic.core.strings.toggle_my_position
import org.meshtastic.core.strings.waypoint_delete
import org.meshtastic.core.strings.you
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
import org.meshtastic.core.resources.map_cache_tiles
import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
import org.meshtastic.core.resources.map_purge_success
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.TracerouteColors
@ -281,18 +281,18 @@ fun MapView(
scope.launch { context.showToast(Res.string.location_disabled) }
return
}
Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" }
if (myLocationOverlay == null) {
myLocationOverlay =
MyLocationNewOverlay(this).apply {
enableMyLocation()
enableFollowLocation()
getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_location_dot_24)
?.let {
setPersonIcon(it)
setPersonAnchor(0.5f, 0.5f)
}
getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_navigation_24)?.let {
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let {
setPersonIcon(it)
setPersonAnchor(0.5f, 0.5f)
}
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let {
setDirectionIcon(it)
setDirectionAnchor(0.5f, 0.5f)
}
@ -388,9 +388,7 @@ fun MapView(
val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
val markerIcon = remember {
AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24)
}
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }

View file

@ -24,7 +24,6 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import org.meshtastic.core.ui.R
import org.meshtastic.proto.Position
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
@ -125,7 +124,7 @@ fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: ()
}
fun MapView.addPositionMarkers(positions: List<Position>, onClick: () -> Unit): List<Marker> {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24)
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
val markers =
positions.map {
Marker(this).apply {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.background
@ -36,11 +35,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.map_select_download_region
import org.meshtastic.core.strings.map_start_download
import org.meshtastic.core.strings.map_tile_download_estimate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.map_select_download_region
import org.meshtastic.core.resources.map_start_download
import org.meshtastic.core.resources.map_tile_download_estimate
@OptIn(ExperimentalLayoutApi::class)
@Composable

View file

@ -30,8 +30,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.map_download_region
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_download_region
@Composable
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {

View file

@ -70,18 +70,18 @@ import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.date
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.description
import org.meshtastic.core.strings.expires
import org.meshtastic.core.strings.locked
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.time
import org.meshtastic.core.strings.waypoint_edit
import org.meshtastic.core.strings.waypoint_new
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.theme.AppTheme

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.size
@ -29,8 +28,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.map_style_selection
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#3388ff"
android:strokeWidth="1.25"
android:strokeColor="@android:color/white"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#3388ff"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="2.0"
android:strokeColor="@android:color/white" />
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#3388ff"
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
android:strokeWidth="1.5"
android:strokeColor="@android:color/white" />
</vector>

View file

@ -97,16 +97,16 @@ import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alt
import org.meshtastic.core.strings.heading
import org.meshtastic.core.strings.latitude
import org.meshtastic.core.strings.longitude
import org.meshtastic.core.strings.position
import org.meshtastic.core.strings.sats
import org.meshtastic.core.strings.speed
import org.meshtastic.core.strings.timestamp
import org.meshtastic.core.strings.track_point
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.clickable
@ -31,9 +30,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.nodes_at_this_location
import org.meshtastic.core.strings.okay
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes_at_this_location
import org.meshtastic.core.resources.okay
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.feature.map.model.NodeClusterItem

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.PaddingValues
@ -39,14 +38,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_layer
import org.meshtastic.core.strings.hide_layer
import org.meshtastic.core.strings.manage_map_layers
import org.meshtastic.core.strings.map_layer_formats
import org.meshtastic.core.strings.no_map_layers_loaded
import org.meshtastic.core.strings.remove_layer
import org.meshtastic.core.strings.show_layer
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.hide_layer
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.no_map_layers_loaded
import org.meshtastic.core.resources.remove_layer
import org.meshtastic.core.resources.show_layer
import org.meshtastic.feature.map.MapLayerItem
@Suppress("LongMethod")

View file

@ -49,21 +49,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_custom_tile_source
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.delete_custom_tile_source
import org.meshtastic.core.strings.edit_custom_tile_source
import org.meshtastic.core.strings.manage_custom_tile_sources
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.name_cannot_be_empty
import org.meshtastic.core.strings.no_custom_tile_sources_found
import org.meshtastic.core.strings.provider_name_exists
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.url_cannot_be_empty
import org.meshtastic.core.strings.url_must_contain_placeholders
import org.meshtastic.core.strings.url_template
import org.meshtastic.core.strings.url_template_hint
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete_custom_tile_source
import org.meshtastic.core.resources.edit_custom_tile_source
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.name_cannot_be_empty
import org.meshtastic.core.resources.no_custom_tile_sources_found
import org.meshtastic.core.resources.provider_name_exists
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.url_cannot_be_empty
import org.meshtastic.core.resources.url_must_contain_placeholders
import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.MapViewModel

View file

@ -71,18 +71,18 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.date
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.description
import org.meshtastic.core.strings.expires
import org.meshtastic.core.strings.locked
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.time
import org.meshtastic.core.strings.waypoint_edit
import org.meshtastic.core.strings.waypoint_new
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours

View file

@ -32,12 +32,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.manage_map_layers
import org.meshtastic.core.strings.map_filter
import org.meshtastic.core.strings.map_tile_source
import org.meshtastic.core.strings.orient_north
import org.meshtastic.core.strings.toggle_my_position
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.map.MapViewModel

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Column
@ -40,11 +39,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.last_heard_filter_label
import org.meshtastic.core.strings.only_favorites
import org.meshtastic.core.strings.show_precision_circle
import org.meshtastic.core.strings.show_waypoints
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.MapViewModel
import kotlin.math.roundToInt

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.material.icons.Icons
@ -29,13 +28,13 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.maps.android.compose.MapType
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.manage_custom_tile_sources
import org.meshtastic.core.strings.map_type_hybrid
import org.meshtastic.core.strings.map_type_normal
import org.meshtastic.core.strings.map_type_satellite
import org.meshtastic.core.strings.map_type_terrain
import org.meshtastic.core.strings.selected_map_type
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.map_type_hybrid
import org.meshtastic.core.resources.map_type_normal
import org.meshtastic.core.resources.map_type_satellite
import org.meshtastic.core.resources.map_type_terrain
import org.meshtastic.core.resources.selected_map_type
import org.meshtastic.feature.map.MapViewModel
@Suppress("LongMethod")

View file

@ -25,8 +25,8 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.locked
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint

View file

@ -36,13 +36,13 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.any
import org.meshtastic.core.resources.eight_hours
import org.meshtastic.core.resources.one_day
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.any
import org.meshtastic.core.strings.eight_hours
import org.meshtastic.core.strings.one_day
import org.meshtastic.core.strings.one_hour
import org.meshtastic.core.strings.two_days
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import androidx.compose.foundation.layout.Box
@ -26,8 +25,8 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.map
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
@Composable

View file

@ -32,7 +32,7 @@ graph TB
:feature:messaging -.-> :core:prefs
:feature:messaging -.-> :core:proto
:feature:messaging -.-> :core:service
:feature:messaging -.-> :core:strings
:feature:messaging -.-> :core:resources
:feature:messaging -.-> :core:ui
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -48,7 +48,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(libs.androidx.compose.material.iconsExtended)
@ -64,4 +64,6 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}

View file

@ -29,10 +29,10 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.relays
import org.meshtastic.core.strings.resend
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.relays
import org.meshtastic.core.resources.resend
import org.meshtastic.core.ui.component.MeshtasticDialog
@Suppress("UnusedParameter")

View file

@ -105,32 +105,32 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alert_bell_text
import org.meshtastic.core.strings.cancel_reply
import org.meshtastic.core.strings.clear_selection
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.delete_messages
import org.meshtastic.core.strings.delete_messages_title
import org.meshtastic.core.strings.filter_disable_for_contact
import org.meshtastic.core.strings.filter_enable_for_contact
import org.meshtastic.core.strings.filter_hide_count
import org.meshtastic.core.strings.filter_show_count
import org.meshtastic.core.strings.message_input_label
import org.meshtastic.core.strings.navigate_back
import org.meshtastic.core.strings.overflow_menu
import org.meshtastic.core.strings.quick_chat
import org.meshtastic.core.strings.quick_chat_hide
import org.meshtastic.core.strings.quick_chat_show
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.replying_to
import org.meshtastic.core.strings.scroll_to_bottom
import org.meshtastic.core.strings.select_all
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.type_a_message
import org.meshtastic.core.strings.unknown
import org.meshtastic.core.strings.unknown_channel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alert_bell_text
import org.meshtastic.core.resources.cancel_reply
import org.meshtastic.core.resources.clear_selection
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_messages_title
import org.meshtastic.core.resources.filter_disable_for_contact
import org.meshtastic.core.resources.filter_enable_for_contact
import org.meshtastic.core.resources.filter_hide_count
import org.meshtastic.core.resources.filter_show_count
import org.meshtastic.core.resources.message_input_label
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.resources.overflow_menu
import org.meshtastic.core.resources.quick_chat
import org.meshtastic.core.resources.quick_chat_hide
import org.meshtastic.core.resources.quick_chat_show
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.replying_to
import org.meshtastic.core.resources.scroll_to_bottom
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.type_a_message
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
import org.meshtastic.core.ui.component.SecurityIcon
@ -305,11 +305,7 @@ fun MessageScreen(
val originalMessage by
remember(replyingToPacketId, pagedMessages.itemCount) {
derivedStateOf {
replyingToPacketId?.let { id ->
(0 until pagedMessages.itemCount).firstNotNullOfOrNull { index ->
pagedMessages[index]?.takeIf { it.packetId == id }
}
}
replyingToPacketId?.let { id -> pagedMessages.itemSnapshotList.firstOrNull { it?.packetId == id } }
}
}

View file

@ -18,13 +18,13 @@ package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
@ -53,6 +53,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@ -65,8 +66,8 @@ import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.new_messages_below
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.new_messages_below
import org.meshtastic.feature.messaging.component.MessageItem
import org.meshtastic.feature.messaging.component.ReactionDialog
@ -192,13 +193,13 @@ private fun MessageListPagedContent(
modifier: Modifier = Modifier,
quickEmojis: List<String>,
) {
// Calculate unread divider position
// Calculate unread divider position using snapshot to avoid side-effects and improve performance
// Optimized: Use full snapshot index to correctly match LazyColumn index range
val unreadDividerIndex by
remember(state.messages.itemCount, state.firstUnreadMessageUuid) {
derivedStateOf {
state.firstUnreadMessageUuid?.let { uuid ->
(0 until state.messages.itemCount).firstOrNull { index -> state.messages[index]?.uuid == uuid }
}
val uuid = state.firstUnreadMessageUuid ?: return@derivedStateOf null
state.messages.itemSnapshotList.indexOfFirst { it?.uuid == uuid }.takeIf { it != -1 }
}
}
@ -212,7 +213,11 @@ private fun MessageListPagedContent(
reverseLayout = true,
contentPadding = PaddingValues(bottom = 24.dp),
) {
items(count = state.messages.itemCount, key = state.messages.itemKey { it.uuid }) { index ->
items(
count = state.messages.itemCount,
key = state.messages.itemKey { it.uuid },
contentType = state.messages.itemContentType { "message" },
) { index ->
val message = state.messages[index]
val visuallyPrevMessage = if (index < state.messages.itemCount - 1) state.messages[index + 1] else null
val visuallyNextMessage = if (index > 0) state.messages[index - 1] else null
@ -234,27 +239,49 @@ private fun MessageListPagedContent(
}
if (message != null) {
renderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
enableAnimations = enableAnimations,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
val isFirstUnread = state.hasUnreadMessages && unreadDividerIndex == index
val itemModifier = if (enableAnimations) Modifier.animateItem() else Modifier
// Show unread divider after the first unread message
if (state.hasUnreadMessages && unreadDividerIndex == index) {
UnreadMessagesDivider(modifier = if (enableAnimations) Modifier.animateItem() else Modifier)
if (isFirstUnread) {
// Wrap in Column to prevent overlapping of divider and message item
// Apply animation to the container Column once
Column(modifier = itemModifier) {
UnreadMessagesDivider()
RenderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
} else {
RenderPagedChatMessageRow(
message = message,
state = state,
nodeMap = nodeMap,
handlers = handlers,
inSelectionMode = inSelectionMode,
coroutineScope = coroutineScope,
haptics = haptics,
listState = listState,
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
modifier = itemModifier,
showUserName = !hasSamePrev,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
}
}
@ -263,7 +290,7 @@ private fun MessageListPagedContent(
state.messages.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
item(key = "append_loading", contentType = "loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
@ -280,7 +307,7 @@ private fun MessageListPagedContent(
@Suppress("LongParameterList")
@Composable
private fun LazyItemScope.renderPagedChatMessageRow(
private fun RenderPagedChatMessageRow(
message: Message,
state: MessageListPagedState,
nodeMap: Map<Int, Node>,
@ -291,7 +318,7 @@ private fun LazyItemScope.renderPagedChatMessageRow(
listState: LazyListState,
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List<Reaction>) -> Unit,
enableAnimations: Boolean,
modifier: Modifier = Modifier,
showUserName: Boolean,
hasSamePrev: Boolean,
hasSameNext: Boolean,
@ -305,7 +332,7 @@ private fun LazyItemScope.renderPagedChatMessageRow(
val node = nodeMap[message.node.num] ?: message.node
MessageItem(
modifier = if (enableAnimations) Modifier.animateItem() else Modifier,
modifier = modifier,
node = node,
ourNode = ourNode,
message = message,
@ -341,12 +368,10 @@ private fun LazyItemScope.renderPagedChatMessageRow(
onNavigateToOriginalMessage = {
coroutineScope.launch {
// Note: With pagination, we can't guarantee the original message is loaded
// This is a limitation of pagination - we would need to implement
// a search/jump feature to load and scroll to specific messages
// Optimized: Use snapshot to find index to avoid side-effects during search
val targetIndex =
(0 until state.messages.itemCount).firstOrNull { index ->
state.messages[index]?.packetId == message.replyId
}
state.messages.itemSnapshotList.indexOfFirst { it?.packetId == message.replyId }.takeIf { it != -1 }
if (targetIndex != null) {
listState.animateScrollToItem(index = targetIndex)
}
@ -408,19 +433,23 @@ private fun AutoScrollToBottomPaged(
}
private fun findFirstVisibleUnreadMessage(messages: LazyPagingItems<Message>, visibleIndex: Int): Message? {
val snapshot = messages.itemSnapshotList
if (visibleIndex >= snapshot.size) return null
val firstVisibleUnreadIndex =
(visibleIndex until messages.itemCount).firstOrNull { i ->
val msg = messages[i]
(visibleIndex until snapshot.size).firstOrNull { i ->
val msg = snapshot[i]
msg != null && !msg.read && !msg.fromLocal
}
return firstVisibleUnreadIndex?.let { messages[it] }
return firstVisibleUnreadIndex?.let { snapshot[it] }
}
private fun findLastUnreadMessageIndex(messages: LazyPagingItems<Message>): Int? =
(0 until messages.itemCount).lastOrNull { i ->
val msg = messages[i]
private fun findLastUnreadMessageIndex(messages: LazyPagingItems<Message>): Int? {
val snapshot = messages.itemSnapshotList
return (0 until snapshot.size).lastOrNull { i ->
val msg = snapshot[i]
msg != null && !msg.read && !msg.fromLocal
}
}
@OptIn(FlowPreview::class)
@Composable
@ -503,7 +532,7 @@ internal fun UnreadMessagesDivider(modifier: Modifier = Modifier) {
}
@Composable
internal fun MessageStatusDialog(
private fun MessageStatusDialog(
message: Message,
nodes: List<Node>,
ourNode: Node?,

View file

@ -16,13 +16,11 @@
*/
package org.meshtastic.feature.messaging
import android.os.RemoteException
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@ -41,7 +39,6 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
@ -50,9 +47,8 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DeviceConfig.Role
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions")
@ -70,6 +66,7 @@ constructor(
private val customEmojiPrefs: CustomEmojiPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
private val sendMessageUseCase: SendMessageUseCase,
) : ViewModel() {
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
@ -194,46 +191,8 @@ constructor(
* broadcasting on channel 0.
* @param replyId The ID of the message this is a reply to, if any.
*/
@Suppress("NestedBlockDepth")
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
// if the destination is a node, we need to ensure it's a
// favorite so it does not get removed from the on-device node database.
if (channel == null) { // no channel specified, so we assume it's a direct message
val fwVersion = ourNodeInfo.value?.metadata?.firmware_version
val destNode = nodeRepository.getNode(dest)
val isClientBase = ourNodeInfo.value?.user?.role == Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
if (capabilities.canSendVerifiedContacts) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
}
// Applying homoglyph encoding to the transmitted string if user has activated the feature
// In most cases the value in "str" parameter will already contain the correct
// transformed string from the text input. This call here added to make sure that
// the feature is effective across all possible message paths (quick-chat, reply, etc.)
val dataPacketText: String =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(str)
} else {
str
}
val p =
DataPacket(dest, channel ?: 0, dataPacketText, replyId).apply {
from = ourNodeInfo.value?.user?.id ?: DataPacket.ID_LOCAL
}
sendDataPacket(p)
viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) }
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
@ -253,30 +212,4 @@ constructor(
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
private fun sendSharedContact(node: Node) = viewModelScope.launch {
try {
val contact =
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
} catch (ex: RemoteException) {
Logger.e(ex) { "Send shared contact error" }
}
}
private fun sendDataPacket(p: DataPacket) {
try {
serviceRepository.meshService?.send(p)
} catch (ex: RemoteException) {
Logger.e { "Send DataPacket error: ${ex.message}" }
}
}
}

View file

@ -65,18 +65,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.message
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.quick_chat
import org.meshtastic.core.strings.quick_chat_append
import org.meshtastic.core.strings.quick_chat_edit
import org.meshtastic.core.strings.quick_chat_instant
import org.meshtastic.core.strings.quick_chat_new
import org.meshtastic.core.strings.save
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.message
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.quick_chat
import org.meshtastic.core.resources.quick_chat_append
import org.meshtastic.core.resources.quick_chat_edit
import org.meshtastic.core.resources.quick_chat_instant
import org.meshtastic.core.resources.quick_chat_new
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.dragContainer

View file

@ -42,10 +42,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.react
import org.meshtastic.core.strings.reply
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.react
import org.meshtastic.core.resources.reply
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
@Composable

View file

@ -47,12 +47,12 @@ import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.select
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.select
@Composable
fun MessageActionsContent(

View file

@ -66,11 +66,11 @@ import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.filter_message_label
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.filter_message_label
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.ui.component.AutoLinkText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi

View file

@ -64,14 +64,14 @@ import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
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.react
import org.meshtastic.core.strings.you
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.react
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BottomSheetDialog
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr

View file

@ -0,0 +1,103 @@
/*
* 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 org.meshtastic.feature.messaging.domain.usecase
import co.touchlab.kermit.Logger
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("TooGenericExceptionCaught")
class SendMessageUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
suspend operator fun invoke(
text: String,
contactKey: String = "0${DataPacket.ID_BROADCAST}",
replyId: Int? = null,
) {
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val ourNode = nodeRepository.ourNodeInfo.value
val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
// logic for direct messages
if (channel == null) {
val destNode = nodeRepository.getNode(dest)
val fwVersion = ourNode?.metadata?.firmware_version
val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
if (capabilities.canSendVerifiedContacts) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
}
// Apply homoglyph encoding
val finalMessageText =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
} else {
text
}
val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
try {
serviceRepository.meshService?.send(packet)
} catch (ex: Exception) {
Logger.e(ex) { "Failed to send data packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
}
private suspend fun sendSharedContact(node: Node) {
try {
val contact =
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}
}
}

View file

@ -0,0 +1,146 @@
/*
* 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 org.meshtastic.feature.messaging.domain.usecase
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var serviceRepository: ServiceRepository
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var useCase: SendMessageUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
serviceRepository = mockk(relaxed = true)
homoglyphEncodingPrefs = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
)
mockkConstructor(Capabilities::class)
}
@Test
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { ourNode.user.id } returns "!1234"
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with direct message to older firmware triggers favoriteNode`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.0.0" // Older firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { destNode.isFavorite } returns false
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns false
// Act
useCase("Direct message", "!dest", null)
// Assert
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with direct message to new firmware triggers sendSharedContact`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.7.12" // Newer firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns true
// Act
useCase("Direct message", "!dest", null)
// Assert
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
}
@Test
fun `invoke with homoglyph enabled transforms text`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
// Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
val originalText = "\u0410pple"
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
// We verify that send was called with the transformed text (Latin 'A'pple)
coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
}
}

View file

@ -31,7 +31,7 @@ graph TB
:feature:node -.-> :core:model
:feature:node -.-> :core:proto
:feature:node -.-> :core:service
:feature:node -.-> :core:strings
:feature:node -.-> :core:resources
:feature:node -.-> :core:ui
:feature:node -.-> :core:navigation
:feature:node -.-> :feature:map

View file

@ -40,7 +40,7 @@ dependencies {
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.strings)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.feature.map)

View file

@ -56,15 +56,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.actions
import org.meshtastic.core.resources.direct_message
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.share_contact
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.NodeDetailAction

View file

@ -32,16 +32,16 @@ import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.firmware
import org.meshtastic.core.resources.firmware_edition
import org.meshtastic.core.resources.installed_firmware_version
import org.meshtastic.core.resources.latest_alpha_firmware
import org.meshtastic.core.resources.latest_stable_firmware
import org.meshtastic.core.resources.remote_admin
import org.meshtastic.core.resources.request_metadata
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.administration
import org.meshtastic.core.strings.firmware
import org.meshtastic.core.strings.firmware_edition
import org.meshtastic.core.strings.installed_firmware_version
import org.meshtastic.core.strings.latest_alpha_firmware
import org.meshtastic.core.strings.latest_stable_firmware
import org.meshtastic.core.strings.remote_admin
import org.meshtastic.core.strings.request_metadata
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange

View file

@ -56,20 +56,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.compass_bearing
import org.meshtastic.core.strings.compass_bearing_na
import org.meshtastic.core.strings.compass_distance
import org.meshtastic.core.strings.compass_location_disabled
import org.meshtastic.core.strings.compass_no_location_fix
import org.meshtastic.core.strings.compass_no_location_permission
import org.meshtastic.core.strings.compass_no_magnetometer
import org.meshtastic.core.strings.compass_title
import org.meshtastic.core.strings.compass_uncertainty
import org.meshtastic.core.strings.compass_uncertainty_unknown
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.exchange_position
import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.compass_bearing
import org.meshtastic.core.resources.compass_bearing_na
import org.meshtastic.core.resources.compass_distance
import org.meshtastic.core.resources.compass_location_disabled
import org.meshtastic.core.resources.compass_no_location_fix
import org.meshtastic.core.resources.compass_no_location_permission
import org.meshtastic.core.resources.compass_no_magnetometer
import org.meshtastic.core.resources.compass_title
import org.meshtastic.core.resources.compass_uncertainty
import org.meshtastic.core.resources.compass_uncertainty_unknown
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning

View file

@ -47,14 +47,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.actions
import org.meshtastic.core.resources.direct_message
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.share_contact
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.LogsType

View file

@ -36,21 +36,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.hardware
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.supported_by_community
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.ic_unverified
import org.meshtastic.core.resources.img_hw_unknown
import org.meshtastic.core.resources.supported
import org.meshtastic.core.resources.supported_by_community
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@ -119,7 +118,7 @@ private fun SupportStatusItem(isSupported: Boolean) {
if (isSupported) {
Icons.TwoTone.Verified
} else {
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_unverified)
},
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
trailingIcon = null,
@ -134,9 +133,12 @@ private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifi
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
error = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
fallback = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
placeholder =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
error =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
fallback =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
modifier = modifier.padding(16.dp),
)
}

View file

@ -24,8 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.distance
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.distance
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -24,9 +24,9 @@ import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.altitude
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.altitude
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.Config

View file

@ -40,23 +40,27 @@ import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.dew_point
import org.meshtastic.core.strings.distance
import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.pressure
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.strings.weight
import org.meshtastic.core.strings.wind
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.dew_point
import org.meshtastic.core.resources.distance
import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.ic_dew_point
import org.meshtastic.core.resources.ic_radioactive
import org.meshtastic.core.resources.ic_soil_moisture
import org.meshtastic.core.resources.ic_soil_temperature
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.pressure
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.resources.weight
import org.meshtastic.core.resources.wind
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
import org.meshtastic.proto.Config
@ -136,7 +140,7 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.dew_point,
dewPoint.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.ic_outlined_dew_point_24,
Res.drawable.ic_dew_point,
),
)
}
@ -147,7 +151,7 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.soil_temperature,
st.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.soil_temperature,
Res.drawable.ic_soil_temperature,
),
)
}
@ -157,16 +161,16 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.soil_moisture,
"%d%%".format(sm),
org.meshtastic.feature.node.R.drawable.soil_moisture,
Res.drawable.ic_soil_moisture,
),
)
}
radiation?.let { r ->
add(
DrawableMetricInfo(
Res.string.radiation,
"%.1f µR/h".format(r),
org.meshtastic.feature.node.R.drawable.ic_filled_radioactive_24,
label = Res.string.radiation,
value = "%.1f µR/h".format(r),
icon = Res.drawable.ic_radioactive,
),
)
}

View file

@ -45,10 +45,10 @@ import com.mikepenz.markdown.m3.Markdown
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.download
import org.meshtastic.core.strings.error_no_app_to_handle_link
import org.meshtastic.core.strings.view_release
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.download
import org.meshtastic.core.resources.error_no_app_to_handle_link
import org.meshtastic.core.resources.view_release
import org.meshtastic.core.ui.util.showToast
@Composable

View file

@ -24,8 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.hops_away
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.hops_away
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -17,7 +17,6 @@
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -42,15 +41,16 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.thenIf
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@ -58,9 +58,9 @@ import org.meshtastic.core.ui.util.thenIf
fun InfoCard(
text: String,
value: String,
icon: ImageVector? = null,
@DrawableRes iconRes: Int? = null,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
iconRes: DrawableResource? = null,
rotateIcon: Float = 0f,
) {
val clipboard: Clipboard = LocalClipboard.current
@ -120,6 +120,6 @@ fun InfoCard(
}
@Composable
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
internal fun DrawableInfoCard(iconRes: DrawableResource, text: String, value: String, rotateIcon: Float = 0f) {
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
}

View file

@ -20,14 +20,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.ui.R
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_antenna
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
@ -40,7 +40,7 @@ fun LastHeardInfo(
) {
IconInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
icon = vectorResource(Res.drawable.ic_antenna),
contentDescription = stringResource(Res.string.node_sort_last_heard),
label = if (showLabel) stringResource(Res.string.node_sort_last_heard) else null,
text = formatAgo(lastHeard),

View file

@ -44,10 +44,10 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme

View file

@ -53,8 +53,8 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
@Composable
internal fun SectionCard(

View file

@ -58,26 +58,26 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.encryption_error
import org.meshtastic.core.strings.encryption_error_text
import org.meshtastic.core.strings.error
import org.meshtastic.core.strings.hops_away
import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.node_number
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.status_message
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.encryption_error
import org.meshtastic.core.resources.encryption_error_text
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.hops_away
import org.meshtastic.core.resources.node_id
import org.meshtastic.core.resources.node_number
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.resources.public_key
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.short_name
import org.meshtastic.core.resources.snr
import org.meshtastic.core.resources.status_message
import org.meshtastic.core.resources.supported
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.user_id
import org.meshtastic.core.resources.via_mqtt
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.ArrowCircleUp
import org.meshtastic.core.ui.icon.ChannelUtilization

View file

@ -61,18 +61,18 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.desc_node_filter_clear
import org.meshtastic.core.strings.node_filter_exclude_infrastructure
import org.meshtastic.core.strings.node_filter_ignored
import org.meshtastic.core.strings.node_filter_include_unknown
import org.meshtastic.core.strings.node_filter_only_direct
import org.meshtastic.core.strings.node_filter_only_online
import org.meshtastic.core.strings.node_filter_placeholder
import org.meshtastic.core.strings.node_filter_show_ignored
import org.meshtastic.core.strings.node_filter_title
import org.meshtastic.core.strings.node_sort_button
import org.meshtastic.core.strings.node_sort_title
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.desc_node_filter_clear
import org.meshtastic.core.resources.node_filter_exclude_infrastructure
import org.meshtastic.core.resources.node_filter_ignored
import org.meshtastic.core.resources.node_filter_include_unknown
import org.meshtastic.core.resources.node_filter_only_direct
import org.meshtastic.core.resources.node_filter_only_online
import org.meshtastic.core.resources.node_filter_placeholder
import org.meshtastic.core.resources.node_filter_show_ignored
import org.meshtastic.core.resources.node_filter_title
import org.meshtastic.core.resources.node_sort_button
import org.meshtastic.core.resources.node_sort_title
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("LongParameterList")

View file

@ -55,15 +55,15 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_utilization
import org.meshtastic.core.strings.channel_utilization
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.AirQualityInfo
import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo

View file

@ -37,16 +37,16 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync

View file

@ -44,10 +44,10 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_a_note
import org.meshtastic.core.strings.notes
import org.meshtastic.core.strings.save
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_a_note
import org.meshtastic.core.resources.notes
import org.meshtastic.core.resources.save
@Composable
fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) {

View file

@ -48,10 +48,10 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.exchange_position
import org.meshtastic.core.strings.open_compass
import org.meshtastic.core.strings.position
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction

View file

@ -27,10 +27,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
import org.meshtastic.core.resources.channel_3
import org.meshtastic.feature.node.model.VectorMetricInfo
/**

View file

@ -24,8 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.sats
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sats
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -49,12 +49,12 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_telemetry
import org.meshtastic.core.strings.telemetry
import org.meshtastic.core.strings.userinfo
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_telemetry
import org.meshtastic.core.resources.telemetry
import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.LineAxis
import org.meshtastic.core.ui.icon.MeshtasticIcons

View file

@ -33,17 +33,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.node_id
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
@Composable
fun TemperatureInfo(

View file

@ -61,10 +61,9 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.loading
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
@ -100,20 +99,19 @@ fun NodeDetailScreen(
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
viewModel.start(nodeId)
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
@ -149,8 +147,8 @@ private fun NodeDetailScaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = getString(Res.string.details),
subtitle = node?.user?.long_name ?: "",
title = stringResource(Res.string.details),
subtitle = uiState.nodeName.asString(),
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,

View file

@ -22,7 +22,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@ -31,44 +30,26 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.getString
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
/**
* UI state for the Node Details screen.
*
* @property node The node being viewed, or null if loading.
* @property nodeName The display name for the node, resolved in the UI.
* @property ourNode Information about the locally connected node.
* @property metricsState Aggregated sensor and signal metrics.
* @property environmentState Standardized environmental sensor data.
@ -76,8 +57,10 @@ import javax.inject.Inject
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
*/
@androidx.compose.runtime.Stable
data class NodeDetailUiState(
val node: Node? = null,
val nodeName: UiText = UiText.DynamicString(""),
val ourNode: Node? = null,
val metricsState: MetricsState = MetricsState.Empty,
val environmentState: EnvironmentMetricsState = EnvironmentMetricsState(),
@ -93,17 +76,12 @@ data class NodeDetailUiState(
@HiltViewModel
class NodeDetailViewModel
@Inject
@Suppress("LongParameterList")
constructor(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val serviceRepository: ServiceRepository,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private val nodeIdFromRoute: Int? =
@ -120,166 +98,10 @@ constructor(
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
buildUiStateFlow(nodeId)
getNodeDetailsUseCase(nodeId)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildUiStateFlow(nodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum
.map { it[nodeId] ?: Node.createFallback(nodeId, getString(Res.string.fallback_node_name)) }
.distinctUntilChanged()
// 1. Logs & Metrics Data (fetches telemetry, packets, paxcount, and response history)
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value),
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config (local device info and radio profile)
val identityFlow =
combine(nodeRepository.ourNodeInfo, nodeRepository.myNodeInfo, radioConfigRepository.deviceProfileFlow) {
ourNode,
myInfo,
profile,
->
IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile)
}
// 3. Metadata & Request Timestamps (firmware versions and last request times)
val metadataFlow =
combine(
meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes.map { it[nodeId] },
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { args: Array<Any?> ->
MetadataGroup(
edition = args[0] as? FirmwareEdition,
stable = args[1] as? FirmwareRelease,
alpha = args[2] as? FirmwareRelease,
trTime = args[3] as? Long,
niTime = args[4] as? Long,
)
}
// 4. Requests History (tracking traceroute and neighbor info requests sent from this device)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP),
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP),
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
NodeDetailUiState(
node = node,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {

View file

@ -24,21 +24,21 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.favorite_add
import org.meshtastic.core.resources.favorite_remove
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.ignore_add
import org.meshtastic.core.resources.ignore_remove
import org.meshtastic.core.resources.mute_add
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_remove
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_node_text
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
import org.meshtastic.core.strings.mute_add
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -27,29 +27,29 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.position
import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_device_metrics
import org.meshtastic.core.strings.request_environment_metrics
import org.meshtastic.core.strings.request_host_metrics
import org.meshtastic.core.strings.request_pax_metrics
import org.meshtastic.core.strings.request_power_metrics
import org.meshtastic.core.strings.requesting_from
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.user_info
import javax.inject.Inject
import javax.inject.Singleton
sealed class NodeRequestEffect {
data class ShowFeedback(val resource: StringResource, val args: List<Any> = emptyList()) : NodeRequestEffect()
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
@Singleton
@ -70,7 +70,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
serviceRepository.meshService?.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.user_info, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request NodeInfo error: ${ex.message}" }
@ -87,8 +89,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
Res.string.requesting_from,
listOf(Res.string.neighbor_info, longName),
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
} catch (ex: android.os.RemoteException) {
@ -108,7 +109,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
serviceRepository.meshService?.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.position, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request position error: ${ex.message}" }
@ -134,7 +137,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(typeRes, longName)))
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request telemetry error: ${ex.message}" }
}
@ -149,7 +154,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
_lastTracerouteTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.traceroute, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request traceroute error: ${ex.message}" }

View file

@ -0,0 +1,60 @@
/*
* 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 org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.Config
import javax.inject.Inject
class GetFilteredNodesUseCase @Inject constructor(private val nodeRepository: NodeRepository) {
@Suppress("CyclomaticComplexMethod", "LongMethod")
operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> = nodeRepository
.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list ->
list
.filter { node -> node.isIgnored == filter.showIgnored }
.filter { node ->
if (filter.excludeInfrastructure) {
val role = node.user.role
@Suppress("DEPRECATION")
val infrastructureRoles =
listOf(
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
true
}
}
}
}

View file

@ -0,0 +1,237 @@
/*
* 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 org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
class GetNodeDetailsUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes.map { it[nodeId] },
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name?.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
}

View file

@ -68,18 +68,18 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_favorite
import org.meshtastic.core.strings.remove_ignored
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent

View file

@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
@ -39,12 +38,13 @@ import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
class NodeListViewModel
@Inject
@ -54,6 +54,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeManagementActions: NodeManagementActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -116,35 +117,7 @@ constructor(
val nodeList: StateFlow<List<Node>> =
combine(nodeFilter, nodeSortOption, ::Pair)
.flatMapLatest { (filter, sort) ->
nodeRepository
.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list ->
list
.filter { node -> node.isIgnored == filter.showIgnored }
.filter { node ->
if (filter.excludeInfrastructure) {
val role = node.user.role
val infrastructureRoles =
listOf(
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
true
}
}
}
}
.flatMapLatest { (filter, sort) -> getFilteredNodesUseCase.invoke(filter, sort) }
.stateInWhileSubscribed(initialValue = emptyList())
val unfilteredNodeList: StateFlow<List<Node>> =

View file

@ -59,9 +59,9 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.logs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.logs
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh

View file

@ -63,12 +63,12 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.snr
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import java.text.DateFormat

View file

@ -66,16 +66,15 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition
import org.meshtastic.core.strings.air_utilization
import org.meshtastic.core.strings.battery
import org.meshtastic.core.strings.ch_util_definition
import org.meshtastic.core.strings.channel_utilization
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_util_definition
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.battery
import org.meshtastic.core.resources.ch_util_definition
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.device_metrics_log
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan
@ -141,7 +140,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -33,15 +33,15 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.baro_pressure
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.proto.Telemetry
@Suppress("MagicNumber")

View file

@ -51,21 +51,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.iaq_definition
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.iaq_definition
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -87,7 +86,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -59,13 +59,12 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disk_free_indexed
import org.meshtastic.core.strings.free_memory
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.load_indexed
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_string
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disk_free_indexed
import org.meshtastic.core.resources.free_memory
import org.meshtastic.core.resources.load_indexed
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.user_string
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.DataArray
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -88,7 +87,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -27,18 +27,17 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -46,11 +45,8 @@ import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
@ -58,26 +54,21 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.io.BufferedWriter
@ -89,8 +80,6 @@ import java.util.Locale
import javax.inject.Inject
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private fun MeshPacket.hasValidSignal(): Boolean = rx_time > 0 && (rx_snr != 0f || rx_rssi != 0)
/**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/
@ -103,27 +92,111 @@ constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private var destNum: Int? =
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
private var jobs: Job? = null
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
val state: StateFlow<MetricsState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty)
getNodeDetailsUseCase(nodeId).map { it.metricsState }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty)
private val environmentState: StateFlow<EnvironmentMetricsState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState())
getNodeDetailsUseCase(nodeId).map { it.environmentState }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
/** The active time window for filtering graphed data. */
val timeFrame: StateFlow<TimeFrame> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(state, environmentState) { currentState, envState ->
val stateOldest = currentState.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Exposes filtered and unit-converted environment metrics for the UI. */
val filteredEnvironmentMetrics: StateFlow<List<Telemetry>> =
combine(environmentState, _timeFrame, state) { envState, timeFrame, currentState ->
val threshold = timeFrame.timeThreshold()
val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
if (currentState.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
telemetry.copy(
environment_metrics =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
} else {
data
}
}
.stateInWhileSubscribed(emptyList())
/** Exposes graphing data specifically for the filtered environment metrics. */
val environmentGraphingData: StateFlow<EnvironmentGraphingData> =
filteredEnvironmentMetrics
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
/** Exposes filtered and decoded pax metrics for the UI. */
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
combine(state, _timeFrame) { currentState, timeFrame ->
val threshold = timeFrame.timeThreshold()
currentState.paxMetrics
.filter { (it.received_date / 1000) >= threshold }
.mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } }
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
combine(nodeRequestActions.lastTracerouteTimes, activeNodeId) { map, id -> id?.let { map[it] } }
.stateInWhileSubscribed(null)
val lastRequestNeighborsTime: StateFlow<Long?> =
combine(nodeRequestActions.lastRequestNeighborTimes, activeNodeId) { map, id -> id?.let { map[it] } }
.stateInWhileSubscribed(null)
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
/** Returns the map overlay for a specific traceroute request ID. */
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
val cached = tracerouteOverlayCache.value[requestId]
if (cached != null) return cached
@ -170,103 +243,35 @@ constructor(
}
}
}
Logger.d { "MetricsViewModel created" }
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) }
(manualNodeId.value ?: nodeIdFromRoute)?.let {
meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value)
}
}
private val _state = MutableStateFlow(MetricsState.Empty)
/** Current aggregated metrics state, including signal history and sensor logs. */
val state: StateFlow<MetricsState> = _state
private val environmentState = MutableStateFlow(EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
/** The active time window for filtering graphed data. */
val timeFrame: StateFlow<TimeFrame> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(_state, environmentState) { state, envState ->
val stateOldest = state.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Exposes filtered and unit-converted environment metrics for the UI. */
val filteredEnvironmentMetrics: StateFlow<List<Telemetry>> =
combine(environmentState, _timeFrame, _state) { envState, timeFrame, state ->
val threshold = timeFrame.timeThreshold()
val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
if (state.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
telemetry.copy(
environment_metrics =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
} else {
data
}
}
.stateInWhileSubscribed(emptyList())
/** Exposes graphing data specifically for the filtered environment metrics. */
val environmentGraphingData: StateFlow<EnvironmentGraphingData> =
filteredEnvironmentMetrics
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
/** Exposes filtered and decoded pax metrics for the UI. */
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
combine(_state, _timeFrame) { state, timeFrame ->
val threshold = timeFrame.timeThreshold()
state.paxMetrics
.filter { (it.received_date / 1000) >= threshold }
.mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } }
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
nodeRequestActions.lastTracerouteTimes.map { it[destNum] }.stateInWhileSubscribed(null)
val lastRequestNeighborsTime: StateFlow<Long?> =
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
fun requestPosition() {
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
fun requestTelemetry(type: TelemetryType) {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.long_name ?: "", type)
}
}
fun requestTraceroute() {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
fun requestNeighborInfo() {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
@ -278,7 +283,6 @@ constructor(
)
}
/** Shows the detail dialog for a traceroute result, with an option to view on the map. */
fun showTracerouteDetail(
annotatedMessage: AnnotatedString,
requestId: Int,
@ -318,188 +322,17 @@ constructor(
}
}
init {
initializeFlows()
}
fun setNodeId(id: Int) {
if (destNum != id) {
destNum = id
initializeFlows()
if (manualNodeId.value != id) {
manualNodeId.value = id
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun initializeFlows() {
jobs?.cancel()
val currentDestNum = destNum
jobs =
viewModelScope.launch {
if (currentDestNum != null) {
val logNodeIdFlow = nodeRepository.effectiveLogNodeId(currentDestNum)
launch {
combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { nodes, myInfo ->
nodes[currentDestNum] to (nodes.keys.firstOrNull() to myInfo)
}
.distinctUntilChanged()
.collect { (node, localData) ->
val (ourNodeNum, myInfo) = localData
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode =
node
?: Node.createFallback(currentDestNum, getString(Res.string.fallback_node_name))
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
val hwModel = actualNode.user.hw_model.value
val deviceHardware =
deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv)
_state.update { state ->
state.copy(
node = actualNode,
isLocal = currentDestNum == ourNodeNum,
deviceHardware = deviceHardware.getOrNull(),
reportedTarget = pioEnv,
)
}
}
}
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.module_config
val displayUnits = profile.config?.display?.units
_state.update { state ->
state.copy(
isManaged = profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC,
)
}
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getTelemetryFrom(it) }
.collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
for (item in telemetry) {
if (item.device_metrics != null) device.add(item)
if (item.power_metrics != null) power.add(item)
if (item.host_metrics != null) host.add(item)
if (item.hasValidEnvironmentMetrics()) env.add(item)
}
_state.update { state ->
state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host)
}
environmentState.update { it.copy(environmentMetrics = env) }
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it) }
.collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}
}
launch {
combine(
meshLogRepository.getRequestLogs(currentDestNum, PortNum.TRACEROUTE_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(tracerouteRequests = request, tracerouteResults = response)
}
}
.collect {}
}
launch {
combine(
meshLogRepository.getRequestLogs(currentDestNum, PortNum.NEIGHBORINFO_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.NEIGHBORINFO_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(neighborInfoRequests = request, neighborInfoResults = response)
}
}
.collect {}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value) }
.collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.PAXCOUNTER_APP.value) }
.collect { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
}
launch {
firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
_state.update { state -> state.copy(latestStableFirmware = latestStable) }
}
}
launch {
firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
_state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
}
}
launch {
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.collect { firmwareEdition ->
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
}
}
Logger.d { "MetricsViewModel created" }
} else {
Logger.d { "MetricsViewModel: destNum is null, skipping metrics flows initialization." }
}
}
}
override fun onCleared() {
super.onCleared()
Logger.d { "MetricsViewModel cleared" }
}
/** Write the persisted Position data out to a CSV file in the specified location. */
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
@ -518,7 +351,6 @@ constructor(
val speed = position.ground_speed
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
// date,time,latitude,longitude,altitude,satsInView,speed,heading
writer.appendLine(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
)
@ -541,12 +373,10 @@ constructor(
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
// Requests for paxcount (want_response = true) should not be logged as data points.
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax
@ -554,10 +384,9 @@ constructor(
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
}
// Fallback: Attempt to parse Paxcount from raw_message as base64 or hex string.
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {

View file

@ -45,10 +45,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -77,7 +76,7 @@ fun NeighborInfoLogScreen(
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
@ -137,6 +136,7 @@ fun NeighborInfoLogScreen(
)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)
var expanded by remember { mutableStateOf(false) }
Box {
@ -149,10 +149,7 @@ fun NeighborInfoLogScreen(
result
?.fromRadio
?.packet
?.getNeighborInfoResponse(
::getUsername,
header = getString(Res.string.neighbor_info),
)
?.getNeighborInfoResponse(::getUsername, header = header)
?.let {
val message =
annotateNeighborInfo(

View file

@ -59,14 +59,13 @@ import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.no_pax_metrics_logs
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.wifi_devices
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ble_devices
import org.meshtastic.core.resources.no_pax_metrics_logs
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
@ -189,7 +188,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -65,17 +65,16 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alt
import org.meshtastic.core.strings.clear
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.heading
import org.meshtastic.core.strings.latitude
import org.meshtastic.core.strings.longitude
import org.meshtastic.core.strings.sats
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.speed
import org.meshtastic.core.strings.timestamp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -182,7 +181,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -64,14 +64,13 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
import org.meshtastic.core.resources.channel_3
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -121,7 +120,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -58,13 +58,12 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.rssi_definition
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.snr_definition
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.rssi_definition
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
@ -98,7 +97,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -52,18 +52,17 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
import org.meshtastic.core.strings.traceroute_direct
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_hops
import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.traceroute_time_and_text
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.traceroute_diff
import org.meshtastic.core.resources.traceroute_direct
import org.meshtastic.core.resources.traceroute_duration
import org.meshtastic.core.resources.traceroute_hops
import org.meshtastic.core.resources.traceroute_log
import org.meshtastic.core.resources.traceroute_route_back_to_us
import org.meshtastic.core.resources.traceroute_route_towards_dest
import org.meshtastic.core.resources.traceroute_time_and_text
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Group
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -98,7 +97,7 @@ fun TracerouteLogScreen(
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
@ -142,6 +141,8 @@ fun TracerouteLogScreen(
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest)
val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us)
val result =
remember(state.tracerouteRequests, log.fromRadio.packet?.id) {
state.tracerouteResults.find {
@ -169,7 +170,7 @@ fun TracerouteLogScreen(
res.fromRadio.packet?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
headerBack = headerBackStr,
),
statusGreen = statusGreen,
statusYellow = statusYellow,
@ -186,7 +187,7 @@ fun TracerouteLogScreen(
?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
headerBack = headerBackStr,
)
?.let { AnnotatedString(it) }
}
@ -214,8 +215,8 @@ fun TracerouteLogScreen(
?.packet
?.getTracerouteResponse(
::getUsername,
headerTowards = getString(Res.string.traceroute_route_towards_dest),
headerBack = getString(Res.string.traceroute_route_back_to_us),
headerTowards = headerTowardsStr,
headerBack = headerBackStr,
)
?.let {
annotateTraceroute(

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