5 Commits

Author SHA1 Message Date
f49279fd10 fix 2026-07-06 10:04:23 +07:00
a48c0c0e7c update 2026-07-06 09:26:45 +07:00
d23b15df6f refactor 2026-07-06 09:15:25 +07:00
8750eaa225 fix crash 2026-07-06 07:27:50 +07:00
0ad872d186 remove toast message 2026-07-06 07:20:43 +07:00
6 changed files with 140 additions and 147 deletions

View File

@@ -626,7 +626,7 @@ fun NewRequests(requests: List<Room>) {
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel)
val supportingText = when { val supportingText = when {
total == 1 -> { total == 1 -> {

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,21 +155,14 @@ fun ImportScreen() {
.clip(MaterialShapes.Cookie9Sided.toShape()), .clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar( Text(
picture = profile?.picture, text = "",
description = "Profile picture", textAlign = TextAlign.Center,
modifier = Modifier.fillMaxSize(), style = MaterialTheme.typography.titleLargeEmphasized.copy(
shape = MaterialShapes.Cookie9Sided.toShape(), fontFamily = getExpressiveFontFamily()
),
) )
} }
Spacer(modifier = Modifier.size(8.dp))
Text(
text = profile?.name ?: "",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
} }
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -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,32 +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 {
if (pubkey == null) { loading = true
authViewModel.verifyIdentity(secret).let { pubkey = it } try {
} 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) {
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

@@ -1,9 +1,7 @@
package su.reya.coop.screens package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
@@ -16,13 +14,9 @@ fun NewIdentityScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
ProfileEditor( ProfileEditor(
title = "Create a new identity", title = "Create a new identity",
buttonLabel = "Continue", buttonLabel = "Continue",
isBusy = isBusy,
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { scope.launch {

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

@@ -68,7 +68,6 @@ fun ProfileEditor(
initialName: String = "", initialName: String = "",
initialBio: String = "", initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) { ) {
@@ -80,6 +79,7 @@ fun ProfileEditor(
var name by remember(initialName) { mutableStateOf(initialName) } var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) } var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
var isBusy by remember { mutableStateOf(false) }
val hasPicture = remember(picture) { val hasPicture = remember(picture) {
when (picture) { when (picture) {
@@ -267,14 +267,20 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
onClick = { onClick = {
scope.launch { scope.launch {
val bytes = withContext(Dispatchers.IO) { isBusy = true
(picture as? Uri)?.let { try {
context.contentResolver.openInputStream(it)?.readBytes() val bytes = withContext(Dispatchers.IO) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}
} }
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
} }
val type = isBusy = false
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
} }
}, },
enabled = name.isNotBlank() && !isBusy enabled = name.isNotBlank() && !isBusy

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
@@ -20,7 +21,6 @@ import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
data class AuthState( data class AuthState(
val isBusy: Boolean = false,
val signerRequired: Boolean? = null, val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false, val isNotificationBannerDismissed: Boolean = false,
) )
@@ -69,7 +69,7 @@ class AuthViewModel(
} }
runCatching { runCatching {
val signer = createSigner(secret) val (signer, _) = createSigner(secret)
nostr.setSigner(signer) nostr.setSigner(signer)
}.onSuccess { }.onSuccess {
_state.update { it.copy(signerRequired = false) } _state.update { it.copy(signerRequired = false) }
@@ -87,8 +87,6 @@ class AuthViewModel(
fun logout(onLogout: () -> Unit = {}) { fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_state.update { it.copy(isBusy = true) }
// Reset the nostr signer and prune the database // Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate()) nostr.signer.switch(Keys.generate())
nostr.prune() nostr.prune()
@@ -98,11 +96,10 @@ class AuthViewModel(
// Clear credentials from persistent storage // Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER) secretStore.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED) secretStore.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels) // Call cleanup callback (e.g. to reset other ViewModels)
onLogout() onLogout()
// Reset local states
_state.update { it.copy(isBusy = false, signerRequired = true) } _state.update { it.copy(signerRequired = true) }
} }
} }
} }
@@ -116,28 +113,35 @@ 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
): Pair<AsyncNostrSigner, String?> {
return when { return when {
secret.startsWith("nsec1") -> Keys.parse(secret) secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password)
val keys = Keys(secret)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> { secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) NostrConnect(uri = bunker, appKeys, timeout, null) to null
} }
secret.startsWith("nip55://") -> { secret.startsWith("nip55://") -> {
@@ -150,77 +154,50 @@ class AuthViewModel(
val pubkey = PublicKey.parse(parts[1]) val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName) handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) ExternalSignerProxy(handler, pubkey) to null
} }
else -> throw IllegalArgumentException("Invalid secret format") else -> throw IllegalArgumentException("Invalid secret format")
} }
} }
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun importIdentity(secret: String, password: String? = null) {
try { val (signer, decryptedSecret) = createSigner(secret, password)
val signer = createSigner(secret) // Update signer
if (secret.startsWith("bunker://")) { nostr.setSigner(signer)
showError("Please approve the connection.") // Persist the secret in the secret storage
} secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
return signer.getPublicKeyAsync() // Update local states
} catch (e: Exception) { _state.update { it.copy(signerRequired = false) }
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 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(
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 permissions = SignerPermissions.toJson(
val signer = ExternalSignerProxy(handler, result.pubkey) listOf(
SignerPermissions.signEvent(0),
// Update signer SignerPermissions.signEvent(3),
nostr.setSigner(signer) SignerPermissions.signEvent(10000),
// Store the signer in the secret storage SignerPermissions.signEvent(10050),
secretStore.set( SignerPermissions.signEvent(10063),
KEY_USER_SIGNER, SignerPermissions.signEvent(22242),
"nip55://${result.packageName}/${result.pubkey.toHex()}" SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
) )
// Update local states )
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) { val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
_state.update { it.copy(isBusy = false) } val signer = ExternalSignerProxy(handler, result.pubkey)
showError("Notice: ${e.message}")
} // 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 { fun isExternalSignerAvailable(): Boolean {
@@ -233,27 +210,16 @@ class AuthViewModel(
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
_state.update { it.copy(isBusy = true) }
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
try { mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(isBusy = false, signerRequired = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
} }
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
} }
} }