diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_login.xml b/composeApp/src/androidMain/composeResources/drawable/ic_login.xml
new file mode 100644
index 0000000..820129b
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_login.xml
@@ -0,0 +1,10 @@
+
+
+
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 d0283e3..f2fbe89 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -626,7 +626,7 @@ fun NewRequests(requests: List) {
val secondRoom = requests.getOrNull(1)
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 {
total == 1 -> {
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 aea7ee5..967b712 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt
@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
-import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -30,7 +29,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -40,32 +38,24 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
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)
@Composable
@@ -74,24 +64,18 @@ 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 ->
runCatching {
- if (result.startsWith("nsec1")) {
+ if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
Keys.parse(result)
} else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result)
@@ -101,13 +85,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) },
@@ -147,35 +137,6 @@ fun ImportScreen() {
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Box(
- modifier = Modifier
- .size(120.dp)
- .clip(MaterialShapes.Cookie9Sided.toShape()),
- 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 = profile?.name ?: "",
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.titleLargeEmphasized.copy(
- fontFamily = getExpressiveFontFamily()
- ),
- )
- }
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -186,7 +147,7 @@ fun ImportScreen() {
Column(
modifier = Modifier
.fillMaxSize()
- .padding(24.dp)
+ .padding(24.dp),
) {
Column(
modifier = Modifier
@@ -203,9 +164,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,32 +198,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 {
- if (pubkey == null) {
- authViewModel.verifyIdentity(secret).let { pubkey = it }
- } else {
+ loading = true
+ try {
// Import the identity
- authViewModel.importIdentity(secret)
+ authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
+ } catch (e: Exception) {
+ 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/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
index 758d75d..80a3393 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt
@@ -1,9 +1,7 @@
package su.reya.coop.screens
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
@@ -16,13 +14,9 @@ fun NewIdentityScreen() {
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
- val authState by authViewModel.state.collectAsStateWithLifecycle()
- val isBusy = authState.isBusy
-
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
- isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
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/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
index 074a9b7..efa531a 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt
@@ -15,7 +15,6 @@ fun UpdateProfileScreen() {
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
- val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord()
@@ -25,7 +24,6 @@ fun UpdateProfileScreen() {
initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "",
initialPicture = profile?.picture,
- isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt
index 9aab16a..e975f85 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt
@@ -68,7 +68,6 @@ fun ProfileEditor(
initialName: String = "",
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
- isBusy: Boolean = false,
onBack: () -> 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 bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
+ var isBusy by remember { mutableStateOf(false) }
val hasPicture = remember(picture) {
when (picture) {
@@ -267,14 +267,20 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight),
onClick = {
scope.launch {
- val bytes = withContext(Dispatchers.IO) {
- (picture as? Uri)?.let {
- context.contentResolver.openInputStream(it)?.readBytes()
+ isBusy = true
+ try {
+ 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 =
- (picture as? Uri)?.let { context.contentResolver.getType(it) }
- onConfirm(name, bio, bytes, type)
+ isBusy = false
}
},
enabled = name.isNotBlank() && !isBusy
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 5d8b226..e77db2e 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
@@ -20,7 +21,6 @@ import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds
data class AuthState(
- val isBusy: Boolean = false,
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
)
@@ -59,7 +59,7 @@ class AuthViewModel(
private fun login() {
viewModelScope.launch {
try {
- val secret = withTimeoutOrNull(3.seconds) {
+ val secret = withTimeoutOrNull(5.seconds) {
secretStore.get(KEY_USER_SIGNER)
}
@@ -69,7 +69,7 @@ class AuthViewModel(
}
runCatching {
- val signer = createSigner(secret)
+ val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
@@ -87,8 +87,6 @@ class AuthViewModel(
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
- _state.update { it.copy(isBusy = true) }
-
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
@@ -98,11 +96,10 @@ class AuthViewModel(
// Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED)
-
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
-
- _state.update { it.copy(isBusy = false, signerRequired = true) }
+ // Reset local states
+ _state.update { it.copy(signerRequired = true) }
}
}
}
@@ -116,28 +113,35 @@ 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
+ ): Pair {
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://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
- NostrConnect(uri = bunker, appKeys, timeout, null)
+ NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
@@ -150,77 +154,50 @@ class AuthViewModel(
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
- ExternalSignerProxy(handler, pubkey)
+ ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
- suspend fun verifyIdentity(secret: String): PublicKey? {
- try {
- val signer = createSigner(secret)
- if (secret.startsWith("bunker://")) {
- showError("Please approve the connection.")
- }
- 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, decryptedSecret) = createSigner(secret, password)
+ // Update signer
+ nostr.setSigner(signer)
+ // Persist the secret in the secret storage
+ secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
+ // Update local states
+ _state.update { it.copy(signerRequired = false) }
}
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 {
@@ -233,27 +210,16 @@ class AuthViewModel(
picture: ByteArray?,
contentType: String? = null
) {
- _state.update { it.copy(isBusy = true) }
-
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
-
- try {
- 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) }
+ val avatarUrl = picture?.let {
+ mediaRepository.blossomUpload(keys, 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(signerRequired = false) }
}
}