mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 07:25:20 +00:00
Add verification sheets and UI affordances
This commit is contained in:
@@ -318,6 +318,10 @@ private fun PrivateChatHeader(
|
|||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
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)
|
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||||
try {
|
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
|
// 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 showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||||
|
val securityModifier = if (!isNostrDM) {
|
||||||
|
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
if (showGlobe) {
|
if (showGlobe) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Public,
|
imageVector = Icons.Outlined.Public,
|
||||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
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
|
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
NoiseSessionIcon(
|
NoiseSessionIcon(
|
||||||
sessionState = sessionState,
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val showAppInfo by viewModel.showAppInfo.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 messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||||
@@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
SidebarOverlay(
|
SidebarOverlay(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onDismiss = { viewModel.hideSidebar() },
|
onDismiss = { viewModel.hideSidebar() },
|
||||||
|
onShowVerification = {
|
||||||
|
viewModel.showVerificationSheet(fromSidebar = true)
|
||||||
|
viewModel.hideSidebar()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
},
|
},
|
||||||
selectedUserForSheet = selectedUserForSheet,
|
selectedUserForSheet = selectedUserForSheet,
|
||||||
selectedMessageForSheet = selectedMessageForSheet,
|
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,
|
onUserSheetDismiss: () -> Unit,
|
||||||
selectedUserForSheet: String,
|
selectedUserForSheet: String,
|
||||||
selectedMessageForSheet: BitchatMessage?,
|
selectedMessageForSheet: BitchatMessage?,
|
||||||
viewModel: ChatViewModel
|
viewModel: ChatViewModel,
|
||||||
|
showVerificationSheet: Boolean,
|
||||||
|
onVerificationSheetDismiss: () -> Unit,
|
||||||
|
showSecurityVerificationSheet: Boolean,
|
||||||
|
onSecurityVerificationSheetDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
// Password dialog
|
// Password dialog
|
||||||
PasswordPromptDialog(
|
PasswordPromptDialog(
|
||||||
@@ -567,4 +581,20 @@ private fun ChatDialogs(
|
|||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showVerificationSheet) {
|
||||||
|
VerificationSheet(
|
||||||
|
isPresented = showVerificationSheet,
|
||||||
|
onDismiss = onVerificationSheetDismiss,
|
||||||
|
viewModel = viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSecurityVerificationSheet) {
|
||||||
|
SecurityVerificationSheet(
|
||||||
|
isPresented = showSecurityVerificationSheet,
|
||||||
|
onDismiss = onSecurityVerificationSheetDismiss,
|
||||||
|
viewModel = viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
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.height
|
||||||
|
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.LaunchedEffect
|
||||||
|
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.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.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.set
|
||||||
|
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.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.journeyapps.barcodescanner.BarcodeCallback
|
||||||
|
import com.journeyapps.barcodescanner.BarcodeResult
|
||||||
|
import com.journeyapps.barcodescanner.DecoratedBarcodeView
|
||||||
|
import com.journeyapps.barcodescanner.DefaultDecoderFactory
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
VerificationScanner(
|
||||||
|
accent = accent,
|
||||||
|
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 VerificationScanner(
|
||||||
|
accent: Color,
|
||||||
|
onScan: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.verify_scan_prompt_friend),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = accent,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
QRScannerPanel(onCode = onScan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
onCode: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||||
|
val context = LocalContext.current
|
||||||
|
var lastValid by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val onCodeState = rememberUpdatedState(onCode)
|
||||||
|
val barcodeCallback = remember {
|
||||||
|
object : BarcodeCallback {
|
||||||
|
override fun barcodeResult(result: BarcodeResult?) {
|
||||||
|
val text = result?.text ?: return
|
||||||
|
if (text == lastValid) return
|
||||||
|
lastValid = text
|
||||||
|
onCodeState.value(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val barcodeView = remember {
|
||||||
|
DecoratedBarcodeView(context).apply {
|
||||||
|
barcodeView.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE))
|
||||||
|
setStatusText("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
barcodeView.decodeContinuous(barcodeCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(permissionState.status.isGranted) {
|
||||||
|
if (permissionState.status.isGranted) {
|
||||||
|
barcodeView.resume()
|
||||||
|
} else {
|
||||||
|
barcodeView.pause()
|
||||||
|
}
|
||||||
|
onDispose { barcodeView.pause() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (permissionState.status.isGranted) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { barcodeView },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(260.dp)
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user