feat: device verification
This commit is contained in:
@@ -64,8 +64,16 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.connect2x.trixnity.client.MatrixClient
|
||||
import de.connect2x.trixnity.client.key
|
||||
import de.connect2x.trixnity.client.store.KeySignatureTrustLevel
|
||||
import de.connect2x.trixnity.client.store.KeyStore
|
||||
import de.connect2x.trixnity.client.verification
|
||||
import de.connect2x.trixnity.client.verification.ActiveDeviceVerification
|
||||
import de.connect2x.trixnity.client.verification.ActiveSasVerificationMethod
|
||||
import de.connect2x.trixnity.client.verification.ActiveSasVerificationState
|
||||
import de.connect2x.trixnity.client.verification.ActiveVerificationState
|
||||
import de.connect2x.trixnity.client.verification.SelfVerificationMethod
|
||||
import de.connect2x.trixnity.client.verification.VerificationService
|
||||
import de.connect2x.trixnity.clientserverapi.client.SyncState
|
||||
import de.connect2x.trixnity.clientserverapi.client.UIA
|
||||
import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
|
||||
@@ -75,6 +83,7 @@ import de.connect2x.trixnity.clientserverapi.model.uia.AuthenticationRequest
|
||||
import de.connect2x.trixnity.clientserverapi.model.user.ProfileField
|
||||
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
||||
import de.connect2x.trixnity.clientserverapi.model.user.displayName
|
||||
import de.connect2x.trixnity.core.model.events.m.key.verification.VerificationMethod
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -452,6 +461,13 @@ fun AccountSettingsScreen(modifier: Modifier = Modifier) {
|
||||
fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
val matrixClient = client!!
|
||||
val syncState by matrixClient.api.sync.currentSyncState.collectAsState(initial = SyncState.STOPPED)
|
||||
val selfVerificationMethodsFlow = remember(matrixClient) {
|
||||
matrixClient.verification.getSelfVerificationMethods()
|
||||
}
|
||||
val selfVerificationMethods by selfVerificationMethodsFlow.collectAsState(
|
||||
initial = VerificationService.SelfVerificationMethods.PreconditionsNotMet(emptySet()),
|
||||
)
|
||||
val activeDeviceVerification by matrixClient.verification.activeDeviceVerification.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase() }
|
||||
val deviceName = listOf(manufacturer, Build.MODEL)
|
||||
@@ -461,8 +477,18 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
var sessions by remember { mutableStateOf<List<MatrixSession>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isClosingDeviceId by remember { mutableStateOf<String?>(null) }
|
||||
var isStartingVerificationDeviceId by remember { mutableStateOf<String?>(null) }
|
||||
var isStartingSelfVerification by remember { mutableStateOf(false) }
|
||||
var isBootstrapping by remember { mutableStateOf(false) }
|
||||
var isAuthenticating by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var pendingAuthentication by remember { mutableStateOf<PendingAuthentication?>(null) }
|
||||
var bootstrapDialogOpen by remember { mutableStateOf(false) }
|
||||
var recoveryKeyToShow by remember { mutableStateOf<String?>(null) }
|
||||
var recoveryKeyMethod by remember { mutableStateOf<SelfVerificationMethod.AesHmacSha2RecoveryKey?>(null) }
|
||||
var recoveryPassphraseMethod by remember {
|
||||
mutableStateOf<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase?>(null)
|
||||
}
|
||||
|
||||
suspend fun loadSessions() {
|
||||
isLoading = true
|
||||
@@ -477,6 +503,108 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
fun completeBootstrap(recoveryKey: String) {
|
||||
recoveryKeyToShow = recoveryKey
|
||||
message = "Cross-signing is set up. Save the recovery key now — PixelDragon will not show it again."
|
||||
scope.launch { loadSessions() }
|
||||
}
|
||||
|
||||
fun bootstrapCrossSigning(passphrase: String?) {
|
||||
scope.launch {
|
||||
isBootstrapping = true
|
||||
message = null
|
||||
val bootstrap = runCatching {
|
||||
if (passphrase.isNullOrBlank()) {
|
||||
matrixClient.key.bootstrapCrossSigning()
|
||||
} else {
|
||||
matrixClient.key.bootstrapCrossSigningFromPassphrase(passphrase)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
message = error.toSettingsError()
|
||||
isBootstrapping = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
bootstrap.result.onSuccess { uia ->
|
||||
uia.handleUia(
|
||||
onNeedsAuthentication = { step ->
|
||||
pendingAuthentication = PendingAuthentication(
|
||||
title = "Set up encryption recovery",
|
||||
description = "Enter your current password to upload cross-signing keys and verify this session.",
|
||||
uia = step,
|
||||
onSuccess = { completeBootstrap(bootstrap.recoveryKey) },
|
||||
)
|
||||
message = "Password confirmation is required to set up cross-signing."
|
||||
},
|
||||
onSuccess = { completeBootstrap(bootstrap.recoveryKey) },
|
||||
)
|
||||
}.onFailure { error ->
|
||||
message = error.toSettingsError()
|
||||
}
|
||||
isBootstrapping = false
|
||||
}
|
||||
}
|
||||
|
||||
fun startSelfVerification(method: SelfVerificationMethod.CrossSignedDeviceVerification) {
|
||||
scope.launch {
|
||||
isStartingSelfVerification = true
|
||||
message = null
|
||||
method.createDeviceVerification()
|
||||
.onSuccess { message = "Verification request sent to your verified session." }
|
||||
.onFailure { message = it.toSettingsError() }
|
||||
isStartingSelfVerification = false
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyWithRecoveryKey(method: SelfVerificationMethod.AesHmacSha2RecoveryKey, recoveryKey: String) {
|
||||
scope.launch {
|
||||
isStartingSelfVerification = true
|
||||
message = null
|
||||
method.verify(recoveryKey)
|
||||
.onSuccess {
|
||||
recoveryKeyMethod = null
|
||||
message = "This session is verified."
|
||||
loadSessions()
|
||||
}
|
||||
.onFailure { message = it.toSettingsError() }
|
||||
isStartingSelfVerification = false
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyWithRecoveryPassphrase(
|
||||
method: SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase,
|
||||
passphrase: String,
|
||||
) {
|
||||
scope.launch {
|
||||
isStartingSelfVerification = true
|
||||
message = null
|
||||
method.verify(passphrase)
|
||||
.onSuccess {
|
||||
recoveryPassphraseMethod = null
|
||||
message = "This session is verified."
|
||||
loadSessions()
|
||||
}
|
||||
.onFailure { message = it.toSettingsError() }
|
||||
isStartingSelfVerification = false
|
||||
}
|
||||
}
|
||||
|
||||
fun startDeviceVerification(device: Device) {
|
||||
scope.launch {
|
||||
isStartingVerificationDeviceId = device.deviceId
|
||||
message = null
|
||||
matrixClient.verification.createDeviceVerificationRequest(
|
||||
theirUserId = matrixClient.userId,
|
||||
theirDeviceIds = setOf(device.deviceId),
|
||||
).onSuccess {
|
||||
message = "Verification request sent to ${device.displayName ?: device.deviceId}."
|
||||
}.onFailure {
|
||||
message = it.toSettingsError()
|
||||
}
|
||||
isStartingVerificationDeviceId = null
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSession(device: Device) {
|
||||
scope.launch {
|
||||
isClosingDeviceId = device.deviceId
|
||||
@@ -518,14 +646,12 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
item {
|
||||
SettingsHeader(
|
||||
title = "Devices",
|
||||
subtitle = "All Matrix sessions connected to your account.",
|
||||
subtitle = "Verify sessions so encrypted messages can be sent safely.",
|
||||
)
|
||||
}
|
||||
|
||||
message?.let { status ->
|
||||
item {
|
||||
SettingsStatusMessage(status)
|
||||
}
|
||||
item { SettingsStatusMessage(status) }
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -540,6 +666,16 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
value = matrixClient.deviceId,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsInfoRow(
|
||||
title = "Identity key",
|
||||
value = matrixClient.identityKey.value.value,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsInfoRow(
|
||||
title = "Signing key",
|
||||
value = matrixClient.signingKey.value.value,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsInfoRow(
|
||||
title = "Android",
|
||||
value = "${Build.VERSION.RELEASE} · API ${Build.VERSION.SDK_INT}",
|
||||
@@ -552,6 +688,90 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsSection(title = "Self-verification") {
|
||||
SettingsInfoRow(
|
||||
title = "Status",
|
||||
value = selfVerificationMethods.toSelfVerificationStatus(),
|
||||
)
|
||||
SettingsDivider()
|
||||
when (val methods = selfVerificationMethods) {
|
||||
is VerificationService.SelfVerificationMethods.PreconditionsNotMet -> {
|
||||
SettingsInfoRow(
|
||||
title = "Waiting for",
|
||||
value = methods.reasons.joinToString { it.toSelfVerificationReason() }
|
||||
.ifBlank { "Initial sync" },
|
||||
)
|
||||
}
|
||||
|
||||
VerificationService.SelfVerificationMethods.NoCrossSigningEnabled -> {
|
||||
SettingsActionRow(
|
||||
title = "Set up cross-signing",
|
||||
value = "Create recovery key, key backup and verify this session",
|
||||
actionLabel = "Set up",
|
||||
enabled = !isBootstrapping,
|
||||
onClick = { bootstrapDialogOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
VerificationService.SelfVerificationMethods.AlreadyCrossSigned -> {
|
||||
SettingsInfoRow(
|
||||
title = "This session",
|
||||
value = "Cross-signed and verified",
|
||||
)
|
||||
}
|
||||
|
||||
is VerificationService.SelfVerificationMethods.CrossSigningEnabled -> {
|
||||
if (methods.methods.isEmpty()) {
|
||||
SettingsActionRow(
|
||||
title = "Reset cross-signing",
|
||||
value = "No verified session or recovery method is available",
|
||||
actionLabel = "Set up",
|
||||
enabled = !isBootstrapping,
|
||||
destructive = true,
|
||||
onClick = { bootstrapDialogOpen = true },
|
||||
)
|
||||
} else {
|
||||
methods.methods.forEachIndexed { index, method ->
|
||||
if (index > 0) SettingsDivider()
|
||||
when (method) {
|
||||
is SelfVerificationMethod.CrossSignedDeviceVerification -> {
|
||||
SettingsActionRow(
|
||||
title = "Verify from another session",
|
||||
value = "Sends SAS/emoji request to your already verified devices",
|
||||
actionLabel = "Start",
|
||||
enabled = !isStartingSelfVerification,
|
||||
onClick = { startSelfVerification(method) },
|
||||
)
|
||||
}
|
||||
|
||||
is SelfVerificationMethod.AesHmacSha2RecoveryKey -> {
|
||||
SettingsActionRow(
|
||||
title = "Verify with recovery key",
|
||||
value = "Use your Matrix recovery key",
|
||||
actionLabel = "Enter",
|
||||
enabled = !isStartingSelfVerification,
|
||||
onClick = { recoveryKeyMethod = method },
|
||||
)
|
||||
}
|
||||
|
||||
is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase -> {
|
||||
SettingsActionRow(
|
||||
title = "Verify with recovery passphrase",
|
||||
value = "Use your Matrix security phrase",
|
||||
actionLabel = "Enter",
|
||||
enabled = !isStartingSelfVerification,
|
||||
onClick = { recoveryPassphraseMethod = method },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -567,9 +787,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
)
|
||||
TextButton(
|
||||
enabled = !isLoading,
|
||||
onClick = {
|
||||
scope.launch { loadSessions() }
|
||||
},
|
||||
onClick = { scope.launch { loadSessions() } },
|
||||
) {
|
||||
Text("Refresh")
|
||||
}
|
||||
@@ -591,9 +809,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
sessions.isEmpty() -> {
|
||||
item {
|
||||
SettingsStatusMessage("No active sessions returned by the homeserver")
|
||||
}
|
||||
item { SettingsStatusMessage("No active sessions returned by the homeserver") }
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -605,24 +821,80 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
session = session,
|
||||
currentDeviceId = matrixClient.deviceId,
|
||||
isClosing = isClosingDeviceId == session.device.deviceId,
|
||||
isStartingVerification = isStartingVerificationDeviceId == session.device.deviceId,
|
||||
onClose = { closeSession(session.device) },
|
||||
onVerify = { startDeviceVerification(session.device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeDeviceVerification?.let { verification ->
|
||||
DeviceVerificationDialog(
|
||||
verification = verification,
|
||||
onDismiss = {
|
||||
scope.launch { verification.cancel() }
|
||||
},
|
||||
onDone = {
|
||||
scope.launch { loadSessions() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (bootstrapDialogOpen) {
|
||||
BootstrapCrossSigningDialog(
|
||||
isBusy = isBootstrapping,
|
||||
onDismiss = { bootstrapDialogOpen = false },
|
||||
onBootstrap = { passphrase ->
|
||||
bootstrapDialogOpen = false
|
||||
bootstrapCrossSigning(passphrase)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
recoveryKeyToShow?.let { recoveryKey ->
|
||||
RecoveryKeyResultDialog(
|
||||
recoveryKey = recoveryKey,
|
||||
onDismiss = { recoveryKeyToShow = null },
|
||||
)
|
||||
}
|
||||
|
||||
recoveryKeyMethod?.let { method ->
|
||||
RecoverySecretDialog(
|
||||
title = "Verify with recovery key",
|
||||
label = "Recovery key",
|
||||
isPassword = false,
|
||||
isBusy = isStartingSelfVerification,
|
||||
onDismiss = { recoveryKeyMethod = null },
|
||||
onConfirm = { recoveryKey -> verifyWithRecoveryKey(method, recoveryKey) },
|
||||
)
|
||||
}
|
||||
|
||||
recoveryPassphraseMethod?.let { method ->
|
||||
RecoverySecretDialog(
|
||||
title = "Verify with recovery passphrase",
|
||||
label = "Security phrase",
|
||||
isPassword = true,
|
||||
isBusy = isStartingSelfVerification,
|
||||
onDismiss = { recoveryPassphraseMethod = null },
|
||||
onConfirm = { passphrase -> verifyWithRecoveryPassphrase(method, passphrase) },
|
||||
)
|
||||
}
|
||||
|
||||
pendingAuthentication?.let { pending ->
|
||||
PasswordAuthenticationDialog(
|
||||
title = pending.title,
|
||||
description = pending.description,
|
||||
isBusy = isClosingDeviceId != null,
|
||||
isBusy = isAuthenticating,
|
||||
onDismiss = {
|
||||
pendingAuthentication = null
|
||||
isClosingDeviceId = null
|
||||
isAuthenticating = false
|
||||
},
|
||||
onConfirm = { password ->
|
||||
scope.launch {
|
||||
isAuthenticating = true
|
||||
val authRequest = AuthenticationRequest.Password(
|
||||
identifier = IdentifierType.User(matrixClient.userId.toString()),
|
||||
password = password,
|
||||
@@ -646,6 +918,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||
}.onFailure {
|
||||
message = it.toSettingsError()
|
||||
}
|
||||
isAuthenticating = false
|
||||
isClosingDeviceId = null
|
||||
}
|
||||
},
|
||||
@@ -1123,10 +1396,13 @@ private fun SessionCard(
|
||||
session: MatrixSession,
|
||||
currentDeviceId: String,
|
||||
isClosing: Boolean,
|
||||
isStartingVerification: Boolean,
|
||||
onClose: () -> Unit,
|
||||
onVerify: () -> Unit,
|
||||
) {
|
||||
val device = session.device
|
||||
val isCurrent = device.deviceId == currentDeviceId
|
||||
val verified = session.verification.isVerified()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -1192,18 +1468,384 @@ private fun SessionCard(
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
enabled = !isCurrent && !isClosing,
|
||||
onClick = onClose,
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isClosing) {
|
||||
OutlinedButton(
|
||||
enabled = !isCurrent && !verified && !isStartingVerification,
|
||||
onClick = onVerify,
|
||||
) {
|
||||
if (isStartingVerification) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (verified) "Verified" else "Verify")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
enabled = !isCurrent && !isClosing,
|
||||
onClick = onClose,
|
||||
) {
|
||||
if (isClosing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (isCurrent) "Current session" else "Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BootstrapCrossSigningDialog(
|
||||
isBusy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onBootstrap: (String?) -> Unit,
|
||||
) {
|
||||
var usePassphrase by remember { mutableStateOf(false) }
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Set up encryption recovery") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "This creates cross-signing keys, verifies this session, and creates a Matrix recovery key. Save the recovery key somewhere safe.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = usePassphrase,
|
||||
onCheckedChange = { usePassphrase = it },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Protect recovery with a passphrase")
|
||||
}
|
||||
if (usePassphrase) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Security phrase") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = !isBusy && (!usePassphrase || passphrase.isNotBlank()),
|
||||
onClick = { onBootstrap(passphrase.takeIf { usePassphrase }) },
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (isCurrent) "Current session" else "Close session")
|
||||
Text("Set up")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
enabled = !isBusy,
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecoveryKeyResultDialog(
|
||||
recoveryKey: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Save your recovery key") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "You will need this key to verify future sessions and restore encrypted message keys.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
text = recoveryKey,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text("I saved it")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecoverySecretDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
isPassword: Boolean,
|
||||
isBusy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var value by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
keyboardOptions = if (isPassword) KeyboardOptions(keyboardType = KeyboardType.Password) else KeyboardOptions.Default,
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = value.isNotBlank() && !isBusy,
|
||||
onClick = { onConfirm(value) },
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text("Verify")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
enabled = !isBusy,
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceVerificationDialog(
|
||||
verification: ActiveDeviceVerification,
|
||||
onDismiss: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by verification.state.collectAsState()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state is ActiveVerificationState.Done) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Verify session") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SettingsTinyInfoRow(
|
||||
title = "User",
|
||||
value = verification.theirUserId.toString(),
|
||||
)
|
||||
SettingsTinyInfoRow(
|
||||
title = "Device",
|
||||
value = verification.theirDeviceId ?: "Waiting for device",
|
||||
)
|
||||
Text(
|
||||
text = state.toVerificationStateDescription(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
when (val currentState = state) {
|
||||
is ActiveVerificationState.Ready -> {
|
||||
SettingsStatusMessage(
|
||||
message = "Both devices accepted the request. Start SAS verification and compare emojis on both devices.",
|
||||
)
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Start -> {
|
||||
val sas = currentState.method as? ActiveSasVerificationMethod
|
||||
if (sas != null) {
|
||||
SasVerificationContent(sas)
|
||||
} else {
|
||||
SettingsStatusMessage("Unsupported verification method")
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Cancel -> {
|
||||
SettingsStatusMessage("Cancelled: ${currentState.content.reason}")
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
VerificationPrimaryAction(
|
||||
state = state,
|
||||
onClose = onDismiss,
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch { verification.cancel() }
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(if (state is ActiveVerificationState.Done) "Close" else "Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationPrimaryAction(
|
||||
state: ActiveVerificationState,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
when (state) {
|
||||
is ActiveVerificationState.TheirRequest -> {
|
||||
Button(onClick = { scope.launch { state.ready() } }) {
|
||||
Text("Accept")
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Ready -> {
|
||||
Button(
|
||||
enabled = state.methods.contains(VerificationMethod.Sas),
|
||||
onClick = { scope.launch { state.start(VerificationMethod.Sas) } },
|
||||
) {
|
||||
Text("Start SAS")
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Start -> {
|
||||
val sas = state.method as? ActiveSasVerificationMethod
|
||||
if (sas == null) {
|
||||
Button(enabled = false, onClick = {}) {
|
||||
Text("Unsupported")
|
||||
}
|
||||
} else {
|
||||
val sasState by sas.state.collectAsState()
|
||||
when (val currentSasState = sasState) {
|
||||
is ActiveSasVerificationState.TheirSasStart -> {
|
||||
Button(onClick = { scope.launch { currentSasState.accept() } }) {
|
||||
Text("Accept SAS")
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveSasVerificationState.ComparisonByUser -> {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { scope.launch { currentSasState.noMatch() } }) {
|
||||
Text("No match")
|
||||
}
|
||||
Button(onClick = { scope.launch { currentSasState.match() } }) {
|
||||
Text("They match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Button(enabled = false, onClick = {}) {
|
||||
Text("Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Done,
|
||||
is ActiveVerificationState.Cancel,
|
||||
ActiveVerificationState.AcceptedByOtherDevice,
|
||||
ActiveVerificationState.Undefined -> {
|
||||
Button(onClick = onClose) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.OwnRequest,
|
||||
is ActiveVerificationState.WaitForDone -> {
|
||||
Button(enabled = false, onClick = {}) {
|
||||
Text("Waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SasVerificationContent(sas: ActiveSasVerificationMethod) {
|
||||
val sasState by sas.state.collectAsState()
|
||||
|
||||
when (val currentSasState = sasState) {
|
||||
is ActiveSasVerificationState.ComparisonByUser -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Compare these emojis with the other device. Only tap “They match” if both sides show the same sequence.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = currentSasState.emojis.joinToString(" ") { it.second },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = currentSasState.decimal.joinToString(" · "),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveSasVerificationState.TheirSasStart -> {
|
||||
SettingsStatusMessage("The other device started SAS. Accept it to show emojis.")
|
||||
}
|
||||
|
||||
is ActiveSasVerificationState.OwnSasStart,
|
||||
is ActiveSasVerificationState.Accept,
|
||||
is ActiveSasVerificationState.WaitForKeys,
|
||||
ActiveSasVerificationState.WaitForMacs -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1565,6 +2207,49 @@ private suspend fun UIA<Unit>.handleUia(
|
||||
}
|
||||
}
|
||||
|
||||
private fun KeySignatureTrustLevel?.isVerified(): Boolean {
|
||||
return when (this) {
|
||||
is KeySignatureTrustLevel.Valid -> verified
|
||||
is KeySignatureTrustLevel.CrossSigned -> verified
|
||||
is KeySignatureTrustLevel.NotAllDeviceKeysCrossSigned -> verified
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun VerificationService.SelfVerificationMethods.toSelfVerificationStatus(): String {
|
||||
return when (this) {
|
||||
is VerificationService.SelfVerificationMethods.PreconditionsNotMet -> "Waiting for sync and keys"
|
||||
VerificationService.SelfVerificationMethods.NoCrossSigningEnabled -> "Cross-signing is not set up"
|
||||
VerificationService.SelfVerificationMethods.AlreadyCrossSigned -> "This session is verified"
|
||||
is VerificationService.SelfVerificationMethods.CrossSigningEnabled -> {
|
||||
if (methods.isEmpty()) "Recovery unavailable" else "Verification available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun VerificationService.SelfVerificationMethods.PreconditionsNotMet.Reason.toSelfVerificationReason(): String {
|
||||
return when (this) {
|
||||
VerificationService.SelfVerificationMethods.PreconditionsNotMet.Reason.SyncNotRunning -> "sync is not running"
|
||||
VerificationService.SelfVerificationMethods.PreconditionsNotMet.Reason.DeviceKeysNotFetchedYet -> "device keys"
|
||||
VerificationService.SelfVerificationMethods.PreconditionsNotMet.Reason.CrossSigningKeysNotFetchedYet -> "cross-signing keys"
|
||||
else -> this::class.simpleName ?: "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ActiveVerificationState.toVerificationStateDescription(): String {
|
||||
return when (this) {
|
||||
is ActiveVerificationState.OwnRequest -> "Waiting for the other device to accept the verification request."
|
||||
is ActiveVerificationState.TheirRequest -> "The other device wants to verify this session."
|
||||
is ActiveVerificationState.Ready -> "Ready to start SAS verification."
|
||||
is ActiveVerificationState.Start -> "SAS verification is in progress."
|
||||
is ActiveVerificationState.WaitForDone -> "Waiting for the other device to finish."
|
||||
ActiveVerificationState.Done -> "Verification completed."
|
||||
is ActiveVerificationState.Cancel -> "Verification was cancelled."
|
||||
ActiveVerificationState.AcceptedByOtherDevice -> "Another device accepted this request."
|
||||
ActiveVerificationState.Undefined -> "Verification state is missing. Start again."
|
||||
}
|
||||
}
|
||||
|
||||
private fun KeySignatureTrustLevel?.toVerificationStatus(): String {
|
||||
return when (this) {
|
||||
is KeySignatureTrustLevel.Valid -> if (verified) "Verified" else "Valid, not verified"
|
||||
|
||||
Reference in New Issue
Block a user