QR and Verification feature (#529)

* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* feat: Add ZXing dependency for QR code scanning

* feat: Request camera permission for QR verification

* Add QR verification payloads and mesh wiring

* Wire verification state, system messages, and notifications

* Add verification sheets and UI affordances

* Show verified badges in sidebar and add strings

* Persist fingerprint caches for offline verification

* Handle bitchat://verify deep links

* feat: Replace zxing-android-embedded with ML Kit and CameraX

* Refactor(Verification): Replace zxing with MLKit for QR scanning

* Replace `AndroidView` with `CameraXViewfinder` for camera preview

* Refactor QR verification: Extract VerificationHandler and fix concurrency issues

* Extract and translate strings for QR verification feature

* Fix build errors: Escape ampersands in strings and restore missing methods in ChatViewModel

* return to main

* return to main 2

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
yet300
2026-01-04 16:29:07 +07:00
committed by GitHub
co-authored by GitHub Action callebtc
parent d73976537d
commit c663e8ede0
56 changed files with 3421 additions and 58 deletions
@@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Warning
@@ -681,6 +682,27 @@ fun AboutSheet(
}
}
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(32.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(18.dp)
)
}
}
/**
* Password prompt dialog for password-protected channels
* Kept as dialog since it requires user input
@@ -320,6 +320,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 {
@@ -417,17 +421,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)
)
}
@@ -58,6 +58,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
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) }
@@ -322,6 +324,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() },
onShowVerification = {
viewModel.showVerificationSheet(fromSidebar = true)
viewModel.hideSidebar()
},
modifier = Modifier.fillMaxSize()
)
}
@@ -368,7 +374,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
showSecurityVerificationSheet = showSecurityVerificationSheet,
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
)
}
@@ -516,7 +526,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(
@@ -570,4 +584,20 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
if (showVerificationSheet) {
VerificationSheet(
isPresented = showVerificationSheet,
onDismiss = onVerificationSheetDismiss,
viewModel = viewModel
)
}
if (showSecurityVerificationSheet) {
SecurityVerificationSheet(
isPresented = showSecurityVerificationSheet,
onDismiss = onSecurityVerificationSheetDismiss,
viewModel = viewModel
)
}
}
@@ -122,6 +122,12 @@ class ChatState(
// Navigation state
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 = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
@@ -302,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
@@ -5,11 +5,15 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
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,6 +57,20 @@ 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(
scope = viewModelScope,
@@ -57,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)
@@ -75,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)
@@ -134,6 +171,8 @@ class ChatViewModel(
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
@@ -247,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 {
@@ -646,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)
@@ -660,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
@@ -668,41 +750,75 @@ 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) {
@@ -744,6 +860,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)
@@ -827,7 +951,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 {
@@ -840,7 +964,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) { }
@@ -969,5 +1093,6 @@ class ChatViewModel(
*/
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
}
}
}
@@ -216,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)
@@ -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 {
@@ -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()
}
@@ -23,6 +23,7 @@ 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,6 +35,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
fun SidebarOverlay(
viewModel: ChatViewModel,
onDismiss: () -> Unit,
onShowVerification: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
@@ -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()) {
@@ -295,6 +312,7 @@ fun PeopleSection(
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]
@@ -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() }
}
}