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.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
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.KeySignatureTrustLevel
|
||||||
import de.connect2x.trixnity.client.store.KeyStore
|
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.SyncState
|
||||||
import de.connect2x.trixnity.clientserverapi.client.UIA
|
import de.connect2x.trixnity.clientserverapi.client.UIA
|
||||||
import de.connect2x.trixnity.clientserverapi.model.authentication.IdentifierType
|
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.ProfileField
|
||||||
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
||||||
import de.connect2x.trixnity.clientserverapi.model.user.displayName
|
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.http.ContentType
|
||||||
import io.ktor.utils.io.ByteReadChannel
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -452,6 +461,13 @@ fun AccountSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
||||||
val matrixClient = client!!
|
val matrixClient = client!!
|
||||||
val syncState by matrixClient.api.sync.currentSyncState.collectAsState(initial = SyncState.STOPPED)
|
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 scope = rememberCoroutineScope()
|
||||||
val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase() }
|
val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase() }
|
||||||
val deviceName = listOf(manufacturer, Build.MODEL)
|
val deviceName = listOf(manufacturer, Build.MODEL)
|
||||||
@@ -461,8 +477,18 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
var sessions by remember { mutableStateOf<List<MatrixSession>>(emptyList()) }
|
var sessions by remember { mutableStateOf<List<MatrixSession>>(emptyList()) }
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
var isClosingDeviceId by remember { mutableStateOf<String?>(null) }
|
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 message by remember { mutableStateOf<String?>(null) }
|
||||||
var pendingAuthentication by remember { mutableStateOf<PendingAuthentication?>(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() {
|
suspend fun loadSessions() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -477,6 +503,108 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
isLoading = false
|
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) {
|
fun closeSession(device: Device) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isClosingDeviceId = device.deviceId
|
isClosingDeviceId = device.deviceId
|
||||||
@@ -518,14 +646,12 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
item {
|
item {
|
||||||
SettingsHeader(
|
SettingsHeader(
|
||||||
title = "Devices",
|
title = "Devices",
|
||||||
subtitle = "All Matrix sessions connected to your account.",
|
subtitle = "Verify sessions so encrypted messages can be sent safely.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
message?.let { status ->
|
message?.let { status ->
|
||||||
item {
|
item { SettingsStatusMessage(status) }
|
||||||
SettingsStatusMessage(status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
@@ -540,6 +666,16 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
value = matrixClient.deviceId,
|
value = matrixClient.deviceId,
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
SettingsInfoRow(
|
||||||
|
title = "Identity key",
|
||||||
|
value = matrixClient.identityKey.value.value,
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsInfoRow(
|
||||||
|
title = "Signing key",
|
||||||
|
value = matrixClient.signingKey.value.value,
|
||||||
|
)
|
||||||
|
SettingsDivider()
|
||||||
SettingsInfoRow(
|
SettingsInfoRow(
|
||||||
title = "Android",
|
title = "Android",
|
||||||
value = "${Build.VERSION.RELEASE} · API ${Build.VERSION.SDK_INT}",
|
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 {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -567,9 +787,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = !isLoading,
|
enabled = !isLoading,
|
||||||
onClick = {
|
onClick = { scope.launch { loadSessions() } },
|
||||||
scope.launch { loadSessions() }
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Text("Refresh")
|
Text("Refresh")
|
||||||
}
|
}
|
||||||
@@ -591,9 +809,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessions.isEmpty() -> {
|
sessions.isEmpty() -> {
|
||||||
item {
|
item { SettingsStatusMessage("No active sessions returned by the homeserver") }
|
||||||
SettingsStatusMessage("No active sessions returned by the homeserver")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -605,24 +821,80 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
session = session,
|
session = session,
|
||||||
currentDeviceId = matrixClient.deviceId,
|
currentDeviceId = matrixClient.deviceId,
|
||||||
isClosing = isClosingDeviceId == session.device.deviceId,
|
isClosing = isClosingDeviceId == session.device.deviceId,
|
||||||
|
isStartingVerification = isStartingVerificationDeviceId == session.device.deviceId,
|
||||||
onClose = { closeSession(session.device) },
|
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 ->
|
pendingAuthentication?.let { pending ->
|
||||||
PasswordAuthenticationDialog(
|
PasswordAuthenticationDialog(
|
||||||
title = pending.title,
|
title = pending.title,
|
||||||
description = pending.description,
|
description = pending.description,
|
||||||
isBusy = isClosingDeviceId != null,
|
isBusy = isAuthenticating,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
pendingAuthentication = null
|
pendingAuthentication = null
|
||||||
isClosingDeviceId = null
|
isClosingDeviceId = null
|
||||||
|
isAuthenticating = false
|
||||||
},
|
},
|
||||||
onConfirm = { password ->
|
onConfirm = { password ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
isAuthenticating = true
|
||||||
val authRequest = AuthenticationRequest.Password(
|
val authRequest = AuthenticationRequest.Password(
|
||||||
identifier = IdentifierType.User(matrixClient.userId.toString()),
|
identifier = IdentifierType.User(matrixClient.userId.toString()),
|
||||||
password = password,
|
password = password,
|
||||||
@@ -646,6 +918,7 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
}.onFailure {
|
}.onFailure {
|
||||||
message = it.toSettingsError()
|
message = it.toSettingsError()
|
||||||
}
|
}
|
||||||
|
isAuthenticating = false
|
||||||
isClosingDeviceId = null
|
isClosingDeviceId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1123,10 +1396,13 @@ private fun SessionCard(
|
|||||||
session: MatrixSession,
|
session: MatrixSession,
|
||||||
currentDeviceId: String,
|
currentDeviceId: String,
|
||||||
isClosing: Boolean,
|
isClosing: Boolean,
|
||||||
|
isStartingVerification: Boolean,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
onVerify: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val device = session.device
|
val device = session.device
|
||||||
val isCurrent = device.deviceId == currentDeviceId
|
val isCurrent = device.deviceId == currentDeviceId
|
||||||
|
val verified = session.verification.isVerified()
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1192,18 +1468,384 @@ private fun SessionCard(
|
|||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
Row(
|
||||||
enabled = !isCurrent && !isClosing,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onClick = onClose,
|
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(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.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 {
|
private fun KeySignatureTrustLevel?.toVerificationStatus(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is KeySignatureTrustLevel.Valid -> if (verified) "Verified" else "Valid, not verified"
|
is KeySignatureTrustLevel.Valid -> if (verified) "Verified" else "Valid, not verified"
|
||||||
|
|||||||
Reference in New Issue
Block a user