mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: KMP Migration, Messaging Modularization, and Handshake Robustness (#4631)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b3f88bd94f
commit
d408964f07
144 changed files with 1460 additions and 664 deletions
|
|
@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
|
|
@ -58,6 +59,7 @@ open class MeshUtilApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextServices.app = this
|
||||
initializeMaps(this)
|
||||
|
||||
// Schedule periodic MeshLog cleanup
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.model
|
||||
|
||||
data class Contact(
|
||||
val contactKey: String,
|
||||
val shortName: String,
|
||||
val longName: String,
|
||||
val lastMessageTime: String?,
|
||||
val lastMessageText: String?,
|
||||
val unreadCount: Int,
|
||||
val messageCount: Int,
|
||||
val isMuted: Boolean,
|
||||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
)
|
||||
|
|
@ -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,22 +14,25 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.navigation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.navigation.navigation
|
||||
import androidx.navigation.toRoute
|
||||
import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
|
||||
import com.geeksville.mesh.ui.sharing.ShareScreen
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.feature.messaging.QuickChatScreen
|
||||
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
|
||||
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
|
||||
|
|
@ -37,7 +40,19 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
composable<ContactsRoutes.Contacts>(
|
||||
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
|
||||
) {
|
||||
AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
|
||||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onHandleScannedUri = uiViewModel::handleScannedUri,
|
||||
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
||||
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
||||
)
|
||||
}
|
||||
composable<ContactsRoutes.Messages>(
|
||||
deepLinks =
|
||||
|
|
@ -49,9 +64,18 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
|||
),
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
|
||||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onHandleScannedUri = uiViewModel::handleScannedUri,
|
||||
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
||||
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
||||
initialContactKey = args.contactKey,
|
||||
initialMessage = args.message,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ constructor(
|
|||
get() = newNodes.size
|
||||
|
||||
private var rawMyNodeInfo: MyNodeInfo? = null
|
||||
private var lastMetadata: DeviceMetadata? = null
|
||||
private var newMyNodeInfo: MyNodeEntity? = null
|
||||
private var myNodeInfo: MyNodeEntity? = null
|
||||
|
||||
|
|
@ -79,12 +80,20 @@ constructor(
|
|||
}
|
||||
|
||||
private fun handleConfigOnlyComplete() {
|
||||
Logger.i { "Config-only complete" }
|
||||
Logger.i { "Config-only complete (Stage 1)" }
|
||||
if (newMyNodeInfo == null) {
|
||||
Logger.e { "Did not receive a valid config - newMyNodeInfo is null" }
|
||||
Logger.w {
|
||||
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
|
||||
}
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
}
|
||||
|
||||
val finalizedInfo = newMyNodeInfo
|
||||
if (finalizedInfo == null) {
|
||||
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
|
||||
} else {
|
||||
myNodeInfo = newMyNodeInfo
|
||||
Logger.i { "myNodeInfo committed successfully" }
|
||||
myNodeInfo = finalizedInfo
|
||||
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||
connectionManager.onRadioConfigLoaded()
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +101,7 @@ constructor(
|
|||
delay(wantConfigDelay)
|
||||
sendHeartbeat()
|
||||
delay(wantConfigDelay)
|
||||
Logger.i { "Requesting NodeInfo (Stage 2)" }
|
||||
connectionManager.startNodeInfoOnly()
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +116,7 @@ constructor(
|
|||
}
|
||||
|
||||
private fun handleNodeInfoComplete() {
|
||||
Logger.i { "NodeInfo complete" }
|
||||
Logger.i { "NodeInfo complete (Stage 2)" }
|
||||
val entities =
|
||||
newNodes.map { info ->
|
||||
nodeManager.installNodeInfo(info, withBroadcast = false)
|
||||
|
|
@ -134,8 +144,8 @@ constructor(
|
|||
fun handleMyInfo(myInfo: MyNodeInfo) {
|
||||
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
|
||||
rawMyNodeInfo = myInfo
|
||||
nodeManager.myNodeNum = myInfo.my_node_num ?: 0
|
||||
regenMyNodeInfo()
|
||||
nodeManager.myNodeNum = myInfo.my_node_num
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
|
||||
scope.handledLaunch {
|
||||
radioConfigRepository.clearChannelSet()
|
||||
|
|
@ -145,7 +155,8 @@ constructor(
|
|||
}
|
||||
|
||||
fun handleLocalMetadata(metadata: DeviceMetadata) {
|
||||
Logger.i { "Local Metadata received" }
|
||||
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
|
||||
lastMetadata = metadata
|
||||
regenMyNodeInfo(metadata)
|
||||
}
|
||||
|
||||
|
|
@ -153,36 +164,43 @@ constructor(
|
|||
newNodes.add(info)
|
||||
}
|
||||
|
||||
private fun regenMyNodeInfo(metadata: DeviceMetadata? = DeviceMetadata()) {
|
||||
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
|
||||
val myInfo = rawMyNodeInfo
|
||||
if (myInfo != null) {
|
||||
val mi =
|
||||
with(myInfo) {
|
||||
MyNodeEntity(
|
||||
myNodeNum = my_node_num ?: 0,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version,
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version ?: 0,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
deviceId = device_id?.utf8() ?: "",
|
||||
pioEnv = if (myInfo.pio_env.isNullOrEmpty()) null else myInfo.pio_env,
|
||||
)
|
||||
try {
|
||||
val mi =
|
||||
with(myInfo) {
|
||||
MyNodeEntity(
|
||||
myNodeNum = my_node_num ?: 0,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
deviceId = device_id.utf8(),
|
||||
pioEnv = myInfo.pio_env.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
if (metadata != null && metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
|
||||
}
|
||||
if (metadata != null && metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
|
||||
newMyNodeInfo = mi
|
||||
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Failed to regenMyNodeInfo" }
|
||||
}
|
||||
newMyNodeInfo = mi
|
||||
} else {
|
||||
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,15 @@ import org.meshtastic.core.common.util.nowMillis
|
|||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected_count
|
||||
import org.meshtastic.core.resources.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.getString
|
||||
import org.meshtastic.core.resources.meshtastic_app_name
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
|
|
@ -77,12 +79,16 @@ constructor(
|
|||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var sleepTimeout: Job? = null
|
||||
private var locationRequestsJob: Job? = null
|
||||
private var handshakeTimeout: Job? = null
|
||||
private var connectTimeMsec = 0L
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
|
||||
|
||||
// Ensure notification title and content stay in sync with state changes
|
||||
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
|
||||
|
||||
nodeRepository.myNodeInfo
|
||||
.onEach { myNodeEntity ->
|
||||
locationRequestsJob?.cancel()
|
||||
|
|
@ -122,11 +128,21 @@ constructor(
|
|||
}
|
||||
|
||||
private fun onConnectionChanged(c: ConnectionState) {
|
||||
if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
|
||||
Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" }
|
||||
val current = connectionStateHolder.connectionState.value
|
||||
if (current == c) return
|
||||
|
||||
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
|
||||
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
|
||||
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i { "onConnectionChanged: $current -> $c" }
|
||||
|
||||
sleepTimeout?.cancel()
|
||||
sleepTimeout = null
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
when (c) {
|
||||
is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
|
||||
|
|
@ -134,19 +150,33 @@ constructor(
|
|||
is ConnectionState.DeviceSleep -> handleDeviceSleep()
|
||||
is ConnectionState.Disconnected -> handleDisconnected()
|
||||
}
|
||||
updateStatusNotification()
|
||||
}
|
||||
|
||||
private fun handleConnected() {
|
||||
// The service state remains 'Connecting' until config is fully loaded
|
||||
if (connectionStateHolder.connectionState.value == ConnectionState.Disconnected) {
|
||||
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
|
||||
connectionStateHolder.setState(ConnectionState.Connecting)
|
||||
}
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
Logger.d { "Starting connect" }
|
||||
Logger.i { "Starting mesh handshake (Stage 1)" }
|
||||
connectTimeMsec = nowMillis
|
||||
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
|
||||
startConfigOnly()
|
||||
|
||||
// Guard against handshake stalls
|
||||
handshakeTimeout =
|
||||
scope.handledLaunch {
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.w { "Handshake stall detected! Retrying Stage 1." }
|
||||
startConfigOnly()
|
||||
// Recursive timeout for one more try
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.e { "Handshake still stalled after retry. Resetting connection." }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeviceSleep() {
|
||||
|
|
@ -215,6 +245,9 @@ constructor(
|
|||
}
|
||||
|
||||
fun onNodeDbReady() {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
// Start MQTT if enabled
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
|
|
@ -236,7 +269,9 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
updateStatusNotification()
|
||||
// Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
}
|
||||
|
||||
private fun reportConnection() {
|
||||
|
|
@ -258,8 +293,7 @@ constructor(
|
|||
val summary =
|
||||
when (connectionStateHolder.connectionState.value) {
|
||||
is ConnectionState.Connected ->
|
||||
getString(Res.string.connected_count)
|
||||
.format(nodeManager.nodeDBbyNodeNum.values.count { it.isOnline })
|
||||
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
|
||||
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
|
||||
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
|
||||
is ConnectionState.Connecting -> getString(Res.string.connecting)
|
||||
|
|
@ -271,6 +305,7 @@ constructor(
|
|||
private const val CONFIG_ONLY_NONCE = 69420
|
||||
private const val NODE_INFO_NONCE = 69421
|
||||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||
private val HANDSHAKE_TIMEOUT = 10.seconds
|
||||
|
||||
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
|
||||
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
|
||||
|
|
|
|||
|
|
@ -16,40 +16,17 @@
|
|||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.meshtastic.core.model.util.MeshDataMapper as CommonMeshDataMapper
|
||||
|
||||
@Singleton
|
||||
class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) {
|
||||
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
|
||||
DataPacket.ID_BROADCAST
|
||||
} else {
|
||||
nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
|
||||
}
|
||||
private val commonMapper = CommonMeshDataMapper(nodeManager)
|
||||
|
||||
fun toDataPacket(packet: MeshPacket): DataPacket? {
|
||||
val decoded = packet.decoded ?: return null
|
||||
return DataPacket(
|
||||
from = toNodeID(packet.from),
|
||||
to = toNodeID(packet.to),
|
||||
time = packet.rx_time * 1000L,
|
||||
id = packet.id,
|
||||
dataType = decoded.portnum.value,
|
||||
bytes = decoded.payload.toByteArray().toByteString(),
|
||||
hopLimit = packet.hop_limit,
|
||||
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
|
||||
wantAck = packet.want_ack == true,
|
||||
hopStart = packet.hop_start,
|
||||
snr = packet.rx_snr,
|
||||
rssi = packet.rx_rssi,
|
||||
replyId = decoded.reply_id,
|
||||
relayNode = packet.relay_node,
|
||||
viaMqtt = packet.via_mqtt == true,
|
||||
emoji = decoded.emoji,
|
||||
transportMechanism = packet.transport_mechanism.value,
|
||||
)
|
||||
}
|
||||
fun toNodeID(n: Int): String = nodeManager.toNodeID(n)
|
||||
|
||||
fun toDataPacket(packet: MeshPacket): DataPacket? = commonMapper.toDataPacket(packet)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import org.meshtastic.core.model.DataPacket
|
|||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.NodeIdLookup
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
|
|
@ -54,7 +55,7 @@ constructor(
|
|||
private val nodeRepository: NodeRepository?,
|
||||
private val serviceBroadcasts: MeshServiceBroadcasts?,
|
||||
private val serviceNotifications: MeshServiceNotifications?,
|
||||
) {
|
||||
) : NodeIdLookup {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val nodeDBbyNodeNum = ConcurrentHashMap<Int, NodeEntity>()
|
||||
|
|
@ -260,9 +261,9 @@ constructor(
|
|||
return hasExistingUser && isDefaultName && isDefaultHwModel
|
||||
}
|
||||
|
||||
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
|
||||
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
|
||||
DataPacket.ID_BROADCAST
|
||||
} else {
|
||||
nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
|
||||
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
|
|||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
|
|
@ -56,6 +57,16 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
|||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.local_stats_bad
|
||||
import org.meshtastic.core.resources.local_stats_battery
|
||||
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
|
||||
import org.meshtastic.core.resources.local_stats_dropped
|
||||
import org.meshtastic.core.resources.local_stats_nodes
|
||||
import org.meshtastic.core.resources.local_stats_noise
|
||||
import org.meshtastic.core.resources.local_stats_relays
|
||||
import org.meshtastic.core.resources.local_stats_traffic
|
||||
import org.meshtastic.core.resources.local_stats_uptime
|
||||
import org.meshtastic.core.resources.local_stats_utilization
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.core.resources.mark_as_read
|
||||
|
|
@ -112,6 +123,7 @@ constructor(
|
|||
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
|
||||
private const val STATS_UPDATE_MINUTES = 15
|
||||
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
|
||||
private const val BULLET = "• "
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -270,35 +282,59 @@ constructor(
|
|||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
var cachedTelemetry: Telemetry? = null
|
||||
var cachedLocalStats: LocalStats? = null
|
||||
var nextStatsUpdateMillis: Long = 0
|
||||
var cachedMessage: String? = null
|
||||
private var cachedDeviceMetrics: DeviceMetrics? = null
|
||||
private var cachedLocalStats: LocalStats? = null
|
||||
private var nextStatsUpdateMillis: Long = 0
|
||||
private var cachedMessage: String? = null
|
||||
|
||||
// region Public Notification Methods
|
||||
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
|
||||
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
|
||||
val hasLocalStats = telemetry?.local_stats != null
|
||||
val hasDeviceMetrics = telemetry?.device_metrics != null
|
||||
// Update caches if telemetry is provided
|
||||
telemetry?.let { t ->
|
||||
t.local_stats?.let { stats ->
|
||||
cachedLocalStats = stats
|
||||
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
|
||||
}
|
||||
t.device_metrics?.let { metrics -> cachedDeviceMetrics = metrics }
|
||||
}
|
||||
|
||||
// Seeding from database if caches are still null (e.g. on restart or reconnection)
|
||||
if (cachedLocalStats == null || cachedDeviceMetrics == null) {
|
||||
val repo = nodeRepository.get()
|
||||
val myNodeNum = repo.myNodeInfo.value?.myNodeNum
|
||||
if (myNodeNum != null) {
|
||||
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
|
||||
// and we only do this once if the cache is empty.
|
||||
val nodes = runBlocking { repo.getNodeDBbyNum().first() }
|
||||
nodes[myNodeNum]?.let { entity ->
|
||||
if (cachedDeviceMetrics == null) {
|
||||
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
|
||||
}
|
||||
if (cachedLocalStats == null) {
|
||||
cachedLocalStats = entity.deviceTelemetry.local_stats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val stats = cachedLocalStats
|
||||
val metrics = cachedDeviceMetrics
|
||||
|
||||
val message =
|
||||
when {
|
||||
hasLocalStats -> {
|
||||
val localStatsMessage = telemetry?.local_stats?.formatToString()
|
||||
cachedTelemetry = telemetry
|
||||
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
|
||||
localStatsMessage
|
||||
}
|
||||
cachedTelemetry == null && hasDeviceMetrics -> {
|
||||
val deviceMetricsMessage = telemetry?.device_metrics?.formatToString()
|
||||
if (cachedLocalStats == null) {
|
||||
cachedTelemetry = telemetry
|
||||
}
|
||||
nextStatsUpdateMillis = nowMillis
|
||||
deviceMetricsMessage
|
||||
}
|
||||
stats != null -> stats.formatToString(metrics?.battery_level)
|
||||
metrics != null -> metrics.formatToString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats)
|
||||
// Only update cachedMessage if we have something new, otherwise keep what we have.
|
||||
// Fallback to "No Stats Available" only if we truly have nothing.
|
||||
if (message != null) {
|
||||
cachedMessage = message
|
||||
} else if (cachedMessage == null) {
|
||||
cachedMessage = getString(Res.string.no_local_stats)
|
||||
}
|
||||
|
||||
val notification =
|
||||
createServiceStateNotification(
|
||||
|
|
@ -471,7 +507,8 @@ constructor(
|
|||
.setShowWhen(true)
|
||||
|
||||
message?.let {
|
||||
builder.setContentText(it)
|
||||
// First line of message is used for collapsed view, ensure it doesn't have a bullet
|
||||
builder.setContentText(it.substringBefore("\n").removePrefix(BULLET))
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
|
||||
}
|
||||
|
||||
|
|
@ -633,7 +670,7 @@ constructor(
|
|||
private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
|
||||
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
|
||||
val title = getString(Res.string.low_battery_title).format(node.shortName)
|
||||
val batteryLevel = node.deviceTelemetry?.device_metrics?.battery_level ?: 0
|
||||
val batteryLevel = node.deviceMetrics?.battery_level ?: 0
|
||||
val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
|
||||
|
||||
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
|
||||
|
|
@ -811,23 +848,48 @@ constructor(
|
|||
|
||||
return IconCompat.createWithBitmap(bitmap)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Extension Functions (Localized)
|
||||
|
||||
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
|
||||
val parts = mutableListOf<String>()
|
||||
batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
|
||||
parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes))
|
||||
parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds)))
|
||||
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization, air_util_tx))
|
||||
|
||||
// Traffic Stats
|
||||
if (num_packets_tx > 0 || num_packets_rx > 0) {
|
||||
parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe))
|
||||
}
|
||||
if (num_tx_relay > 0) {
|
||||
parts.add(BULLET + getString(Res.string.local_stats_relays, num_tx_relay, num_tx_relay_canceled))
|
||||
}
|
||||
|
||||
// Diagnostic Fields
|
||||
val diagnosticParts = mutableListOf<String>()
|
||||
if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise, noise_floor))
|
||||
if (num_packets_rx_bad > 0) diagnosticParts.add(getString(Res.string.local_stats_bad, num_packets_rx_bad))
|
||||
if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped, num_tx_dropped))
|
||||
|
||||
if (diagnosticParts.isNotEmpty()) {
|
||||
parts.add(
|
||||
BULLET + getString(Res.string.local_stats_diagnostics_prefix, diagnosticParts.joinToString(" | ")),
|
||||
)
|
||||
}
|
||||
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun DeviceMetrics.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
|
||||
uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) }
|
||||
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f))
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
// Extension function to format LocalStats into a readable string.
|
||||
private fun LocalStats.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("Uptime: ${formatUptime(uptime_seconds)}")
|
||||
parts.add("ChUtil: %.2f%%".format(channel_utilization))
|
||||
parts.add("AirUtilTX: %.2f%%".format(air_util_tx))
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun DeviceMetrics.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
battery_level?.let { parts.add("Battery Level: $it") }
|
||||
uptime_seconds?.let { parts.add("Uptime: ${formatUptime(it)}") }
|
||||
channel_utilization?.let { parts.add("ChUtil: %.2f%%".format(it)) }
|
||||
air_util_tx?.let { parts.add("AirUtilTX: %.2f%%".format(it)) }
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -507,42 +507,47 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
|||
},
|
||||
)
|
||||
} else {
|
||||
myFirmwareVersion?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
|
||||
"min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
myFirmwareVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
|
||||
"min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
|
||||
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
|
||||
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
} ?: run { Logger.w { "[FW_CHECK] Firmware version is null despite myNodeInfo being present" } }
|
||||
?: run {
|
||||
Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
|
||||
}
|
||||
}
|
||||
} ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@
|
|||
*/
|
||||
package com.geeksville.mesh.ui.connections
|
||||
|
||||
import android.net.InetAddresses
|
||||
import android.os.Build
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -87,15 +84,6 @@ import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
|||
import org.meshtastic.proto.Config
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
} else {
|
||||
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
|
||||
* displays connection status.
|
||||
|
|
@ -180,8 +168,9 @@ fun ConnectionsScreen(
|
|||
val uiState =
|
||||
when {
|
||||
connectionState.isConnected() && ourNode != null -> 2
|
||||
connectionState == ConnectionState.Connecting ||
|
||||
(connectionState == ConnectionState.Disconnected && selectedDevice != "n") -> 1
|
||||
connectionState.isConnected() ||
|
||||
connectionState == ConnectionState.Connecting ||
|
||||
selectedDevice != NO_DEVICE_SELECTED -> 1
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,14 +118,17 @@ fun CurrentlyConnectedInfo(
|
|||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(text = node.user.long_name ?: "", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
node.metadata?.firmware_version?.let { firmwareVersion ->
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_version, firmwareVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
node.metadata
|
||||
?.firmware_version
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { firmwareVersion ->
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_version, firmwareVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp
|
|||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import com.geeksville.mesh.ui.connections.isValidAddress
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.isValidAddress
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_network_device
|
||||
import org.meshtastic.core.resources.address
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.ui.contact
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.icon.Conversations
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.feature.messaging.MessageScreen
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun AdaptiveContactsScreen(
|
||||
navController: NavHostController,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
initialContactKey: String? = null,
|
||||
initialMessage: String = "",
|
||||
) {
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
||||
|
||||
val handleBack: () -> Unit = {
|
||||
val currentEntry = navController.currentBackStackEntry
|
||||
val isContactsRoute = currentEntry?.destination?.hasRoute<ContactsRoutes.Contacts>() == true
|
||||
|
||||
// Check if we navigated here from another screen (e.g., from Nodes or Map)
|
||||
val previousEntry = navController.previousBackStackEntry
|
||||
val isFromDifferentGraph = previousEntry?.destination?.hasRoute<ContactsRoutes.ContactsGraph>() == false
|
||||
|
||||
if (isFromDifferentGraph && !isContactsRoute) {
|
||||
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
|
||||
navController.navigateUp()
|
||||
} else {
|
||||
// Close the detail pane within the adaptive scaffold
|
||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
|
||||
|
||||
LaunchedEffect(initialContactKey) {
|
||||
if (initialContactKey != null) {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents.collect { event ->
|
||||
if (
|
||||
event is ScrollToTopEvent.ConversationsTabPressed &&
|
||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail
|
||||
) {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
navigator.navigateBack(backNavigationBehavior)
|
||||
} else {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
ContactsScreen(
|
||||
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
|
||||
onClickNodeChip = {
|
||||
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToMessages = { contactKey ->
|
||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
|
||||
},
|
||||
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
activeContactKey = navigator.currentDestination?.contentKey,
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
navigator.currentDestination?.contentKey?.let { contactKey ->
|
||||
key(contactKey) {
|
||||
MessageScreen(
|
||||
contactKey = contactKey,
|
||||
message = if (contactKey == initialContactKey) initialMessage else "",
|
||||
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
|
||||
onNavigateBack = handleBack,
|
||||
)
|
||||
}
|
||||
} ?: PlaceholderScreen()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Conversations,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.conversations),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.ui.contact
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.VolumeOff
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.sample_message
|
||||
import org.meshtastic.core.resources.some_username
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.ui.component.SecurityIcon
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ContactItem(
|
||||
contact: Contact,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isActive: Boolean = false,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
onNodeChipClick: () -> Unit = {},
|
||||
channels: ChannelSet? = null,
|
||||
) = with(contact) {
|
||||
val isOutlined = !selected && !isActive
|
||||
|
||||
val colors =
|
||||
if (isOutlined) {
|
||||
CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
|
||||
} else {
|
||||
val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
|
||||
CardDefaults.cardColors(containerColor = containerColor)
|
||||
}
|
||||
|
||||
val border =
|
||||
if (isOutlined) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.semantics { contentDescription = shortName },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = colors,
|
||||
border = border,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)
|
||||
|
||||
ChatMetadata(modifier = Modifier.padding(top = 4.dp), contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactHeader(
|
||||
contact: Contact,
|
||||
channels: ChannelSet?,
|
||||
modifier: Modifier = Modifier,
|
||||
onNodeChipClick: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
if (contact.nodeColors != null) {
|
||||
AssistChipDefaults.assistChipColors(
|
||||
labelColor = Color(contact.nodeColors.first),
|
||||
containerColor = Color(contact.nodeColors.second),
|
||||
)
|
||||
} else {
|
||||
AssistChipDefaults.assistChipColors()
|
||||
}
|
||||
|
||||
Row(modifier = modifier.padding(0.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
AssistChip(
|
||||
onClick = onNodeChipClick,
|
||||
modifier =
|
||||
Modifier.width(IntrinsicSize.Min).height(32.dp).semantics { contentDescription = contact.shortName },
|
||||
label = {
|
||||
Text(
|
||||
text = contact.shortName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
colors = colors,
|
||||
)
|
||||
|
||||
// Show unlock icon for broadcast with default PSK
|
||||
val isBroadcast = with(contact.contactKey) { getOrNull(1) == '^' || endsWith("^all") || endsWith("^broadcast") }
|
||||
|
||||
if (isBroadcast && channels != null) {
|
||||
val channelIndex = contact.contactKey[0].digitToIntOrNull()
|
||||
channelIndex?.let { index -> SecurityIcon(channels, index) }
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp).weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
text = contact.longName,
|
||||
)
|
||||
Text(
|
||||
text = contact.lastMessageTime.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNREAD_MESSAGE_LIMIT = 99
|
||||
|
||||
@Composable
|
||||
private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = contact.lastMessageText.orEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
AnimatedVisibility(visible = contact.isMuted) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(start = 4.dp).size(20.dp),
|
||||
imageVector = Icons.AutoMirrored.TwoTone.VolumeOff,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(modifier = Modifier.padding(start = 4.dp), visible = contact.unreadCount > 0) {
|
||||
val text =
|
||||
if (contact.unreadCount > UNREAD_MESSAGE_LIMIT) {
|
||||
"$UNREAD_MESSAGE_LIMIT+"
|
||||
} else {
|
||||
contact.unreadCount.toString()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.primary, shape = CircleShape)
|
||||
.defaultMinSize(minWidth = 20.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun ContactItemPreview() {
|
||||
val sampleContact =
|
||||
Contact(
|
||||
contactKey = "0^all",
|
||||
shortName = stringResource(Res.string.some_username),
|
||||
longName = stringResource(Res.string.unknown_username),
|
||||
lastMessageTime = "Mon",
|
||||
lastMessageText = stringResource(Res.string.sample_message),
|
||||
unreadCount = 2,
|
||||
messageCount = 10,
|
||||
isMuted = true,
|
||||
isUnmessageable = false,
|
||||
)
|
||||
|
||||
val contactsList =
|
||||
listOf(
|
||||
sampleContact,
|
||||
sampleContact.copy(
|
||||
shortName = "0",
|
||||
longName = "A very long contact name that should be truncated.",
|
||||
lastMessageTime = "15 minutes ago",
|
||||
),
|
||||
)
|
||||
|
||||
AppTheme { Column { contactsList.forEach { contact -> ContactItem(contact = contact, selected = false) } } }
|
||||
}
|
||||
|
|
@ -1,615 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.ui.contact
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.formatMuteRemainingTime
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.are_you_sure
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
import org.meshtastic.core.resources.close_selection
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.resources.currently
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.delete_messages
|
||||
import org.meshtastic.core.resources.delete_selection
|
||||
import org.meshtastic.core.resources.mute_1_week
|
||||
import org.meshtastic.core.resources.mute_8_hours
|
||||
import org.meshtastic.core.resources.mute_always
|
||||
import org.meshtastic.core.resources.mute_notifications
|
||||
import org.meshtastic.core.resources.mute_status_always
|
||||
import org.meshtastic.core.resources.mute_status_muted_for_days
|
||||
import org.meshtastic.core.resources.mute_status_muted_for_hours
|
||||
import org.meshtastic.core.resources.mute_status_unmuted
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.select_all
|
||||
import org.meshtastic.core.resources.unmute
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.MeshtasticTextDialog
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.SelectAll
|
||||
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
|
||||
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
onNavigateToShare: () -> Unit,
|
||||
viewModel: ContactsViewModel = hiltViewModel(),
|
||||
uIViewModel: UIViewModel = hiltViewModel(),
|
||||
onClickNodeChip: (Int) -> Unit = {},
|
||||
onNavigateToMessages: (String) -> Unit = {},
|
||||
onNavigateToNodeDetails: (Int) -> Unit = {},
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeContactKey: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var showMuteDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// State for managing selected contacts
|
||||
val selectedContactKeys = remember { mutableStateListOf<String>() }
|
||||
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
|
||||
|
||||
// State for contacts list
|
||||
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
|
||||
|
||||
// Create channel placeholders (always show broadcast contacts, even when empty)
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
val channelPlaceholders =
|
||||
remember(channels.settings.size) {
|
||||
(0 until channels.settings.size).map { ch ->
|
||||
Contact(
|
||||
contactKey = "$ch^all",
|
||||
shortName = "$ch",
|
||||
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
|
||||
lastMessageTime = "",
|
||||
lastMessageText = "",
|
||||
unreadCount = 0,
|
||||
messageCount = 0,
|
||||
isMuted = false,
|
||||
isUnmessageable = false,
|
||||
nodeColors = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val contactsListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents?.collectLatest { event ->
|
||||
if (event is ScrollToTopEvent.ConversationsTabPressed) {
|
||||
contactsListState.smartScrollToTop(coroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derived state for selected contacts and count
|
||||
val selectedContacts =
|
||||
remember(pagedContacts.itemCount, selectedContactKeys) {
|
||||
(0 until pagedContacts.itemCount)
|
||||
.mapNotNull { pagedContacts[it] }
|
||||
.filter { it.contactKey in selectedContactKeys }
|
||||
}
|
||||
// Get message count directly from repository for selected contacts
|
||||
var selectedCount by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) {
|
||||
selectedCount = viewModel.getTotalMessageCount(selectedContactKeys.toList())
|
||||
}
|
||||
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
|
||||
|
||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { uIViewModel.clearRequestChannelUrl() }) }
|
||||
|
||||
// Callback functions for item interaction
|
||||
val onContactClick: (Contact) -> Unit = { contact ->
|
||||
if (isSelectionModeActive) {
|
||||
// If in selection mode, toggle selection
|
||||
if (selectedContactKeys.contains(contact.contactKey)) {
|
||||
selectedContactKeys.remove(contact.contactKey)
|
||||
} else {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
}
|
||||
} else {
|
||||
// If not in selection mode, navigate to messages
|
||||
onNavigateToMessages(contact.contactKey)
|
||||
}
|
||||
}
|
||||
|
||||
val onNodeChipClick: (Contact) -> Unit = { contact ->
|
||||
if (contact.contactKey.contains("!")) {
|
||||
// if it's a node, look up the nodeNum including the !
|
||||
val nodeKey = contact.contactKey.substring(1)
|
||||
val node = viewModel.getNode(nodeKey)
|
||||
onNavigateToNodeDetails(node.num)
|
||||
} else {
|
||||
// Channels
|
||||
}
|
||||
}
|
||||
|
||||
val onContactLongClick: (Contact) -> Unit = { contact ->
|
||||
// Enter selection mode and select the item on long press
|
||||
if (!isSelectionModeActive) {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
} else {
|
||||
// If already in selection mode, toggle selection
|
||||
if (selectedContactKeys.contains(contact.contactKey)) {
|
||||
selectedContactKeys.remove(contact.contactKey)
|
||||
} else {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.conversations),
|
||||
ourNode = ourNode,
|
||||
showNodeChip = ourNode != null && connectionState.isConnected(),
|
||||
canNavigateUp = false,
|
||||
onNavigateUp = {},
|
||||
actions = {},
|
||||
onClickChip = { onClickNodeChip(it.num) },
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState.isConnected()) {
|
||||
MeshtasticImportFAB(
|
||||
sharedContact = sharedContactRequested,
|
||||
onImport = { uri ->
|
||||
uIViewModel.handleScannedUri(uri) {
|
||||
scope.launch { context.showToast(Res.string.channel_invalid) }
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShare,
|
||||
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
if (isSelectionModeActive) {
|
||||
// Display selection toolbar when in selection mode
|
||||
SelectionToolbar(
|
||||
selectedCount = selectedContactKeys.size,
|
||||
onCloseSelection = { selectedContactKeys.clear() },
|
||||
onMuteSelected = { showMuteDialog = true },
|
||||
onDeleteSelected = { showDeleteDialog = true },
|
||||
onSelectAll = {
|
||||
selectedContactKeys.clear()
|
||||
selectedContactKeys.addAll(
|
||||
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
|
||||
)
|
||||
},
|
||||
isAllMuted = isAllMuted, // Pass the derived state
|
||||
)
|
||||
}
|
||||
|
||||
ContactListViewPaged(
|
||||
contacts = pagedContacts,
|
||||
channelPlaceholders = channelPlaceholders,
|
||||
selectedList = selectedContactKeys,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onContactClick,
|
||||
onLongClick = onContactLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = contactsListState,
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
selectedCount = selectedCount,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteContacts(selectedContactKeys.toList())
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Get contact settings for the dialog
|
||||
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
|
||||
if (showMuteDialog) {
|
||||
MuteNotificationsDialog(
|
||||
selectedContactKeys = selectedContactKeys.toList(),
|
||||
contactSettings = contactSettings,
|
||||
onDismiss = { showMuteDialog = false },
|
||||
onConfirm = { muteUntil ->
|
||||
showMuteDialog = false
|
||||
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun MuteNotificationsDialog(
|
||||
selectedContactKeys: List<String>,
|
||||
contactSettings: Map<String, ContactSettings>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
|
||||
) {
|
||||
// Options for mute duration
|
||||
val muteOptions = remember {
|
||||
listOf(
|
||||
Res.string.unmute to 0L,
|
||||
Res.string.mute_8_hours to TimeConstants.EIGHT_HOURS.inWholeMilliseconds,
|
||||
Res.string.mute_1_week to 7.days.inWholeMilliseconds,
|
||||
Res.string.mute_always to Long.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
// State to hold the selected mute duration index
|
||||
var selectedOptionIndex by remember { mutableIntStateOf(2) } // Default to "Always"
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
|
||||
titleRes = Res.string.mute_notifications,
|
||||
confirmTextRes = Res.string.okay,
|
||||
onConfirm = {
|
||||
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
|
||||
onConfirm(selectedMuteDuration)
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
dismissTextRes = Res.string.cancel,
|
||||
text = {
|
||||
Column {
|
||||
// Show current mute status
|
||||
selectedContactKeys.forEach { contactKey ->
|
||||
contactSettings[contactKey]?.let { settings ->
|
||||
val now = nowMillis
|
||||
val statusText =
|
||||
when {
|
||||
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
|
||||
val remaining = settings.muteUntil - now
|
||||
if (remaining > 0) {
|
||||
val (days, hours) = formatMuteRemainingTime(remaining)
|
||||
if (days >= 1) {
|
||||
stringResource(Res.string.mute_status_muted_for_days, days, hours)
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_muted_for_hours, hours)
|
||||
}
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
}
|
||||
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
|
||||
else -> stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.currently) + " " + statusText,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
muteOptions.forEachIndexed { index, (stringRes, _) ->
|
||||
val isSelected = index == selectedOptionIndex
|
||||
val text = stringResource(stringRes)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmationDialog(
|
||||
selectedCount: Int, // Number of items to be deleted
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit, // Lambda to handle the delete action
|
||||
) {
|
||||
val deleteMessage =
|
||||
pluralStringResource(
|
||||
Res.plurals.delete_messages,
|
||||
selectedCount,
|
||||
selectedCount, // Pass the count as a format argument
|
||||
)
|
||||
|
||||
MeshtasticTextDialog(
|
||||
titleRes = Res.string.are_you_sure,
|
||||
message = deleteMessage,
|
||||
confirmTextRes = Res.string.delete,
|
||||
onConfirm = {
|
||||
onConfirm()
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectionToolbar(
|
||||
selectedCount: Int,
|
||||
onCloseSelection: () -> Unit,
|
||||
onMuteSelected: () -> Unit,
|
||||
onDeleteSelected: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
isAllMuted: Boolean,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(text = "$selectedCount") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close_selection))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onMuteSelected) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isAllMuted) {
|
||||
MeshtasticIcons.VolumeUpTwoTone
|
||||
} else {
|
||||
MeshtasticIcons.VolumeMuteTwoTone
|
||||
},
|
||||
contentDescription =
|
||||
if (isAllMuted) {
|
||||
"Unmute selected"
|
||||
} else {
|
||||
"Mute selected"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteSelected) {
|
||||
Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete_selection))
|
||||
}
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select_all))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactListViewPaged(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
channels: ChannelSet? = null,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
ContactListContentInternal(
|
||||
contacts = contacts,
|
||||
channelPlaceholders = channelPlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = listState,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactListContentInternal(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
listState: LazyListState,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
|
||||
|
||||
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
||||
contactListPlaceholdersItems(
|
||||
placeholders = visiblePlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListPagedItems(
|
||||
contacts = contacts,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListAppendLoadingItem(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListPlaceholdersItems(
|
||||
placeholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
|
||||
val contact = placeholders[index]
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
},
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListPagedItems(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
|
||||
contacts[index]?.let { contact ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
},
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
|
||||
if (contacts.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberVisiblePlaceholders(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
|
||||
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
|
||||
channelPlaceholders.filter { it.contactKey !in pagedKeys }
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.ui.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.model.util.getShortDate
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.channel_name
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.map as collectionsMap
|
||||
|
||||
@HiltViewModel
|
||||
class ContactsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
) : ViewModel() {
|
||||
val ourNodeInfo = nodeRepository.ourNodeInfo
|
||||
|
||||
val connectionState = serviceRepository.connectionState
|
||||
|
||||
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
|
||||
|
||||
// Combine node info and myId to reduce argument count in subsequent combines
|
||||
private val identityFlow: Flow<Pair<MyNodeEntity?, String?>> =
|
||||
combine(nodeRepository.myNodeInfo, nodeRepository.myId) { info, id -> Pair(info, id) }
|
||||
|
||||
/**
|
||||
* Non-paginated contact list.
|
||||
*
|
||||
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
|
||||
* uses [contactListPaged] instead for better performance with large contact lists.
|
||||
*
|
||||
* @see contactListPaged for the paginated version used in ContactsScreen
|
||||
*/
|
||||
val contactList =
|
||||
combine(identityFlow, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) {
|
||||
identity,
|
||||
contacts,
|
||||
channelSet,
|
||||
settings,
|
||||
->
|
||||
val (myNodeInfo, myId) = identity
|
||||
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
|
||||
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
|
||||
val placeholder =
|
||||
(0 until channelSet.settings.size).associate { ch ->
|
||||
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
|
||||
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
|
||||
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
|
||||
}
|
||||
|
||||
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
|
||||
val data = packet.data
|
||||
val contactKey = packet.contact_key
|
||||
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
|
||||
val toBroadcast = data.to == DataPacket.ID_BROADCAST
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val user = getUser(if (fromLocal) data.to else data.from)
|
||||
val node = getNode(if (fromLocal) data.to else data.from)
|
||||
|
||||
val shortName = user.short_name ?: ""
|
||||
val longName =
|
||||
if (toBroadcast) {
|
||||
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
|
||||
} else {
|
||||
user.long_name ?: ""
|
||||
}
|
||||
|
||||
Contact(
|
||||
contactKey = contactKey,
|
||||
shortName = if (toBroadcast) "${data.channel}" else shortName,
|
||||
longName = longName,
|
||||
lastMessageTime = getShortDate(data.time),
|
||||
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
|
||||
unreadCount = packetRepository.getUnreadCount(contactKey),
|
||||
messageCount = packetRepository.getMessageCount(contactKey),
|
||||
isMuted = settings[contactKey]?.isMuted == true,
|
||||
isUnmessageable = user.is_unmessagable ?: false,
|
||||
nodeColors =
|
||||
if (!toBroadcast) {
|
||||
node.colors
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val contactListPaged: Flow<PagingData<Contact>> =
|
||||
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
|
||||
val (myNodeInfo, myId) = identity
|
||||
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val myNodeNum = params.myNodeNum
|
||||
val channelSet = params.channelSet
|
||||
val settings = params.settings
|
||||
val myId = params.myId
|
||||
|
||||
packetRepository.getContactsPaged().map { pagingData ->
|
||||
pagingData.map { packet ->
|
||||
val data = packet.data
|
||||
val contactKey = packet.contact_key
|
||||
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
|
||||
val toBroadcast = data.to == DataPacket.ID_BROADCAST
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val user = getUser(if (fromLocal) data.to else data.from)
|
||||
val node = getNode(if (fromLocal) data.to else data.from)
|
||||
|
||||
val shortName = user.short_name ?: ""
|
||||
val longName =
|
||||
if (toBroadcast) {
|
||||
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
|
||||
} else {
|
||||
user.long_name ?: ""
|
||||
}
|
||||
|
||||
Contact(
|
||||
contactKey = contactKey,
|
||||
shortName = if (toBroadcast) "${data.channel}" else shortName,
|
||||
longName = longName,
|
||||
lastMessageTime = getShortDate(data.time),
|
||||
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
|
||||
unreadCount = packetRepository.getUnreadCount(contactKey),
|
||||
messageCount = packetRepository.getMessageCount(contactKey),
|
||||
isMuted = settings[contactKey]?.isMuted == true,
|
||||
isUnmessageable = user.is_unmessagable ?: false,
|
||||
nodeColors =
|
||||
if (!toBroadcast) {
|
||||
node.colors
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun deleteContacts(contacts: List<String>) =
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
|
||||
|
||||
fun setMuteUntil(contacts: List<String>, until: Long) =
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
|
||||
|
||||
fun getContactSettings() = packetRepository.getContactSettings()
|
||||
|
||||
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total message count for a list of contact keys. This queries the repository directly, so it works even if
|
||||
* contacts aren't loaded in the paged list.
|
||||
*/
|
||||
suspend fun getTotalMessageCount(contactKeys: List<String>): Int = if (contactKeys.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
contactKeys.sumOf { contactKey -> packetRepository.getMessageCount(contactKey) }
|
||||
}
|
||||
|
||||
private fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
private data class ContactsPagedParams(
|
||||
val myNodeNum: Int?,
|
||||
val channelSet: ChannelSet,
|
||||
val settings: Map<String, ContactSettings>,
|
||||
val myId: String?,
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -68,6 +69,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.util.getChannelUrl
|
||||
import org.meshtastic.core.model.util.qrCode
|
||||
|
|
@ -299,10 +301,10 @@ fun ChannelScreen(
|
|||
|
||||
@Composable
|
||||
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
|
||||
val url = channelSet.getChannelUrl(shouldAddChannel)
|
||||
val commonUri = channelSet.getChannelUrl(shouldAddChannel)
|
||||
QrDialog(
|
||||
title = stringResource(Res.string.share_channels_qr),
|
||||
uri = url,
|
||||
uri = commonUri.toPlatformUri() as Uri,
|
||||
qrCode = channelSet.qrCode(shouldAddChannel),
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.ui.contact.ContactItem
|
||||
import com.geeksville.mesh.ui.contact.ContactsViewModel
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.sample_message
|
||||
import org.meshtastic.core.resources.share
|
||||
import org.meshtastic.core.resources.share_to
|
||||
import org.meshtastic.core.resources.some_username
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
|
||||
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
|
||||
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
|
||||
var selectedContact by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.share_to),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
itemsIndexed(contacts, key = { index, contact -> "${contact.contactKey}#$index" }) { _, contact ->
|
||||
val selected = contact.contactKey == selectedContact
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selected,
|
||||
onClick = { selectedContact = contact.contactKey },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { onConfirm(selectedContact) },
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
enabled = selectedContact.isNotEmpty(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Send,
|
||||
contentDescription = stringResource(Res.string.share),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ShareScreenPreview() {
|
||||
AppTheme {
|
||||
ShareScreen(
|
||||
contacts =
|
||||
listOf(
|
||||
Contact(
|
||||
contactKey = "0^all",
|
||||
shortName = stringResource(Res.string.some_username),
|
||||
longName = stringResource(Res.string.unknown_username),
|
||||
lastMessageTime = "3 minutes ago",
|
||||
lastMessageText = stringResource(Res.string.sample_message),
|
||||
unreadCount = 2,
|
||||
messageCount = 10,
|
||||
isMuted = true,
|
||||
isUnmessageable = false,
|
||||
),
|
||||
),
|
||||
onConfirm = {},
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@
|
|||
<path
|
||||
android:pathData="m17.5564,11.8482 l-5.208,7.6376 -1.5217,-1.0377 5.9674,-8.7512c0.1714,-0.2513 0.4558,-0.4019 0.76,-0.4022 0.3042,-0.0003 0.5889,0.1497 0.7608,0.4008l5.9811,8.7374 -1.5199,1.0404z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillColor="#ff000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m5.854,19.4956 l6.3707,-9.3423 -1.5749,-1.0739 -6.3707,9.3423z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillColor="#ff000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import org.junit.Assert.assertNotNull
|
|||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
|
@ -42,6 +41,7 @@ class MeshDataMapperTest {
|
|||
|
||||
@Test
|
||||
fun `toNodeID resolves broadcast correctly`() {
|
||||
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
|
||||
assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST))
|
||||
}
|
||||
|
||||
|
|
@ -49,9 +49,7 @@ class MeshDataMapperTest {
|
|||
fun `toNodeID resolves known node correctly`() {
|
||||
val nodeNum = 1234
|
||||
val nodeId = "!1234abcd"
|
||||
val nodeEntity = mockk<NodeEntity>()
|
||||
every { nodeEntity.user.id } returns nodeId
|
||||
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns nodeEntity
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
|
||||
assertEquals(nodeId, mapper.toNodeID(nodeNum))
|
||||
}
|
||||
|
|
@ -59,9 +57,10 @@ class MeshDataMapperTest {
|
|||
@Test
|
||||
fun `toNodeID resolves unknown node to default ID`() {
|
||||
val nodeNum = 1234
|
||||
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns null
|
||||
val nodeId = DataPacket.nodeNumToDefaultId(nodeNum)
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
|
||||
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), mapper.toNodeID(nodeNum))
|
||||
assertEquals(nodeId, mapper.toNodeID(nodeNum))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -74,9 +73,8 @@ class MeshDataMapperTest {
|
|||
fun `toDataPacket maps basic fields correctly`() {
|
||||
val nodeNum = 1234
|
||||
val nodeId = "!1234abcd"
|
||||
val nodeEntity = mockk<NodeEntity>()
|
||||
every { nodeEntity.user.id } returns nodeId
|
||||
every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
|
||||
|
||||
val proto =
|
||||
MeshPacket(
|
||||
|
|
@ -113,7 +111,7 @@ class MeshDataMapperTest {
|
|||
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
|
||||
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
|
||||
|
||||
every { nodeManager.nodeDBbyNodeNum[any()] } returns null
|
||||
every { nodeManager.toNodeID(any()) } returns "any"
|
||||
|
||||
val result = mapper.toDataPacket(proto)
|
||||
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue