mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 23:45:20 +00:00
Merge branch 'main' into gossip-routing-2
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val dotColor = when {
|
||||
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header - Fully reactive state tracking
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
|
||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation - no more static lookups!
|
||||
val isFavorite = isFavoriteReactive(
|
||||
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
|
||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||
|
||||
// Pass geohash context and people for NIP-17 chat title formatting
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
PrivateChatHeader(
|
||||
peerID = selectedPrivatePeer,
|
||||
@@ -318,6 +318,10 @@ private fun PrivateChatHeader(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||
try {
|
||||
@@ -413,17 +417,33 @@ private fun PrivateChatHeader(
|
||||
|
||||
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
||||
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||
val securityModifier = if (!isNostrDM) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
if (showGlobe) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
modifier = Modifier.size(14.dp).then(securityModifier),
|
||||
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||
)
|
||||
} else {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
modifier = Modifier.size(14.dp).then(securityModifier)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(14.dp).then(securityModifier),
|
||||
tint = Color(0xFF32D74B)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -523,18 +543,18 @@ private fun MainHeader(
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
// Bookmarks store for current geohash toggle (iOS parity)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -653,8 +673,8 @@ private fun LocationChannelsButton(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Get current channel selection from location manager
|
||||
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val teleported by viewModel.isTeleported.observeAsState(false)
|
||||
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
|
||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
|
||||
@@ -41,22 +41,24 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val messages by viewModel.messages.observeAsState(emptyList())
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
|
||||
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
|
||||
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@@ -78,11 +80,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
showPasswordDialog = showPasswordPrompt
|
||||
}
|
||||
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Get location channel info for timeline switching
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Determine what messages to show based on current context (unified timelines)
|
||||
val displayMessages = when {
|
||||
@@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
SidebarOverlay(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { viewModel.hideSidebar() },
|
||||
onShowVerification = {
|
||||
viewModel.showVerificationSheet(fromSidebar = true)
|
||||
viewModel.hideSidebar()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
},
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
|
||||
)
|
||||
}
|
||||
|
||||
@@ -513,7 +523,11 @@ private fun ChatDialogs(
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
showSecurityVerificationSheet: Boolean,
|
||||
onSecurityVerificationSheetDismiss: () -> Unit
|
||||
) {
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@@ -567,4 +581,20 @@ private fun ChatDialogs(
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerificationSheet) {
|
||||
VerificationSheet(
|
||||
isPresented = showVerificationSheet,
|
||||
onDismiss = onVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerificationSheet) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = showSecurityVerificationSheet,
|
||||
onDismiss = onSecurityVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Centralized state definitions and data classes for the chat system
|
||||
@@ -21,171 +28,168 @@ data class CommandSuggestion(
|
||||
/**
|
||||
* Contains all the observable state for the chat system
|
||||
*/
|
||||
class ChatState {
|
||||
class ChatState(
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
|
||||
// Core messages and peer state
|
||||
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
|
||||
val messages: LiveData<List<BitchatMessage>> = _messages
|
||||
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
|
||||
val connectedPeers: LiveData<List<String>> = _connectedPeers
|
||||
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
|
||||
|
||||
private val _nickname = MutableLiveData<String>()
|
||||
val nickname: LiveData<String> = _nickname
|
||||
private val _nickname = MutableStateFlow<String>("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableLiveData<Boolean>(false)
|
||||
val isConnected: LiveData<Boolean> = _isConnected
|
||||
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// Private chats
|
||||
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
|
||||
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
|
||||
|
||||
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
|
||||
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
|
||||
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
|
||||
|
||||
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
|
||||
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
|
||||
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
|
||||
|
||||
// Channels
|
||||
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
|
||||
val joinedChannels: LiveData<Set<String>> = _joinedChannels
|
||||
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
|
||||
|
||||
private val _currentChannel = MutableLiveData<String?>(null)
|
||||
val currentChannel: LiveData<String?> = _currentChannel
|
||||
private val _currentChannel = MutableStateFlow<String?>(null)
|
||||
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
|
||||
|
||||
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
|
||||
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||
|
||||
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
|
||||
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
|
||||
|
||||
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
|
||||
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
|
||||
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
|
||||
|
||||
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
|
||||
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
|
||||
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
|
||||
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
|
||||
|
||||
private val _passwordPromptChannel = MutableLiveData<String?>(null)
|
||||
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
|
||||
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||
|
||||
// Sidebar state
|
||||
private val _showSidebar = MutableLiveData(false)
|
||||
val showSidebar: LiveData<Boolean> = _showSidebar
|
||||
private val _showSidebar = MutableStateFlow(false)
|
||||
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
||||
|
||||
// Command autocomplete
|
||||
private val _showCommandSuggestions = MutableLiveData(false)
|
||||
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
|
||||
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||
|
||||
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
|
||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
|
||||
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
|
||||
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
|
||||
|
||||
// Mention autocomplete
|
||||
private val _showMentionSuggestions = MutableLiveData(false)
|
||||
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
|
||||
private val _showMentionSuggestions = MutableStateFlow(false)
|
||||
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
|
||||
|
||||
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
|
||||
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
|
||||
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
|
||||
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
|
||||
|
||||
// Favorites
|
||||
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
|
||||
val favoritePeers: LiveData<Set<String>> = _favoritePeers
|
||||
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
|
||||
|
||||
// Noise session states for peers (for reactive UI updates)
|
||||
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
|
||||
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
|
||||
|
||||
// Peer fingerprint state for reactive favorites (for reactive UI updates)
|
||||
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
|
||||
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
|
||||
|
||||
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
|
||||
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
|
||||
|
||||
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
|
||||
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
|
||||
|
||||
// Direct connection status per peer (for live UI updates)
|
||||
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
|
||||
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
|
||||
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
|
||||
|
||||
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
// Navigation state
|
||||
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
||||
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||
|
||||
private val _showVerificationSheet = MutableStateFlow(false)
|
||||
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
||||
|
||||
private val _showSecurityVerificationSheet = MutableStateFlow(false)
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
|
||||
|
||||
// Location channels state (for Nostr geohash features)
|
||||
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
|
||||
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
|
||||
|
||||
private val _isTeleported = MutableLiveData<Boolean>(false)
|
||||
val isTeleported: LiveData<Boolean> = _isTeleported
|
||||
private val _isTeleported = MutableStateFlow<Boolean>(false)
|
||||
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
|
||||
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||
// For background thread updates by repositories/handlers in their own scopes
|
||||
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
||||
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
|
||||
|
||||
|
||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
|
||||
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
|
||||
|
||||
// Geohash participant counts reactive state (for real-time location channel counts)
|
||||
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
|
||||
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
|
||||
|
||||
// Unread state computed properties
|
||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
|
||||
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
|
||||
.map { unreadMap -> unreadMap.values.any { it > 0 } }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
init {
|
||||
// Initialize unread state mediators
|
||||
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
|
||||
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
|
||||
}
|
||||
|
||||
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
|
||||
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
|
||||
}
|
||||
}
|
||||
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
|
||||
.map { unreadSet -> unreadSet.isNotEmpty() }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
// Getters for internal state access
|
||||
fun getMessagesValue() = _messages.value ?: emptyList()
|
||||
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
|
||||
fun getMessagesValue() = _messages.value
|
||||
fun getConnectedPeersValue() = _connectedPeers.value
|
||||
fun getNicknameValue() = _nickname.value
|
||||
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
|
||||
fun getPrivateChatsValue() = _privateChats.value
|
||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
||||
// Thread-safe posting helpers for background updates
|
||||
fun postGeohashPeople(people: List<GeoPerson>) {
|
||||
_geohashPeople.postValue(people)
|
||||
}
|
||||
|
||||
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||
_geohashParticipantCounts.postValue(counts)
|
||||
}
|
||||
|
||||
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value
|
||||
fun getCurrentChannelValue() = _currentChannel.value
|
||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
|
||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
|
||||
fun getChannelMessagesValue() = _channelMessages.value
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
||||
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
||||
fun getShowSidebarValue() = _showSidebar.value ?: false
|
||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
||||
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
|
||||
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
|
||||
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
||||
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
|
||||
fun getShowSidebarValue() = _showSidebar.value
|
||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
||||
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
||||
fun getMentionSuggestionsValue() = _mentionSuggestions.value
|
||||
fun getFavoritePeersValue() = _favoritePeers.value
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
||||
fun getShowAppInfoValue() = _showAppInfo.value
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||
|
||||
// Setters for state updates
|
||||
fun setMessages(messages: List<BitchatMessage>) {
|
||||
@@ -197,7 +201,7 @@ class ChatState {
|
||||
}
|
||||
|
||||
fun postTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.postValue(teleported)
|
||||
_teleportedGeo.value = teleported
|
||||
}
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
@@ -269,7 +273,7 @@ class ChatState {
|
||||
}
|
||||
|
||||
fun setFavoritePeers(favorites: Set<String>) {
|
||||
val currentValue = _favoritePeers.value ?: emptySet()
|
||||
val currentValue = _favoritePeers.value
|
||||
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
|
||||
Log.d("ChatState", "Current value: $currentValue")
|
||||
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
|
||||
@@ -278,8 +282,7 @@ class ChatState {
|
||||
// Always set the value - even if equal, this ensures observers are triggered
|
||||
_favoritePeers.value = favorites
|
||||
|
||||
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
|
||||
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
|
||||
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
||||
}
|
||||
|
||||
fun setPeerSessionStates(states: Map<String, String>) {
|
||||
@@ -305,6 +308,14 @@ class ChatState {
|
||||
fun setShowAppInfo(show: Boolean) {
|
||||
_showAppInfo.value = show
|
||||
}
|
||||
|
||||
fun setShowVerificationSheet(show: Boolean) {
|
||||
_showVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setShowSecurityVerificationSheet(show: Boolean) {
|
||||
_showSecurityVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
_selectedLocationChannel.value = channel
|
||||
|
||||
@@ -4,12 +4,16 @@ import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@@ -19,6 +23,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@@ -46,8 +57,24 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun getCurrentNpub(): String? {
|
||||
return try {
|
||||
NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(getApplication())
|
||||
?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String {
|
||||
return VerificationService.buildMyQRString(nickname, npub) ?: ""
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
private val state = ChatState()
|
||||
private val state = ChatState(
|
||||
scope = viewModelScope,
|
||||
)
|
||||
|
||||
// Transfer progress tracking
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
@@ -55,6 +82,7 @@ class ChatViewModel(
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
@@ -73,6 +101,17 @@ class ChatViewModel(
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
meshService = meshService,
|
||||
identityManager = identityManager,
|
||||
state = state,
|
||||
notificationManager = notificationManager,
|
||||
messageManager = messageManager
|
||||
)
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
|
||||
|
||||
@@ -103,44 +142,81 @@ class ChatViewModel(
|
||||
|
||||
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
val nickname: LiveData<String> = state.nickname
|
||||
val isConnected: LiveData<Boolean> = state.isConnected
|
||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
|
||||
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
|
||||
val currentChannel: LiveData<String?> = state.currentChannel
|
||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
|
||||
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
|
||||
val showSidebar: LiveData<Boolean> = state.showSidebar
|
||||
|
||||
val messages: StateFlow<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
|
||||
val nickname: StateFlow<String> = state.nickname
|
||||
val isConnected: StateFlow<Boolean> = state.isConnected
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
||||
val hasUnreadChannels = state.hasUnreadChannels
|
||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
||||
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
|
||||
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
|
||||
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
||||
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
|
||||
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
|
||||
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: LiveData<Boolean> = state.isTeleported
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
|
||||
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
||||
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
||||
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
||||
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
||||
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
||||
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
|
||||
state.setConnectedPeers(peers)
|
||||
state.setIsConnected(peers.isNotEmpty())
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
|
||||
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
|
||||
state.setMessages(msgs)
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||
// Replace with store snapshot
|
||||
state.setPrivateChats(byPeer)
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
byPeer.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
}
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
} catch (_: Exception) { }
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
|
||||
// Replace with store snapshot
|
||||
state.setChannelMessages(byChannel)
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
|
||||
viewModelScope.launch {
|
||||
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
|
||||
@@ -210,6 +286,9 @@ class ChatViewModel(
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Load verified fingerprints from secure storage
|
||||
verificationHandler.loadVerifiedFingerprints()
|
||||
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
@@ -565,7 +644,7 @@ class ChatViewModel(
|
||||
|
||||
private fun logCurrentFavoriteState() {
|
||||
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
|
||||
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
|
||||
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||
Log.i("ChatViewModel", "==============================")
|
||||
@@ -609,6 +688,18 @@ class ChatViewModel(
|
||||
// Update fingerprint mappings from centralized manager
|
||||
val fingerprints = privateChatManager.getAllPeerFingerprints()
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
fingerprints.forEach { (peerID, fingerprint) ->
|
||||
identityManager.cachePeerFingerprint(peerID, fingerprint)
|
||||
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
|
||||
}
|
||||
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(fingerprint, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
val nicknames = meshService.getPeerNicknames()
|
||||
state.setPeerNicknames(nicknames)
|
||||
@@ -623,6 +714,34 @@ class ChatViewModel(
|
||||
}
|
||||
state.setPeerDirect(directMap)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Flush any pending QR verification once a Noise session is established
|
||||
currentPeers.forEach { peerID ->
|
||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR Verification
|
||||
|
||||
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
|
||||
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
verificationHandler.unverifyFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
||||
return verificationHandler.beginQRVerification(qr)
|
||||
}
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
@@ -631,41 +750,71 @@ class ChatViewModel(
|
||||
return meshService.getDebugStatus()
|
||||
}
|
||||
|
||||
// Note: Mesh service restart is now handled by MainActivity
|
||||
// This function is no longer needed
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
// Forward to notification manager for notification logic
|
||||
notificationManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
// Update notification manager with current private chat peer
|
||||
notificationManager.setCurrentPrivateChatPeer(peerID)
|
||||
}
|
||||
|
||||
fun setCurrentGeohash(geohash: String?) {
|
||||
// Update notification manager with current geohash for notification logic
|
||||
notificationManager.setCurrentGeohash(geohash)
|
||||
}
|
||||
|
||||
fun clearNotificationsForSender(peerID: String) {
|
||||
// Clear notifications when user opens a chat
|
||||
notificationManager.clearNotificationsForSender(peerID)
|
||||
}
|
||||
|
||||
fun clearNotificationsForGeohash(geohash: String) {
|
||||
// Clear notifications when user opens a geohash chat
|
||||
notificationManager.clearNotificationsForGeohash(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mesh mention notifications when user opens mesh chat
|
||||
*/
|
||||
fun clearMeshMentionNotifications() {
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
private var reopenSidebarAfterVerification = false
|
||||
|
||||
fun showVerificationSheet(fromSidebar: Boolean = false) {
|
||||
if (fromSidebar) {
|
||||
reopenSidebarAfterVerification = true
|
||||
}
|
||||
state.setShowVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideVerificationSheet() {
|
||||
state.setShowVerificationSheet(false)
|
||||
if (reopenSidebarAfterVerification) {
|
||||
reopenSidebarAfterVerification = false
|
||||
state.setShowSidebar(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(false)
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return verificationHandler.getMyFingerprint()
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.verifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.unverifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
// MARK: - Command Autocomplete (delegated)
|
||||
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
@@ -707,6 +856,14 @@ class ChatViewModel(
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyResponse(peerID, payload)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -790,7 +947,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear secure identity state (if used)
|
||||
try {
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
val identityManager = SecureIdentityStateManager(getApplication())
|
||||
identityManager.clearIdentityData()
|
||||
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||
try {
|
||||
@@ -803,7 +960,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear FavoritesPersistenceService persistent relationships
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -932,5 +1089,6 @@ class ChatViewModel(
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.util.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val isTeleported by viewModel.isTeleported.observeAsState(false)
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
@@ -55,9 +55,9 @@ class GeohashViewModel(
|
||||
private var geoTimer: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
@@ -73,12 +73,16 @@ class GeohashViewModel(
|
||||
}
|
||||
try {
|
||||
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
||||
state.setSelectedLocationChannel(channel)
|
||||
switchLocationChannel(channel)
|
||||
viewModelScope.launch {
|
||||
locationChannelManager?.selectedChannel?.collect { channel ->
|
||||
state.setSelectedLocationChannel(channel)
|
||||
switchLocationChannel(channel)
|
||||
}
|
||||
}
|
||||
locationChannelManager?.teleported?.observeForever { teleported ->
|
||||
state.setIsTeleported(teleported)
|
||||
viewModelScope.launch {
|
||||
locationChannelManager?.teleported?.collect { teleported ->
|
||||
state.setIsTeleported(teleported)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||
@@ -120,7 +124,7 @@ class GeohashViewModel(
|
||||
}
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val teleported = state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
@@ -231,7 +235,7 @@ class GeohashViewModel(
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val teleported = state.isTeleported.value
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
|
||||
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||||
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe bookmarks state
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe reactive participant counts
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
|
||||
.height(56.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
TextButton(
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.close_plain),
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -35,10 +35,10 @@ fun LocationNotesButton(
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
@@ -46,7 +46,7 @@ fun LocationNotesButton(
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -25,6 +24,7 @@ import com.bitchat.android.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
|
||||
// State
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.observeAsState()
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
|
||||
|
||||
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
|
||||
val count = notes.size
|
||||
|
||||
// Get location name (building or block) - matches iOS locationNames lookup
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
|
||||
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
// Get location name from locationManager
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
|
||||
*/
|
||||
@Composable
|
||||
fun shouldAnimateMessage(messageId: String): Boolean {
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
|
||||
return miningMessages.contains(messageId)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
|
||||
if (message.isPrivate) {
|
||||
// Private message
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
|
||||
// Reactive read receipts: Send immediately if user is currently viewing this chat
|
||||
|
||||
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
sendReadReceiptIfFocused(senderPeerID)
|
||||
sendReadReceiptIfFocused(message)
|
||||
}
|
||||
|
||||
// Show notification with enhanced information - now includes senderPeerID
|
||||
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
|
||||
)
|
||||
}
|
||||
} else if (message.channel != null) {
|
||||
// Channel message
|
||||
// Channel message: AppStateStore is the source of truth for list; only manage unread
|
||||
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
||||
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
|
||||
val channel = message.channel
|
||||
val viewingClassic = state.getCurrentChannelValue() == channel
|
||||
val viewingGeohash = try {
|
||||
if (channel.startsWith("geo:")) {
|
||||
val geo = channel.removePrefix("geo:")
|
||||
val selected = state.selectedLocationChannel.value
|
||||
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
|
||||
} else false
|
||||
} catch (_: Exception) { false }
|
||||
if (!viewingClassic && !viewingGeohash) {
|
||||
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
||||
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
||||
state.setUnreadChannelMessages(currentUnread)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Public mesh message - always store to preserve message history
|
||||
messageManager.addMessage(message)
|
||||
|
||||
// Check for mentions in mesh chat
|
||||
// Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
|
||||
// Still run mention detection/notifications
|
||||
checkAndTriggerMeshMentionNotification(message)
|
||||
}
|
||||
|
||||
@@ -205,6 +216,14 @@ class MeshDelegateHandler(
|
||||
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -263,21 +282,31 @@ class MeshDelegateHandler(
|
||||
* Uses same logic as notification system - send read receipt if user is currently
|
||||
* viewing the private chat with this sender AND app is in foreground.
|
||||
*/
|
||||
private fun sendReadReceiptIfFocused(senderPeerID: String) {
|
||||
private fun sendReadReceiptIfFocused(message: BitchatMessage) {
|
||||
// Get notification manager's focus state (mirror the notification logic)
|
||||
val isAppInBackground = notificationManager.getAppBackgroundState()
|
||||
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
|
||||
|
||||
// Send read receipt if user is currently focused on this specific chat
|
||||
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID
|
||||
val senderPeerID = message.senderPeerID
|
||||
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID")
|
||||
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService())
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
// Send directly for this message to avoid relying on unread queues
|
||||
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
if (current.remove(senderPeerID)) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
|
||||
val currentMessages = state.getMessagesValue().toMutableList()
|
||||
currentMessages.add(message)
|
||||
state.setMessages(currentMessages)
|
||||
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
|
||||
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Log a system message into the main chat (visible to user)
|
||||
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
|
||||
channelMessageList.add(message)
|
||||
currentChannelMessages[channel] = channelMessageList
|
||||
state.setChannelMessages(currentChannelMessages)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
|
||||
|
||||
// Update unread count if not currently viewing this channel
|
||||
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
||||
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
|
||||
// Mark as unread if not currently viewing this chat
|
||||
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
||||
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun clearPrivateMessages(peerID: String) {
|
||||
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
|
||||
|
||||
// MARK: - Delivery Status Updates
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
is DeliveryStatus.Sending -> 1
|
||||
is DeliveryStatus.Sent -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 3
|
||||
is DeliveryStatus.Delivered -> 4
|
||||
is DeliveryStatus.Read -> 5
|
||||
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
|
||||
}
|
||||
|
||||
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
|
||||
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
|
||||
return if (statusPriority(new) >= statusPriority(old)) new else old
|
||||
}
|
||||
|
||||
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
|
||||
// Update in private chats
|
||||
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
|
||||
val updatedMessages = messages.toMutableList()
|
||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||
if (messageIndex >= 0) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
||||
updatedPrivateChats[peerID] = updatedMessages
|
||||
updated = true
|
||||
val current = updatedMessages[messageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||
updatedPrivateChats[peerID] = updatedMessages
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
state.setPrivateChats(updatedPrivateChats)
|
||||
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
|
||||
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Update in main messages
|
||||
val updatedMessages = state.getMessagesValue().toMutableList()
|
||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||
if (messageIndex >= 0) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
||||
state.setMessages(updatedMessages)
|
||||
val current = updatedMessages[messageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||
state.setMessages(updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// Update in channel messages
|
||||
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
|
||||
val channelMessagesList = messages.toMutableList()
|
||||
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
|
||||
if (channelMessageIndex >= 0) {
|
||||
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
|
||||
updatedChannelMessages[channel] = channelMessagesList
|
||||
val current = channelMessagesList[channelMessageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
|
||||
updatedChannelMessages[channel] = channelMessagesList
|
||||
}
|
||||
}
|
||||
}
|
||||
state.setChannelMessages(updatedChannelMessages)
|
||||
|
||||
@@ -280,6 +280,37 @@ class NotificationManager(
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
|
||||
}
|
||||
|
||||
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (peerID != null) {
|
||||
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
|
||||
putExtra(EXTRA_PEER_ID, peerID)
|
||||
putExtra(EXTRA_SENDER_NICKNAME, body)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
|
||||
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
|
||||
) {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
val isMining by PoWPreferenceManager.isMining.collectAsState()
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
|
||||
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
|
||||
@@ -292,28 +292,30 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
val senderPeerID = message.senderPeerID
|
||||
if (senderPeerID != null) {
|
||||
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
|
||||
if (!isPeerBlocked(senderPeerID)) {
|
||||
// Add to private messages
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
}
|
||||
|
||||
// Track as unread for read receipt purposes
|
||||
var unreadCount = 0
|
||||
if (!suppressUnread) {
|
||||
// Ensure chat exists
|
||||
messageManager.initializePrivateChat(senderPeerID)
|
||||
// Track as unread for read receipt purposes if not focused
|
||||
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
unreadList.add(message)
|
||||
unreadCount = unreadList.size
|
||||
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
|
||||
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
currentUnread.add(senderPeerID)
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
|
||||
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(inferredPeer, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,27 +324,33 @@ class PrivateChatManager(
|
||||
* Called when the user focuses on a private chat
|
||||
*/
|
||||
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
||||
val unreadList = unreadReceivedMessages[peerID]
|
||||
if (unreadList.isNullOrEmpty()) {
|
||||
Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
|
||||
return
|
||||
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||
val messages = chats[peerID].orEmpty()
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
|
||||
|
||||
// Send read receipt for each unread message - now using direct method call
|
||||
unreadList.forEach { message ->
|
||||
try {
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
meshService.sendReadReceipt(message.id, peerID, myNickname)
|
||||
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
var sentCount = 0
|
||||
messages.forEach { msg ->
|
||||
// Only for incoming messages from this peer
|
||||
if (msg.senderPeerID == peerID) {
|
||||
try {
|
||||
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
||||
sentCount += 1
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the unread list since we've sent read receipts
|
||||
// Clear any locally tracked unread queue for this peer
|
||||
unreadReceivedMessages.remove(peerID)
|
||||
// Also clear UI unread marker for this peer now that chat is focused/read
|
||||
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
|
||||
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
|
||||
}
|
||||
|
||||
fun cleanupDisconnectedPeer(peerID: String) {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.NoEncryption
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
|
||||
private data class SecurityStatusInfo(
|
||||
val text: String,
|
||||
val icon: ImageVector,
|
||||
val tint: Color
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SecurityVerificationSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!isPresented) return
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
|
||||
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SecurityVerificationHeader(
|
||||
accent = accent,
|
||||
onClose = onDismiss
|
||||
)
|
||||
|
||||
if (peerID == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_no_peer),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
} else {
|
||||
val selectedPeerID = peerID!!
|
||||
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
|
||||
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
|
||||
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
val sessionState = peerSessionStates[selectedPeerID]
|
||||
val statusInfo = buildStatusInfo(
|
||||
isVerified = isVerified,
|
||||
sessionState = sessionState,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
SecurityStatusCard(
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
boxColor = boxColor,
|
||||
statusInfo = statusInfo
|
||||
)
|
||||
|
||||
FingerprintBlock(
|
||||
title = stringResource(R.string.fingerprint_their),
|
||||
fingerprint = fingerprint,
|
||||
boxColor = boxColor,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
FingerprintBlock(
|
||||
title = stringResource(R.string.fingerprint_yours),
|
||||
fingerprint = viewModel.getMyFingerprint(),
|
||||
boxColor = boxColor,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
SecurityVerificationActions(
|
||||
isVerified = isVerified,
|
||||
fingerprint = fingerprint,
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
|
||||
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
|
||||
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
|
||||
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityVerificationHeader(
|
||||
accent: Color,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.security_verification_title),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CloseButton(onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun buildStatusInfo(
|
||||
isVerified: Boolean,
|
||||
sessionState: String?,
|
||||
accent: Color
|
||||
): SecurityStatusInfo {
|
||||
val text = when {
|
||||
isVerified -> stringResource(R.string.fingerprint_status_verified)
|
||||
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
|
||||
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
|
||||
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
|
||||
else -> stringResource(R.string.fingerprint_status_uninitialized)
|
||||
}
|
||||
val icon = when {
|
||||
isVerified -> Icons.Filled.Verified
|
||||
sessionState == "handshaking" -> Icons.Outlined.Sync
|
||||
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
|
||||
sessionState == "established" -> Icons.Filled.Lock
|
||||
else -> Icons.Outlined.NoEncryption
|
||||
}
|
||||
val tint = when {
|
||||
isVerified -> Color(0xFF32D74B)
|
||||
sessionState == "failed" -> Color(0xFFFF3B30)
|
||||
sessionState == "handshaking" -> Color(0xFFFF9500)
|
||||
sessionState == "established" -> Color(0xFF32D74B)
|
||||
else -> accent.copy(alpha = 0.6f)
|
||||
}
|
||||
return SecurityStatusInfo(text, icon, tint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityStatusCard(
|
||||
displayName: String,
|
||||
accent: Color,
|
||||
boxColor: Color,
|
||||
statusInfo: SecurityStatusInfo
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(boxColor, shape = MaterialTheme.shapes.medium)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = statusInfo.icon,
|
||||
contentDescription = null,
|
||||
tint = statusInfo.tint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
)
|
||||
Text(
|
||||
text = statusInfo.text,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityVerificationActions(
|
||||
isVerified: Boolean,
|
||||
fingerprint: String?,
|
||||
displayName: String,
|
||||
accent: Color,
|
||||
canStartHandshake: Boolean,
|
||||
onStartHandshake: () -> Unit,
|
||||
onVerify: (String) -> Unit,
|
||||
onUnverify: (String) -> Unit
|
||||
) {
|
||||
if (canStartHandshake) {
|
||||
Button(
|
||||
onClick = onStartHandshake,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accent,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_start_handshake),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Verified,
|
||||
iconTint = Color(0xFF32D74B),
|
||||
text = stringResource(R.string.fingerprint_verified_label),
|
||||
textTint = Color(0xFF32D74B)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_verified_message),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Button(
|
||||
onClick = { fingerprint?.let(onUnverify) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFFF3B30),
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Warning,
|
||||
iconTint = Color(0xFFFF9500),
|
||||
text = stringResource(R.string.fingerprint_not_verified_label),
|
||||
textTint = Color(0xFFFF9500)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (fingerprint != null) {
|
||||
Button(
|
||||
onClick = { onVerify(fingerprint) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF34C759),
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_mark_verified),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationStatusRow(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
text: String,
|
||||
textTint: Color
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = textTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerprintBlock(
|
||||
title: String,
|
||||
fingerprint: String?,
|
||||
boxColor: Color,
|
||||
accent: Color
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var showMenu by remember(fingerprint) { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
)
|
||||
if (fingerprint != null) {
|
||||
Column {
|
||||
Text(
|
||||
text = formatFingerprint(fingerprint),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = accent,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true }
|
||||
)
|
||||
.background(boxColor, shape = MaterialTheme.shapes.small)
|
||||
.padding(16.dp),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.fingerprint_copy)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(fingerprint))
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_pending),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = Color(0xFFFF9500),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFingerprint(fingerprint: String): String {
|
||||
val upper = fingerprint.uppercase()
|
||||
val sb = StringBuilder()
|
||||
upper.forEachIndexed { index, c ->
|
||||
if (index > 0 && index % 4 == 0) {
|
||||
if (index % 16 == 0) sb.append('\n') else sb.append(' ')
|
||||
}
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -22,7 +21,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
|
||||
|
||||
/**
|
||||
@@ -34,19 +35,20 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
fun SidebarOverlay(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onShowVerification: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
|
||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
||||
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -110,7 +112,7 @@ fun SidebarOverlay(
|
||||
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
|
||||
|
||||
when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
@@ -131,6 +133,7 @@ fun SidebarOverlay(
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onShowVerification = onShowVerification,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
@@ -257,8 +260,11 @@ fun PeopleSection(
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onShowVerification: () -> Unit,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -279,6 +285,17 @@ fun PeopleSection(
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
@@ -291,10 +308,11 @@ fun PeopleSection(
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
@@ -308,10 +326,16 @@ fun PeopleSection(
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
@@ -342,7 +366,7 @@ fun PeopleSection(
|
||||
// Offline favorites (exclude ones mapped to connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (!isMappedToConnected) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
@@ -368,6 +392,7 @@ fun PeopleSection(
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
@@ -384,7 +409,7 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
@@ -392,6 +417,7 @@ fun PeopleSection(
|
||||
isDirect = isDirectLive,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
@@ -408,7 +434,7 @@ fun PeopleSection(
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
@@ -419,7 +445,7 @@ fun PeopleSection(
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
if (hrp == "npub") data.hexEncodedString() else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
@@ -435,6 +461,7 @@ fun PeopleSection(
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
@@ -449,6 +476,7 @@ fun PeopleSection(
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
@@ -516,6 +544,7 @@ private fun PeerItem(
|
||||
isDirect: Boolean,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isVerified: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
@@ -616,6 +645,16 @@ private fun PeerItem(
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite star with proper filled/outlined states
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Handles QR verification logic and state, extracted from ChatViewModel.
|
||||
*/
|
||||
class VerificationHandler(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val meshService: BluetoothMeshService,
|
||||
private val identityManager: SecureIdentityStateManager,
|
||||
private val state: ChatState,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val messageManager: MessageManager
|
||||
) {
|
||||
|
||||
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
|
||||
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
|
||||
|
||||
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
|
||||
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
|
||||
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
|
||||
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun loadVerifiedFingerprints() {
|
||||
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
|
||||
}
|
||||
|
||||
fun isPeerVerified(peerID: String): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
|
||||
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return _verifiedFingerprints.value.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, false)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.remove(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
||||
val targetNoise = qr.noiseKeyHex.lowercase()
|
||||
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
|
||||
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
|
||||
noiseKeyHex == targetNoise
|
||||
} ?: return false
|
||||
|
||||
if (pendingQRVerifications.containsKey(peerID)) return true
|
||||
val nonce = ByteArray(16)
|
||||
java.security.SecureRandom().nextBytes(nonce)
|
||||
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
|
||||
pendingQRVerifications[peerID] = pending
|
||||
|
||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
|
||||
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
||||
} else {
|
||||
meshService.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
|
||||
identityManager.cacheFingerprintNickname(fp, qr.nickname)
|
||||
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
|
||||
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendPendingVerificationIfNeeded(peerID: String) {
|
||||
val pending = pendingQRVerifications[peerID] ?: return
|
||||
if (pending.sent) return
|
||||
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
|
||||
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
||||
}
|
||||
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
|
||||
scope.launch {
|
||||
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
|
||||
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
|
||||
if (parsed.first.lowercase() != myNoiseHex) return@launch
|
||||
|
||||
val lastNonce = lastVerifyNonceByPeer[peerID]
|
||||
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
|
||||
lastVerifyNonceByPeer[peerID] = parsed.second
|
||||
|
||||
val fp = meshService.getPeerFingerprint(peerID)
|
||||
if (fp != null) {
|
||||
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
|
||||
if (_verifiedFingerprints.value.contains(fp)) {
|
||||
val lastToast = lastMutualToastAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
||||
lastMutualToastAt[fp] = System.currentTimeMillis()
|
||||
val name = resolvePeerDisplayName(peerID)
|
||||
val body = context.getString(R.string.verify_mutual_match_body, name)
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
|
||||
}
|
||||
}
|
||||
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
|
||||
scope.launch {
|
||||
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
|
||||
val pending = pendingQRVerifications[peerID] ?: return@launch
|
||||
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
|
||||
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
|
||||
|
||||
val ok = VerificationService.verifyResponseSignature(
|
||||
noiseKeyHex = resp.noiseKeyHex,
|
||||
nonceA = resp.nonceA,
|
||||
signature = resp.signature,
|
||||
signerPublicKeyHex = pending.signKeyHex
|
||||
)
|
||||
if (!ok) return@launch
|
||||
|
||||
pendingQRVerifications.remove(peerID)
|
||||
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
|
||||
identityManager.setVerifiedFingerprint(fp, true)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fp)
|
||||
_verifiedFingerprints.value = current
|
||||
|
||||
val name = resolvePeerDisplayName(peerID)
|
||||
identityManager.cacheFingerprintNickname(fp, name)
|
||||
val noiseKeyHex = try {
|
||||
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
|
||||
}
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
|
||||
|
||||
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
|
||||
val lastToast = lastMutualToastAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
||||
lastMutualToastAt[fp] = System.currentTimeMillis()
|
||||
val body = context.getString(R.string.verify_mutual_match_body, name)
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
val fromMap = state.getPeerFingerprintsValue()[peerID]
|
||||
if (fromMap != null) return fromMap
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
return try {
|
||||
when {
|
||||
peerID.length == 64 && peerID.matches(hexRegex) -> {
|
||||
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
|
||||
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
|
||||
}
|
||||
peerID.length == 16 && peerID.matches(hexRegex) -> {
|
||||
val meshFp = meshService.getPeerFingerprint(peerID)
|
||||
if (meshFp != null) return meshFp
|
||||
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
||||
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
||||
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
||||
}
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
||||
}
|
||||
peerID.startsWith("nostr_") -> {
|
||||
val pubHex = GeohashAliasRegistry.get(peerID)
|
||||
val noiseKey = pubHex?.let {
|
||||
FavoritesPersistenceService.shared.findNoiseKey(it)
|
||||
}
|
||||
noiseKey?.let {
|
||||
val noiseHex = it.hexEncodedString()
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
||||
}
|
||||
}
|
||||
peerID.startsWith("nostr:") -> {
|
||||
val prefix = peerID.removePrefix("nostr:").lowercase()
|
||||
val pubHex = GeohashAliasRegistry
|
||||
.snapshot()
|
||||
.values
|
||||
.firstOrNull { it.lowercase().startsWith(prefix) }
|
||||
val noiseKey = pubHex?.let {
|
||||
FavoritesPersistenceService.shared.findNoiseKey(it)
|
||||
}
|
||||
noiseKey?.let {
|
||||
val noiseHex = it.hexEncodedString()
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val meshFp = meshService.getPeerFingerprint(peerID)
|
||||
if (meshFp != null) return meshFp
|
||||
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
||||
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
||||
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
||||
}
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
val nicknameMap = state.peerNicknames.value
|
||||
nicknameMap[peerID]?.let { return it }
|
||||
try {
|
||||
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
||||
fingerprint?.let { fp ->
|
||||
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
|
||||
if (cached.isNotBlank()) return cached
|
||||
}
|
||||
}
|
||||
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
if (peerID.length == 64 && peerID.matches(hexRegex)) {
|
||||
val noiseKeyBytes = try {
|
||||
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
} catch (_: Exception) { null }
|
||||
val favorite = noiseKeyBytes?.let {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(it)
|
||||
}
|
||||
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
|
||||
if (peerID.length == 16 && peerID.matches(hexRegex)) {
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
|
||||
return peerID.take(8)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return meshService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
if (fingerprint.isBlank()) return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, true)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
if (fingerprint.isBlank()) return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, false)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.remove(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
private fun addVerificationSystemMessage(peerID: String, text: String) {
|
||||
val msg = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
messageManager.addPrivateMessageNoUnread(peerID, msg)
|
||||
}
|
||||
|
||||
private fun resolvePeerDisplayName(peerID: String): String {
|
||||
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
|
||||
return nick ?: peerID.take(8)
|
||||
}
|
||||
|
||||
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
|
||||
notificationManager.showVerificationNotification(title, body, peerID)
|
||||
}
|
||||
|
||||
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
|
||||
val bytes = noiseHex.dataFromHexString() ?: return null
|
||||
return fingerprintFromNoiseBytes(bytes)
|
||||
}
|
||||
|
||||
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return hash.hexEncodedString()
|
||||
}
|
||||
|
||||
private data class PendingVerification(
|
||||
val noiseKeyHex: String,
|
||||
val signKeyHex: String,
|
||||
val nonceA: ByteArray,
|
||||
val startedAtMs: Long,
|
||||
val sent: Boolean
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PendingVerification
|
||||
|
||||
if (startedAtMs != other.startedAtMs) return false
|
||||
if (sent != other.sent) return false
|
||||
if (noiseKeyHex != other.noiseKeyHex) return false
|
||||
if (signKeyHex != other.signKeyHex) return false
|
||||
if (!nonceA.contentEquals(other.nonceA)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startedAtMs.hashCode()
|
||||
result = 31 * result + sent.hashCode()
|
||||
result = 31 * result + noiseKeyHex.hashCode()
|
||||
result = 31 * result + signKeyHex.hashCode()
|
||||
result = 31 * result + nonceA.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.camera.compose.CameraXViewfinder
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.viewfinder.core.ImplementationMode
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCode
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerificationSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!isPresented) return
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
|
||||
|
||||
var showingScanner by remember { mutableStateOf(false) }
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val npub = remember {
|
||||
viewModel.getCurrentNpub()
|
||||
}
|
||||
|
||||
|
||||
val qrString = remember(nickname, npub) {
|
||||
viewModel.buildMyQRString(nickname, npub)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
VerificationHeader(
|
||||
accent = accent,
|
||||
onClose = onDismiss
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (showingScanner) {
|
||||
QRScannerPanel(
|
||||
accent = accent,
|
||||
boxColor = boxColor,
|
||||
onScan = { code ->
|
||||
val qr = VerificationService.verifyScannedQR(code)
|
||||
if (qr != null && viewModel.beginQRVerification(qr)) {
|
||||
showingScanner = false
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MyQrPanel(
|
||||
qrString = qrString,
|
||||
accent = accent,
|
||||
boxColor = boxColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
ToggleVerificationModeButton(
|
||||
showingScanner = showingScanner,
|
||||
onToggle = { showingScanner = !showingScanner }
|
||||
)
|
||||
|
||||
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
if (peerID != null) {
|
||||
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
|
||||
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
||||
Button(
|
||||
onClick = { viewModel.unverifyFingerprint(peerID!!) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationHeader(
|
||||
accent: Color,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_title).uppercase(),
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = accent
|
||||
)
|
||||
CloseButton(onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyQrPanel(
|
||||
qrString: String,
|
||||
accent: Color,
|
||||
boxColor: Color,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_my_qr_title),
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = accent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
QRCodeCard(qrString = qrString, boxColor = boxColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleVerificationModeButton(
|
||||
showingScanner: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onToggle,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (showingScanner) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.verify_show_my_qr),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.verify_scan_someone),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QRCodeCard(qrString: String, boxColor: Color) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(boxColor, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (qrString.isNotBlank()) {
|
||||
QRCodeImage(data = qrString, size = 220.dp)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.background(Color.Transparent, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_qr_unavailable),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = qrString,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QRCodeImage(data: String, size: Dp) {
|
||||
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
|
||||
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
||||
if (data.isBlank() || sizePx <= 0) return null
|
||||
return try {
|
||||
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
bitmapFromMatrix(matrix)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bitmap = createBitmap(width, height)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap[x, y] =
|
||||
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
private fun QRScannerPanel(
|
||||
onScan: (String) -> Unit,
|
||||
accent: Color,
|
||||
boxColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var lastValid by remember { mutableStateOf<String?>(null) }
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
|
||||
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
|
||||
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val onCodeState = rememberUpdatedState(onScan)
|
||||
val analyzer = remember {
|
||||
QRCodeAnalyzer { text ->
|
||||
mainHandler.post {
|
||||
if (text == lastValid) return@post
|
||||
lastValid = text
|
||||
onCodeState.value(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(permissionState.status.isGranted) {
|
||||
var cameraProvider: ProcessCameraProvider? = null
|
||||
if (permissionState.status.isGranted) {
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
val preview = Preview.Builder()
|
||||
.build()
|
||||
.also { it.setSurfaceProvider { request -> surfaceRequests.value = request } }
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor, analyzer) }
|
||||
|
||||
runCatching {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
|
||||
}
|
||||
},
|
||||
executor
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
surfaceRequests.value = null
|
||||
runCatching { cameraProvider?.unbindAll() }
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { cameraExecutor.shutdown() }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(boxColor, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_scan_prompt_friend),
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = accent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (permissionState.status.isGranted) {
|
||||
surfaceRequest?.let { request ->
|
||||
CameraXViewfinder(
|
||||
surfaceRequest = request,
|
||||
implementationMode = ImplementationMode.EMBEDDED,
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clipToBounds()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_camera_permission),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
Button(
|
||||
onClick = { permissionState.launchPermissionRequest() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_request_camera),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class QRCodeAnalyzer(
|
||||
private val onCode: (String) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
private val scanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
@ExperimentalGetImage
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val mediaImage = imageProxy.image ?: run {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(input)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val text = barcodes.firstOrNull()?.rawValue
|
||||
if (!text.isNullOrBlank()) onCode(text)
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ object DebugPreferenceManager {
|
||||
// GCS keys (no migration/back-compat)
|
||||
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
||||
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
||||
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
@@ -100,4 +101,6 @@ object DebugPreferenceManager {
|
||||
fun setGcsFprPercent(value: Double) {
|
||||
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
||||
}
|
||||
|
||||
// No longer storing persistent notification in debug prefs.
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
|
||||
private val _packetRelayEnabled = MutableStateFlow(true)
|
||||
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
|
||||
|
||||
// Visibility of the debug sheet; gates heavy work
|
||||
private val _debugSheetVisible = MutableStateFlow(false)
|
||||
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
|
||||
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
|
||||
|
||||
// Connection limit overrides (debug)
|
||||
private val _maxConnectionsOverall = MutableStateFlow(8)
|
||||
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
|
||||
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
|
||||
|
||||
// Timestamps to compute rolling window stats
|
||||
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
|
||||
// Per-device and per-peer rolling timestamps for stacked graphs
|
||||
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
|
||||
// Additional buckets to split incoming vs outgoing
|
||||
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
|
||||
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
|
||||
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||
|
||||
// Expose current per-second rates (updated when logging/pruning occurs)
|
||||
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
|
||||
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
|
||||
// New flows used by UI for incoming/outgoing stacked plots
|
||||
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
|
||||
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
|
||||
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
|
||||
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
|
||||
|
||||
// Per-minute counts per key
|
||||
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
|
||||
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
|
||||
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
|
||||
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
|
||||
|
||||
// Totals per key (since app start)
|
||||
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
|
||||
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
|
||||
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
|
||||
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
|
||||
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
|
||||
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
|
||||
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
|
||||
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
|
||||
|
||||
// Internal data storage for managing debug data
|
||||
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
|
||||
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
|
||||
|
||||
private fun updateRelayStatsFromTimestamps() {
|
||||
if (!_debugSheetVisible.value) return
|
||||
val now = System.currentTimeMillis()
|
||||
// prune older than 15m
|
||||
while (true) {
|
||||
@@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() {
|
||||
relayTimestamps.poll()
|
||||
} else break
|
||||
}
|
||||
// prune per-device and per-peer and compute 1s/60s rates
|
||||
fun pruneAndCount1s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
val iterator = map.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (key, q) = iterator.next()
|
||||
// prune this queue
|
||||
while (true) {
|
||||
val ts = q.peek() ?: break
|
||||
if (now - ts > 15 * 60 * 1000L) {
|
||||
q.poll()
|
||||
} else break
|
||||
}
|
||||
// count last 1s only
|
||||
val count1s = q.count { now - it <= 1_000L }
|
||||
if (q.isEmpty()) {
|
||||
// cleanup empty queues to prevent unbounded growth
|
||||
iterator.remove()
|
||||
}
|
||||
if (count1s > 0) result[key] = count1s
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun pruneAndCount60s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
map.forEach { (key, q) ->
|
||||
val count60 = q.count { now - it <= 60_000L }
|
||||
if (count60 > 0) result[key] = count60
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps)
|
||||
val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps)
|
||||
|
||||
_perDeviceLastSecond.value = perDevice1s
|
||||
_perPeerLastSecond.value = perPeer1s
|
||||
// Also compute incoming/outgoing per-key rates
|
||||
_perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming)
|
||||
_perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing)
|
||||
_perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming)
|
||||
_perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing)
|
||||
_perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming)
|
||||
_perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing)
|
||||
_perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming)
|
||||
_perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing)
|
||||
val last1s = relayTimestamps.count { now - it <= 1_000L }
|
||||
val last10s = relayTimestamps.count { now - it <= 10_000L }
|
||||
val last1m = relayTimestamps.count { now - it <= 60_000L }
|
||||
val last15m = relayTimestamps.size
|
||||
val total = _relayStats.value.totalRelaysCount + 1
|
||||
// And incoming/outgoing per-second counters
|
||||
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
|
||||
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
|
||||
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
|
||||
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
|
||||
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
|
||||
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
|
||||
val last15mIncoming = incomingTimestamps.size
|
||||
val last15mOutgoing = outgoingTimestamps.size
|
||||
val totalIncoming = _relayStats.value.totalIncomingCount
|
||||
val totalOutgoing = _relayStats.value.totalOutgoingCount
|
||||
_relayStats.value = PacketRelayStats(
|
||||
totalRelaysCount = total,
|
||||
totalRelaysCount = totalIncoming + totalOutgoing,
|
||||
lastSecondRelays = last1s,
|
||||
last10SecondRelays = last10s,
|
||||
lastMinuteRelays = last1m,
|
||||
last15MinuteRelays = last15m,
|
||||
lastResetTime = _relayStats.value.lastResetTime
|
||||
lastResetTime = _relayStats.value.lastResetTime,
|
||||
lastSecondIncoming = last1sIncoming,
|
||||
lastSecondOutgoing = last1sOutgoing,
|
||||
last10SecondIncoming = last10sIncoming,
|
||||
last10SecondOutgoing = last10sOutgoing,
|
||||
lastMinuteIncoming = last1mIncoming,
|
||||
lastMinuteOutgoing = last1mOutgoing,
|
||||
last15MinuteIncoming = last15mIncoming,
|
||||
last15MinuteOutgoing = last15mOutgoing,
|
||||
totalIncomingCount = totalIncoming,
|
||||
totalOutgoingCount = totalOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
@@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update rolling statistics only for relays
|
||||
if (isRelay) {
|
||||
relayTimestamps.offer(System.currentTimeMillis())
|
||||
updateRelayStatsFromTimestamps()
|
||||
// Do not update counters here; this path is for readable logs only.
|
||||
}
|
||||
|
||||
// Explicit incoming/outgoing logging to avoid double counting
|
||||
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = fromNickname ?: fromPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
if (visible) incomingTimestamps.offer(now)
|
||||
fromDeviceAddress?.let {
|
||||
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
|
||||
}
|
||||
fromPeerID?.let {
|
||||
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
|
||||
}
|
||||
// bump totals
|
||||
val cur = _relayStats.value
|
||||
_relayStats.value = cur.copy(
|
||||
totalIncomingCount = cur.totalIncomingCount + 1,
|
||||
totalRelaysCount = cur.totalRelaysCount + 1
|
||||
)
|
||||
if (visible) updateRelayStatsFromTimestamps()
|
||||
}
|
||||
|
||||
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = toNickname ?: toPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
if (visible) outgoingTimestamps.offer(now)
|
||||
toDeviceAddress?.let {
|
||||
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
|
||||
}
|
||||
(toPeerID ?: previousHopPeerID)?.let {
|
||||
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
|
||||
}
|
||||
val cur = _relayStats.value
|
||||
_relayStats.value = cur.copy(
|
||||
totalOutgoingCount = cur.totalOutgoingCount + 1,
|
||||
totalRelaysCount = cur.totalRelaysCount + 1
|
||||
)
|
||||
if (visible) updateRelayStatsFromTimestamps()
|
||||
}
|
||||
|
||||
// MARK: - Clear Data
|
||||
@@ -407,5 +579,15 @@ data class PacketRelayStats(
|
||||
val last10SecondRelays: Int = 0,
|
||||
val lastMinuteRelays: Int = 0,
|
||||
val last15MinuteRelays: Int = 0,
|
||||
val lastResetTime: Date = Date()
|
||||
val lastResetTime: Date = Date(),
|
||||
val lastSecondIncoming: Int = 0,
|
||||
val lastSecondOutgoing: Int = 0,
|
||||
val last10SecondIncoming: Int = 0,
|
||||
val last10SecondOutgoing: Int = 0,
|
||||
val lastMinuteIncoming: Int = 0,
|
||||
val lastMinuteOutgoing: Int = 0,
|
||||
val last15MinuteIncoming: Int = 0,
|
||||
val last15MinuteOutgoing: Int = 0,
|
||||
val totalIncomingCount: Long = 0,
|
||||
val totalOutgoingCount: Long = 0
|
||||
)
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -31,6 +34,9 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bitchat.android.service.MeshServicePreferences
|
||||
import com.bitchat.android.service.MeshForegroundService
|
||||
|
||||
@Composable
|
||||
fun MeshTopologySection() {
|
||||
@@ -113,7 +119,9 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DebugSettingsSheet(
|
||||
isPresented: Boolean,
|
||||
@@ -138,6 +146,8 @@ fun DebugSettingsSheet(
|
||||
val seenCapacity by manager.seenPacketCapacity.collectAsState()
|
||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||
val context = LocalContext.current
|
||||
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||
|
||||
// Push live connected devices from mesh service whenever sheet is visible
|
||||
LaunchedEffect(isPresented) {
|
||||
@@ -174,6 +184,11 @@ fun DebugSettingsSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
// Mark debug sheet visible/invisible to gate heavy work
|
||||
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -292,135 +307,260 @@ fun DebugSettingsSheet(
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||
}
|
||||
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
|
||||
var series by remember { mutableStateOf(List(60) { 0f }) }
|
||||
LaunchedEffect(isPresented) {
|
||||
// Removed aggregate labels; we will show per-direction compact labels below titles
|
||||
// Toggle: overall vs per-connection vs per-peer
|
||||
var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
|
||||
val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
|
||||
val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
|
||||
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
|
||||
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
|
||||
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
|
||||
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
|
||||
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
|
||||
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
|
||||
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
|
||||
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
|
||||
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
|
||||
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
|
||||
val nicknameMap = remember { mutableStateOf<Map<String, String?>>(emptyMap()) }
|
||||
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
LaunchedEffect(Unit) {
|
||||
try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { }
|
||||
// Try to fetch device->peer map periodically for legend resolution
|
||||
while (isPresented) {
|
||||
val s = relayStats.lastSecondRelays.toFloat()
|
||||
val last = series.lastOrNull() ?: 0f
|
||||
// Faster decay and smoothing
|
||||
val v = last * 0.5f + s * 0.5f
|
||||
series = (series + v).takeLast(60)
|
||||
kotlinx.coroutines.delay(400)
|
||||
try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
val maxValRaw = series.maxOrNull() ?: 0f
|
||||
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
||||
val leftGutter = 40.dp
|
||||
Box(Modifier.fillMaxWidth().height(56.dp)) {
|
||||
// Graph canvas
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||
val axisPx = leftGutter.toPx() // reserved left gutter for labels
|
||||
val barCount = series.size
|
||||
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
||||
val w = availW / barCount
|
||||
val h = size.height
|
||||
// Baseline at bottom (y = 0)
|
||||
drawLine(
|
||||
color = Color(0x33888888),
|
||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
||||
strokeWidth = 1f
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Mode selector
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.OVERALL,
|
||||
onClick = { graphMode = GraphMode.OVERALL },
|
||||
label = { Text("Overall") }
|
||||
)
|
||||
// Bars from bottom-up; skip zeros entirely
|
||||
series.forEachIndexed { i, value ->
|
||||
if (value > 0f && maxVal > 0f) {
|
||||
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
||||
val barHeight = (h * ratio).coerceAtLeast(0f)
|
||||
if (barHeight > 0.5f) {
|
||||
drawRect(
|
||||
color = Color(0xFF00C851),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.PER_DEVICE,
|
||||
onClick = { graphMode = GraphMode.PER_DEVICE },
|
||||
label = { Text("Per Device") },
|
||||
leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.PER_PEER,
|
||||
onClick = { graphMode = GraphMode.PER_PEER },
|
||||
label = { Text("Per Peer") },
|
||||
leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
// Time series state
|
||||
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<String>()) }
|
||||
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
|
||||
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
|
||||
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
|
||||
var highlightedKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
// Color palette for stacked legend
|
||||
val palette = remember {
|
||||
listOf(
|
||||
Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30),
|
||||
Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759),
|
||||
Color(0xFFFFCC00), Color(0xFF5856D6)
|
||||
)
|
||||
}
|
||||
val colorForKey = remember { mutableStateMapOf<String, Color>() }
|
||||
fun stableColorFor(key: String): Color {
|
||||
// Deterministic fallback color based on key hash using HSV palette
|
||||
val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360
|
||||
return Color.hsv(h.toFloat(), 0.65f, 0.95f)
|
||||
}
|
||||
// Ensure colors are assigned for current keys before drawing
|
||||
fun ensureColors(keys: List<String>) {
|
||||
keys.forEachIndexed { idx, k ->
|
||||
colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k))
|
||||
}
|
||||
}
|
||||
// Y-axis ticks (min/max) in the left margin
|
||||
Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp))
|
||||
Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp))
|
||||
// Y-axis unit label (vertical)
|
||||
Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MeshTopologySection() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val graphService = remember { MeshGraphService.getInstance() }
|
||||
val snapshot by graphService.graphState.collectAsState()
|
||||
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
|
||||
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
val nodes = snapshot.nodes
|
||||
val edges = snapshot.edges
|
||||
val empty = nodes.isEmpty()
|
||||
if (empty) {
|
||||
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val cx = w / 2f
|
||||
val cy = h / 2f
|
||||
val radius = (minOf(w, h) * 0.36f)
|
||||
val n = nodes.size
|
||||
if (n == 1) {
|
||||
// Single node centered
|
||||
drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy))
|
||||
} else {
|
||||
// Circular layout
|
||||
val positions = nodes.mapIndexed { i, node ->
|
||||
val angle = (2 * Math.PI * i.toDouble()) / n
|
||||
val x = cx + (radius * Math.cos(angle)).toFloat()
|
||||
val y = cy + (radius * Math.sin(angle)).toFloat()
|
||||
node.peerID to androidx.compose.ui.geometry.Offset(x, y)
|
||||
}.toMap()
|
||||
|
||||
// Draw edges
|
||||
edges.forEach { e ->
|
||||
val p1 = positions[e.a]
|
||||
val p2 = positions[e.b]
|
||||
if (p1 != null && p2 != null) {
|
||||
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
|
||||
LaunchedEffect(isPresented, graphMode) {
|
||||
while (isPresented) {
|
||||
when (graphMode) {
|
||||
GraphMode.OVERALL -> {
|
||||
val sIn = relayStats.lastSecondIncoming.toFloat()
|
||||
val sOut = relayStats.lastSecondOutgoing.toFloat()
|
||||
overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60)
|
||||
overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60)
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val snapshotIn = perDeviceIncoming
|
||||
val snapshotOut = perDeviceOutgoing
|
||||
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
|
||||
val next = mutableMapOf<String, List<Float>>()
|
||||
val union = (base.keys + snap.keys).toSet()
|
||||
union.forEach { k ->
|
||||
val prev = base[k] ?: List(60) { 0f }
|
||||
val s = (snap[k] ?: 0).toFloat()
|
||||
next[k] = (prev + s).takeLast(60)
|
||||
}
|
||||
return next
|
||||
}
|
||||
// Advance and prune fully-stale series (all-zero in visible window)
|
||||
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
|
||||
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
|
||||
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
|
||||
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
|
||||
}
|
||||
GraphMode.PER_PEER -> {
|
||||
val snapshotIn = perPeerIncoming
|
||||
val snapshotOut = perPeerOutgoing
|
||||
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
|
||||
val next = mutableMapOf<String, List<Float>>()
|
||||
val union = (base.keys + snap.keys).toSet()
|
||||
union.forEach { k ->
|
||||
val prev = base[k] ?: List(60) { 0f }
|
||||
val s = (snap[k] ?: 0).toFloat()
|
||||
next[k] = (prev + s).takeLast(60)
|
||||
}
|
||||
return next
|
||||
}
|
||||
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
|
||||
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
|
||||
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
|
||||
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
|
||||
}
|
||||
}
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions moved to top-level composable below to avoid scope issues
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach { node ->
|
||||
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy)
|
||||
drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos)
|
||||
// Render two blocks: Incoming and Outgoing
|
||||
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Incoming",
|
||||
stackedKeys = stackedKeysIncoming,
|
||||
stackedSeries = stackedSeriesIncoming,
|
||||
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
|
||||
graphMode = graphMode,
|
||||
highlightedKey = highlightedKey,
|
||||
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
|
||||
ensureColors = { keys -> ensureColors(keys) },
|
||||
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
|
||||
legendTitleFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val nick = nicknameMap.value[key]
|
||||
val prefix = key.take(6)
|
||||
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val device = key
|
||||
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
|
||||
?: devicePeerMap.value[device]
|
||||
if (pid != null) {
|
||||
val nick = nicknameMap.value[pid]
|
||||
val prefix = pid.take(6)
|
||||
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
|
||||
} else device
|
||||
}
|
||||
else -> key
|
||||
}
|
||||
},
|
||||
legendMetricsFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val s = perPeerIncoming[key] ?: 0
|
||||
val m = perPeerIncoming1m[key] ?: 0
|
||||
val t = (perPeerIncomingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val s = perDeviceIncoming[key] ?: 0
|
||||
val m = perDeviceIncoming1m[key] ?: 0
|
||||
val t = (perDeviceIncomingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
)
|
||||
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Outgoing",
|
||||
stackedKeys = stackedKeysOutgoing,
|
||||
stackedSeries = stackedSeriesOutgoing,
|
||||
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null,
|
||||
graphMode = graphMode,
|
||||
highlightedKey = highlightedKey,
|
||||
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
|
||||
ensureColors = { keys -> ensureColors(keys) },
|
||||
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
|
||||
legendTitleFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val nick = nicknameMap.value[key]
|
||||
val prefix = key.take(6)
|
||||
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val device = key
|
||||
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
|
||||
?: devicePeerMap.value[device]
|
||||
if (pid != null) {
|
||||
val nick = nicknameMap.value[pid]
|
||||
val prefix = pid.take(6)
|
||||
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
|
||||
} else device
|
||||
}
|
||||
else -> key
|
||||
}
|
||||
},
|
||||
legendMetricsFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val s = perPeerOutgoing[key] ?: 0
|
||||
val m = perPeerOutgoing1m[key] ?: 0
|
||||
val t = (perPeerOutgoingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val s = perDeviceOutgoing[key] ?: 0
|
||||
val m = perDeviceOutgoing1m[key] ?: 0
|
||||
val t = (perDeviceOutgoingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
)
|
||||
if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Label list for clarity under the canvas
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
|
||||
items(nodes) { node ->
|
||||
val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}"
|
||||
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
@@ -443,9 +583,6 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
@@ -532,3 +669,146 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawGraphBlock(
|
||||
title: String,
|
||||
stackedKeys: List<String>,
|
||||
stackedSeries: Map<String, List<Float>>,
|
||||
overallSeries: List<Float>?,
|
||||
graphMode: GraphMode,
|
||||
highlightedKey: String?,
|
||||
onToggleHighlight: (String) -> Unit,
|
||||
ensureColors: (List<String>) -> Unit,
|
||||
colorForKey: (String) -> Color,
|
||||
legendTitleFor: (String) -> String,
|
||||
legendMetricsFor: (String) -> String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val leftGutter = 40.dp
|
||||
Box(Modifier.fillMaxWidth().height(56.dp)) {
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||
val axisPx = leftGutter.toPx()
|
||||
val barCount = 60
|
||||
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
||||
val w = availW / barCount
|
||||
val h = size.height
|
||||
drawLine(
|
||||
color = Color(0x33888888),
|
||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
||||
strokeWidth = 1f
|
||||
)
|
||||
|
||||
when (graphMode) {
|
||||
GraphMode.OVERALL -> {
|
||||
val maxValRaw = (overallSeries?.maxOrNull() ?: 0f)
|
||||
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
||||
(overallSeries ?: emptyList()).forEachIndexed { i, value ->
|
||||
if (value > 0f && maxVal > 0f) {
|
||||
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
||||
val barHeight = (h * ratio).coerceAtLeast(0f)
|
||||
if (barHeight > 0.5f) {
|
||||
drawRect(
|
||||
color = Color(0xFF00C851),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val indices = 0 until 60
|
||||
val totals = indices.map { idx ->
|
||||
stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat()
|
||||
}
|
||||
val maxTotal = (totals.maxOrNull() ?: 0f)
|
||||
val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
|
||||
indices.forEach { i ->
|
||||
var yTop = h
|
||||
if (maxTotal > 0f) {
|
||||
ensureColors(drawKeysBars)
|
||||
drawKeysBars.forEach { k ->
|
||||
val v = stackedSeries[k]?.getOrNull(i) ?: 0f
|
||||
if (v > 0f) {
|
||||
val ratio = (v / maxTotal).coerceIn(0f, 1f)
|
||||
val segH = (h * ratio)
|
||||
if (segH > 0.5f) {
|
||||
val top = (yTop - segH)
|
||||
val baseColor = colorForKey(k)
|
||||
val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f)
|
||||
drawRect(
|
||||
color = c,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top),
|
||||
size = androidx.compose.ui.geometry.Size(w, segH)
|
||||
)
|
||||
yTop = top
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.width(leftGutter).fillMaxHeight()) {
|
||||
Text(
|
||||
"p/s",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
|
||||
)
|
||||
val topLabel = when (graphMode) {
|
||||
GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString()
|
||||
else -> {
|
||||
val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() }
|
||||
(totals.maxOrNull() ?: 0f).toInt().toString()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
topLabel,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
|
||||
)
|
||||
Text(
|
||||
"0",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
|
||||
if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
drawKeys.forEach { key ->
|
||||
val baseColor = colorForKey(key)
|
||||
val dimmed = highlightedKey != null && highlightedKey != key
|
||||
val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.clickable { onToggleHighlight(key) }
|
||||
) {
|
||||
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
|
||||
Column {
|
||||
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
|
||||
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user