6 Commits

Author SHA1 Message Date
7a44e836a9 chore: bump version 2026-07-07 13:29:11 +07:00
4dae4045ae Merge pull request 'chore: improve loading state handling' (#38) from fix-loading-state into master
Reviewed-on: #38
2026-07-07 04:09:28 +00:00
9df4789b62 update 2026-07-07 11:09:06 +07:00
ea7e3fd3ed . 2026-07-07 10:59:09 +07:00
a76c8d117b fix syncing state 2026-07-07 08:27:25 +07:00
9ed29c90ba chore: improve authentication handling (#37)
Reviewed-on: #37
2026-07-06 07:33:06 +00:00
13 changed files with 157 additions and 194 deletions

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.2.3"
versionName = "0.2.4"
}
packaging {
resources {

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
</vector>

View File

@@ -626,7 +626,7 @@ fun NewRequests(requests: List<Room>) {
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 -> {

View File

@@ -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<PublicKey?>(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,
)
}

View File

@@ -1,10 +1,6 @@
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
import su.reya.coop.Screen
@@ -14,21 +10,14 @@ import su.reya.coop.shared.ProfileEditor
fun NewIdentityScreen() {
val authViewModel = LocalAuthViewModel.current
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 {
authViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
}
)
}

View File

@@ -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) }

View File

@@ -2,9 +2,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.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor
@@ -13,9 +11,7 @@ import su.reya.coop.shared.ProfileEditor
fun UpdateProfileScreen() {
val nostrViewModel = LocalNostrViewModel.current
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,13 +21,10 @@ 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 {
nostrViewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
}
)
}

View File

@@ -68,9 +68,8 @@ 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
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
@@ -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,6 +267,8 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight),
onClick = {
scope.launch {
isBusy = true
try {
val bytes = withContext(Dispatchers.IO) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
@@ -275,6 +277,10 @@ fun ProfileEditor(
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
}
isBusy = false
}
},
enabled = name.isNotBlank() && !isBusy

View File

@@ -155,7 +155,7 @@ class Nostr {
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
val giftWrapQueue = Channel<Event>(1024)
var processedCount = 0
var eoseReceived = false
@@ -219,6 +219,7 @@ class Nostr {
KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
messages.updateSyncState { it.copy(isSyncing = true) }
messages.getUserMessages(msgRelayList = event)
}
}

View File

@@ -82,7 +82,7 @@ class ProfileManager(private val nostr: Nostr) {
client?.sync(filter, relays)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
println("Failed to sync mutual contacts: ${e.message}")
}
}

View File

@@ -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<AsyncNostrSigner, String?> {
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,46 +154,26 @@ 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)
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, secret)
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: 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) }
}
_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),
@@ -211,16 +195,9 @@ class AuthViewModel(
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
_state.update { it.copy(isBusy = false) }
showError("Notice: ${e.message}")
}
_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")
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(isBusy = false, signerRequired = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
_state.update { it.copy(signerRequired = false) }
}
}

View File

@@ -65,7 +65,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0) {
if (syncState.processedCount > 0 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}

View File

@@ -72,8 +72,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)