diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index ef18d2b..f2fbe89 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -627,7 +627,7 @@ fun NewRequests(requests: List) { val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel) - + val supportingText = when { total == 1 -> { val message = firstRoom.lastMessage ?: "" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 32f7259..8aa2f50 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -48,23 +48,18 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen -import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -74,19 +69,13 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val nostrViewModel = LocalNostrViewModel.current val authViewModel = LocalAuthViewModel.current - val scope = rememberCoroutineScope() - val authState by authViewModel.state.collectAsStateWithLifecycle() - val isBusy = authState.isBusy var secret by remember { mutableStateOf("") } - var pubkey by remember { mutableStateOf(null) } - - val profile by remember(pubkey) { - pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) - }.collectAsStateWithLifecycle(null) + var password by remember { mutableStateOf("") } + var requirePassword by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> @@ -101,13 +90,19 @@ fun ImportScreen() { }.onSuccess { secret = result }.onFailure { e -> - snackbarHostState.showSnackbar("Invalid secret: ${e.message}") + e.message?.let { snackbarHostState.showSnackbar(it) } } // Clear the nav state qrScanResult.clear() } } + LaunchedEffect(secret) { + if (secret.startsWith("ncryptsec1")) { + requirePassword = true + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -160,21 +155,14 @@ fun ImportScreen() { .clip(MaterialShapes.Cookie9Sided.toShape()), contentAlignment = Alignment.Center ) { - Avatar( - picture = profile?.picture, - description = "Profile picture", - modifier = Modifier.fillMaxSize(), - shape = MaterialShapes.Cookie9Sided.toShape(), + Text( + text = "", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontFamily = getExpressiveFontFamily() + ), ) } - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = profile?.name ?: "", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLargeEmphasized.copy( - fontFamily = getExpressiveFontFamily() - ), - ) } Surface( modifier = Modifier @@ -186,7 +174,7 @@ fun ImportScreen() { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(24.dp), ) { Column( modifier = Modifier @@ -203,9 +191,9 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, - enabled = !isBusy, + enabled = !loading, modifier = Modifier.fillMaxWidth(), - maxLines = 4, + singleLine = true, keyboardOptions = KeyboardOptions( 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)) Button( onClick = { scope.launch { + loading = true try { - if (pubkey == null) { - authViewModel.verifyIdentity(secret).let { pubkey = it } - } else { - // Import the identity - authViewModel.importIdentity(secret) - // Navigate to the home screen - navigator.navigate(Screen.Home) - } + // Import the identity + authViewModel.importIdentity(secret, password) + // Navigate to the home screen + navigator.navigate(Screen.Home) } catch (e: Exception) { - snackbarHostState.showSnackbar("Error: ${e.message}") + snackbarHostState.showSnackbar(e.message ?: "Error") + loading = false } } - }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !isBusy, + enabled = secret.isNotBlank() && !loading, ) { - if (isBusy) { + if (loading) { LoadingIndicator() } else { Text( - text = if (pubkey == null) "Verify" else "Click again to Continue", + text = "Continue", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index bbdf7d6..a042cd7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -160,7 +160,10 @@ fun OnboardingScreen() { scope.launch { if (authViewModel.isExternalSignerAvailable()) { try { + // Connect to the external signer + // TODO: show all available signers? authViewModel.connectExternalSigner() + // Navigate to the home screen navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 12dfa8c..c92febd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EncryptedSecretKey import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri @@ -116,23 +117,25 @@ class AuthViewModel( private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get(KEY_APP_KEYS) - // If app keys are already stored, use them - if (secret != null) { - return Keys.parse(secret) - } - + if (secret != null) return Keys.parse(secret) // Generate new app keys and save to the secret storage val keys = Keys.generate() secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) - return keys } - private suspend fun createSigner(secret: String): AsyncNostrSigner { + private suspend fun createSigner(secret: String, password: String? = null): AsyncNostrSigner { return when { 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://") -> { val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) @@ -157,67 +160,41 @@ class AuthViewModel( } } - suspend fun verifyIdentity(secret: String): PublicKey? { - try { - 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 - nostr.setSigner(signer) - // Persist the secret in the secret storage - 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 importIdentity(secret: String, password: String? = null) { + val signer = createSigner(secret, password) + // Update signer + nostr.setSigner(signer) + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) } suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _state.update { it.copy(isBusy = true) } - try { - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") - val signer = ExternalSignerProxy(handler, result.pubkey) - - // Update signer - nostr.setSigner(signer) - // Store the signer in the secret storage - secretStore.set( - KEY_USER_SIGNER, - "nip55://${result.packageName}/${result.pubkey.toHex()}" + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), ) - // Update local states - _state.update { it.copy(signerRequired = false, isBusy = false) } - } catch (e: Exception) { - _state.update { it.copy(isBusy = false) } - showError("Notice: ${e.message}") - } + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + + // Update signer + nostr.setSigner(signer) + // Store the signer in the secret storage + secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") + // Update local states + _state.update { it.copy(signerRequired = false) } } fun isExternalSignerAvailable(): Boolean {