This commit is contained in:
2026-07-06 09:15:25 +07:00
parent 8750eaa225
commit d23b15df6f
4 changed files with 107 additions and 107 deletions

View File

@@ -48,23 +48,18 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -74,19 +69,13 @@ fun ImportScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) } var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
val profile by remember(pubkey) { var loading by remember { mutableStateOf(false) }
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
@@ -101,13 +90,19 @@ fun ImportScreen() {
}.onSuccess { }.onSuccess {
secret = result secret = result
}.onFailure { e -> }.onFailure { e ->
snackbarHostState.showSnackbar("Invalid secret: ${e.message}") e.message?.let { snackbarHostState.showSnackbar(it) }
} }
// Clear the nav state // Clear the nav state
qrScanResult.clear() qrScanResult.clear()
} }
} }
LaunchedEffect(secret) {
if (secret.startsWith("ncryptsec1")) {
requirePassword = true
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -160,22 +155,15 @@ fun ImportScreen() {
.clip(MaterialShapes.Cookie9Sided.toShape()), .clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar(
picture = profile?.picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = profile?.name ?: "", text = "",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
), ),
) )
} }
}
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -186,7 +174,7 @@ fun ImportScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp) .padding(24.dp),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -203,9 +191,9 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !isBusy, enabled = !loading,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxLines = 4, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
@@ -237,36 +225,68 @@ fun ImportScreen() {
} }
} }
) )
Spacer(modifier = Modifier.size(8.dp))
if (requirePassword) {
Text(
text = "Decrypt Password:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = password,
onValueChange = { password = it },
enabled = !loading && requirePassword,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
innerTextField()
}
}
)
}
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
loading = true
try { try {
if (pubkey == null) {
authViewModel.verifyIdentity(secret).let { pubkey = it }
} else {
// Import the identity // Import the identity
authViewModel.importIdentity(secret) authViewModel.importIdentity(secret, password)
// Navigate to the home screen // Navigate to the home screen
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
}
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}") snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
} }
} }
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isBusy, enabled = secret.isNotBlank() && !loading,
) { ) {
if (isBusy) { if (loading) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(
text = if (pubkey == null) "Verify" else "Click again to Continue", text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -160,7 +160,10 @@ fun OnboardingScreen() {
scope.launch { scope.launch {
if (authViewModel.isExternalSignerAvailable()) { if (authViewModel.isExternalSignerAvailable()) {
try { try {
// Connect to the external signer
// TODO: show all available signers?
authViewModel.connectExternalSigner() authViewModel.connectExternalSigner()
// Navigate to the home screen
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
@@ -116,23 +117,25 @@ class AuthViewModel(
private suspend fun getOrInitAppKeys(): Keys { private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get(KEY_APP_KEYS) val secret = secretStore.get(KEY_APP_KEYS)
// If app keys are already stored, use them // If app keys are already stored, use them
if (secret != null) { if (secret != null) return Keys.parse(secret)
return Keys.parse(secret)
}
// Generate new app keys and save to the secret storage // Generate new app keys and save to the secret storage
val keys = Keys.generate() val keys = Keys.generate()
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys return keys
} }
private suspend fun createSigner(secret: String): AsyncNostrSigner { private suspend fun createSigner(secret: String, password: String? = null): AsyncNostrSigner {
return when { return when {
secret.startsWith("nsec1") -> Keys.parse(secret) secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password)
Keys(secret)
}
secret.startsWith("bunker://") -> { secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
@@ -157,36 +160,17 @@ class AuthViewModel(
} }
} }
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun importIdentity(secret: String, password: String? = null) {
try { val signer = createSigner(secret, password)
val signer = createSigner(secret)
return signer.getPublicKeyAsync()
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun importIdentity(secret: String) {
_state.update { it.copy(isBusy = true) }
try {
val signer = createSigner(secret)
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Persist the secret in the secret storage // Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret) secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
} }
suspend fun connectExternalSigner() { suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_state.update { it.copy(isBusy = true) }
try {
val permissions = SignerPermissions.toJson( val permissions = SignerPermissions.toJson(
listOf( listOf(
SignerPermissions.signEvent(0), SignerPermissions.signEvent(0),
@@ -208,16 +192,9 @@ class AuthViewModel(
// Update signer // Update signer
nostr.setSigner(signer) nostr.setSigner(signer)
// Store the signer in the secret storage // Store the signer in the secret storage
secretStore.set( secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states // Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) } _state.update { it.copy(signerRequired = false) }
} catch (e: Exception) {
_state.update { it.copy(isBusy = false) }
showError("Notice: ${e.message}")
}
} }
fun isExternalSignerAvailable(): Boolean { fun isExternalSignerAvailable(): Boolean {