bitchat-android/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt
2026-07-29 19:32:28 +02:00

446 lines
16 KiB
Kotlin

package com.bitchat.android.ui
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.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.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.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.services.ContactDirectory
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 peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val accent = colorScheme.primary
val boxColor = colorScheme.surfaceVariant
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
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 = BitchatFontFamily),
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 activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID
val sessionState = resolveConversationSessionState(
conversationID = selectedPeerID,
activeMeshPeerID = activeMeshPeerID,
peerSessionStates = peerSessionStates
)
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.initiateMeshHandshake(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 = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
val dismiss = LocalSheetDismiss.current
CloseButton(onClick = { dismiss?.invoke() ?: 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 = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
)
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
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 = BitchatFontFamily,
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 = BitchatFontFamily
),
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 = BitchatFontFamily,
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 = BitchatFontFamily
),
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 = BitchatFontFamily,
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 = BitchatFontFamily,
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 = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
)
if (fingerprint != null) {
Column {
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily,
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 = BitchatFontFamily),
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()
}