10 Commits

Author SHA1 Message Date
38b704fe18 improve initial loading 2026-07-09 16:44:11 +07:00
e0701504aa remove unnecessary query call 2026-07-09 16:12:38 +07:00
175c06a1a8 chore: refactor the internal storage layer (#40)
Reviewed-on: #40
2026-07-09 07:53:22 +00:00
c7a646ae73 chore: improve line break handling (#39)
Reviewed-on: #39
2026-07-09 03:48:42 +00:00
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
24 changed files with 280 additions and 318 deletions

View File

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

@@ -1,8 +1,13 @@
package su.reya.coop.coop.storage package su.reya.coop
import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
data class SecretEntry( private val Context.dataStore by preferencesDataStore("secret_store")
val encrypted: String,
val iv: String data class SecretEntry(val encrypted: String, val iv: String)
)
class SecretCrypto { class SecretCrypto {
private val keyAlias = "coop" private val keyAlias = "coop"
@@ -21,11 +25,9 @@ class SecretCrypto {
private val transformation = "AES/GCM/NoPadding" private val transformation = "AES/GCM/NoPadding"
fun encrypt(content: String): SecretEntry { fun encrypt(content: String): SecretEntry {
// Initialize cipher
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// Encrypt content
val encrypted = cipher.doFinal(content.toByteArray()) val encrypted = cipher.doFinal(content.toByteArray())
val iv = cipher.iv val iv = cipher.iv
@@ -39,12 +41,10 @@ class SecretCrypto {
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP) val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
val iv = Base64.decode(entry.iv, Base64.NO_WRAP) val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
// Initialize cipher
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
val spec = GCMParameterSpec(128, iv) val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
// Decrypt content
val plaintext = cipher.doFinal(encrypted) val plaintext = cipher.doFinal(encrypted)
return String(plaintext, StandardCharsets.UTF_8) return String(plaintext, StandardCharsets.UTF_8)
@@ -54,13 +54,9 @@ class SecretCrypto {
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) } val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
val existingKey = keyStore.getKey(keyAlias, null) val existingKey = keyStore.getKey(keyAlias, null)
// Return existing key if available
if (existingKey is SecretKey) return existingKey if (existingKey is SecretKey) return existingKey
// Construct a new key generator
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType) val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
// Initialize key generation parameters
val spec = KeyGenParameterSpec.Builder( val spec = KeyGenParameterSpec.Builder(
keyAlias, keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -70,9 +66,49 @@ class SecretCrypto {
.setKeySize(256) .setKeySize(256)
.build() .build()
// Generate a new key
keyGenerator.init(spec) keyGenerator.init(spec)
return keyGenerator.generateKey() return keyGenerator.generateKey()
} }
} }
class AppStore(private val context: Context) : AppStorage {
private val crypto = SecretCrypto()
override suspend fun get(key: String): String? {
return context.dataStore.data.first()[stringPreferencesKey(key)]
}
override suspend fun set(key: String, value: String) {
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun getSecret(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun setSecret(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey(key))
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs.contains(stringPreferencesKey(key)) ||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
}
}

View File

@@ -12,7 +12,6 @@ import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
@@ -26,15 +25,13 @@ class MainActivity : ComponentActivity() {
private val factory by lazy { private val factory by lazy {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity)
private val nostrViewModel = NostrViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val secretStore = SecretStore(this@MainActivity)
private val nostrViewModel =
NostrViewModel(NostrManager.instance)
private val chatViewModel =
ChatViewModel(NostrManager.instance)
private val authViewModel = private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner) AuthViewModel(NostrManager.instance, storage, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {

View File

@@ -155,7 +155,7 @@ fun HomeScreen() {
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(Unit) { LaunchedEffect(authState.signerRequired) {
chatViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
} }
@@ -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

@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -30,7 +29,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -40,32 +38,24 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction 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.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
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -74,24 +64,18 @@ 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 ->
runCatching { runCatching {
if (result.startsWith("nsec1")) { if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
Keys.parse(result) Keys.parse(result)
} else if (result.startsWith("bunker://")) { } else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result) NostrConnectUri.parse(result)
@@ -101,13 +85,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) },
@@ -147,35 +137,6 @@ fun ImportScreen() {
.padding(top = innerPadding.calculateTopPadding()) .padding(top = innerPadding.calculateTopPadding())
.imePadding(), .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( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -186,7 +147,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 +164,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 +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)) 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,10 +1,6 @@
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.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -14,21 +10,14 @@ import su.reya.coop.shared.ProfileEditor
fun NewIdentityScreen() { fun NewIdentityScreen() {
val authViewModel = LocalAuthViewModel.current val authViewModel = LocalAuthViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
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 { authViewModel.createIdentity(name, bio, bytes, type)
authViewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home)
navigator.navigate(Screen.Home)
}
} }
) )
} }

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

@@ -2,9 +2,7 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
@@ -13,9 +11,7 @@ import su.reya.coop.shared.ProfileEditor
fun UpdateProfileScreen() { fun UpdateProfileScreen() {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord() val profile = currentUser?.metadata?.asRecord()
@@ -25,13 +21,10 @@ fun UpdateProfileScreen() {
initialName = profile?.displayName ?: profile?.name ?: "", initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "", initialBio = profile?.about ?: "",
initialPicture = profile?.picture, initialPicture = profile?.picture,
isBusy = isBusy,
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { nostrViewModel.updateProfile(name, bio, bytes, type)
nostrViewModel.updateProfile(name, bio, bytes, type) navigator.goBack()
navigator.goBack()
}
} }
) )
} }

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_add_circle import coop.composeapp.generated.resources.ic_add_circle
@@ -52,6 +55,11 @@ fun ChatInput(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
placeholder = { Text("Message") }, placeholder = { Text("Message") },
maxLines = 5,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Default
),
leadingIcon = { leadingIcon = {
IconButton(onClick = onUpload) { IconButton(onClick = onUpload) {
Icon( Icon(

View File

@@ -68,9 +68,8 @@ 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: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
@@ -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

@@ -1,43 +0,0 @@
package su.reya.coop.coop.storage
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import su.reya.coop.storage.SecretStorage
private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto()
override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${key}_encrypted")] != null
}
}

View File

@@ -33,6 +33,8 @@ kotlin {
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.3.2") implementation("su.reya:nostr-sdk-kmp:0.3.2")

View File

@@ -0,0 +1,14 @@
package su.reya.coop
interface AppStorage {
// Plain text storage
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
// Encrypted storage
suspend fun getSecret(key: String): String?
suspend fun setSecret(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -10,17 +10,17 @@ fun PublicKey.short(): String {
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE) val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun String.extractUrls(): List<String> {
return URL_REGEX.findAll(this).map { it.value }.toList()
}
fun String.removeImageUrls(): String { fun String.removeImageUrls(): String {
return URL_REGEX.replace(this) { result -> return URL_REGEX.replace(this) { result ->
if (result.value.isImageUrl()) "" else result.value if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim() }.trim()
} }
fun String.isImageUrl(): Boolean { fun String.isImageUrl(): Boolean {
val extension = this.substringAfterLast('.', "").lowercase() val extension = this.substringAfterLast('.', "").lowercase()
return extension in imageExtensions return extension in imageExtensions
} }
fun String.sanitizeName(): String {
return this.replace("\n", " ").replace("\r", " ").trim()
}

View File

@@ -10,8 +10,8 @@ data class Profile(
private val record by lazy { metadata.asRecord() } private val record by lazy { metadata.asRecord() }
val name: String val name: String
get() = record.displayName ?: record.name ?: publicKey.short() get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
val picture: String? val picture: String?
get() = record.picture get() = record.picture

View File

@@ -41,8 +41,7 @@ data class Room(
} }
companion object { companion object {
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt() val createdAt = rumor.createdAt()
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content() val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
@@ -99,11 +98,13 @@ fun Room.uiStateFlow(
val displayMembers = if (isGroup()) members.take(2) else members.take(1) val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) { if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject, isGroup = isGroup())) return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
} }
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() } val names = profiles.mapIndexed { i, profile ->
profile?.name?.sanitizeName() ?: displayMembers[i].short()
}
val name = when { val name = when {
isGroup() -> { isGroup() -> {

View File

@@ -164,38 +164,35 @@ class MessageManager(private val nostr: Nostr) {
try { try {
val userPubkey = val userPubkey =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K) val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all DM events // Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms // Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf() val roomsMap: MutableMap<Long, Room> = mutableMapOf()
events events
?.toVec() .map { UnsignedEvent.fromJson(it.content()) }
?.map { UnsignedEvent.fromJson(it.content()) } .filter { it.tags().publicKeys().isNotEmpty() }
?.filter { it.tags().publicKeys().isNotEmpty() } .forEach { rumor ->
?.forEach { event -> val id = rumor.roomId()
val newRoom = Room.new(rumor = event, userPubkey = userPubkey) val isFromMe = rumor.author() == userPubkey
val existingRoom = roomsMap[newRoom.id] val existing = roomsMap[id]
val createdAt = rumor.createdAt()
// Check if the room already exists // If the room is new or the current rumor is newer than the existing one
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
val rTag = SingleLetterTag.lowercase(Alphabet.R) // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val filter = Filter().kind(kind).pubkey(userPubkey) val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
.customTag(rTag, newRoom.id.toString()) val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
// Determine if it's an ongoing room } else if (isFromMe && existing.kind != RoomKind.Ongoing) {
val isOngoing = // If it's an older rumor but sent by the user, mark the room as Ongoing
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
// Append room to map
roomsMap[newRoom.id] =
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
} }
} }

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -7,27 +7,27 @@ 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
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
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,
) )
class AuthViewModel( class AuthViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage, private val storage: AppStorage,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() { ) : BaseViewModel() {
private val mediaRepository = MediaRepository() private val mediaRepository = MediaRepository()
@@ -51,7 +51,7 @@ class AuthViewModel(
private fun checkNotificationBannerDismissedStatus() { private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch { viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) } _state.update { it.copy(isNotificationBannerDismissed = dismissed) }
} }
} }
@@ -59,8 +59,8 @@ class AuthViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
try { try {
val secret = withTimeoutOrNull(3.seconds) { val secret = withTimeoutOrNull(5.seconds) {
secretStore.get(KEY_USER_SIGNER) storage.getSecret(KEY_USER_SIGNER)
} }
if (secret == null) { if (secret == null) {
@@ -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()
@@ -96,48 +94,54 @@ class AuthViewModel(
showError("Logout encountered an error: ${e.message}") showError("Logout encountered an error: ${e.message}")
} finally { } finally {
// Clear credentials from persistent storage // Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER) storage.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED) storage.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) }
} }
} }
} }
fun dismissNotificationBanner() { fun dismissNotificationBanner() {
viewModelScope.launch { viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true") storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) } _state.update { it.copy(isNotificationBannerDismissed = true) }
} }
} }
private suspend fun getOrInitAppKeys(): Keys { private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get(KEY_APP_KEYS) val secret = storage.getSecret(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()) storage.setSecret(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
} storage.setSecret(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
storage.setSecret(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
storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
} }
} }

View File

@@ -65,12 +65,12 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
launch { launch {
nostr.messages.messageSyncState.collect { syncState -> nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list // 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) } _state.update { it.copy(isPartialProcessedGiftWrap = true) }
} }
// Refresh UI every 10 messages OR when sync is fully done // Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms() refreshChatRooms()
} }
} }
@@ -97,9 +97,6 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
_newEvents.emit(event) _newEvents.emit(event)
} }
} }
// Initial load of rooms
refreshChatRooms()
} }
} }
@@ -149,9 +146,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
_state.update { currentState -> _state.update { currentState ->
val merged = currentState.rooms.associateBy { it.id }.toMutableMap() val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database // Add or update rooms from the database
rooms.forEach { room -> rooms.forEach { room -> merged[room.id] = room }
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency // Return as a sorted set to maintain UI consistency
currentState.copy(rooms = merged.values.sortedDescending().toSet()) currentState.copy(rooms = merged.values.sortedDescending().toSet())
} }

View File

@@ -71,9 +71,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty } val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)