2 Commits

Author SHA1 Message Date
9a8d3c06fa update 2026-07-10 10:06:54 +07:00
5970acc88b clean up view models 2026-07-10 08:41:44 +07:00
12 changed files with 219 additions and 179 deletions

View File

@@ -34,7 +34,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository
import kotlinx.coroutines.launch
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
@@ -118,8 +118,20 @@ fun App(
}
LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message ->
snackbarHostState.showSnackbar(message)
launch {
authViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
nostrViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}

View File

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

View File

@@ -34,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,10 +43,10 @@ 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.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.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
@@ -65,12 +64,12 @@ fun ImportScreen() {
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
@@ -98,6 +97,20 @@ fun ImportScreen() {
}
}
// Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(authState.signerRequired) {
if (authState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show import errors via snackbar
LaunchedEffect(authState.importError) {
authState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -164,7 +177,7 @@ fun ImportScreen() {
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !loading,
enabled = !authState.isImporting,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -209,7 +222,7 @@ fun ImportScreen() {
BasicTextField(
value = password,
onValueChange = { password = it },
enabled = !loading && requirePassword,
enabled = !authState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -237,25 +250,14 @@ fun ImportScreen() {
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
scope.launch {
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
}
}
authViewModel.importIdentity(secret, password)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !loading,
enabled = secret.isNotBlank() && !authState.isImporting,
) {
if (loading) {
if (authState.isImporting) {
LoadingIndicator()
} else {
Text(

View File

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -44,6 +46,7 @@ import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
@@ -58,8 +61,24 @@ fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current
val authState by authViewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
LaunchedEffect(authState.signerRequired) {
if (authState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show connection errors
LaunchedEffect(authState.importError) {
authState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
@@ -157,18 +176,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton(
onClick = {
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) }
}
} else {
if (authViewModel.isExternalSignerAvailable()) {
// Connect to the external signer
// TODO: show all available signers?
authViewModel.connectExternalSigner()
} else {
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install",

View File

@@ -120,20 +120,16 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri ->
scope.launch {
try {
// Read file
val file = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
// Read file on IO dispatcher
val file = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
chatViewModel.sendFileMessage(id, file, type)
}
}

View File

@@ -69,7 +69,7 @@ fun ProfileEditor(
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
onBack: () -> Unit,
onConfirm: suspend (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current

View File

@@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
/**
* Switch to a new signer.
*/
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
val pubkey = try {
withTimeoutOrNull(20.seconds) {
newSigner.getPublicKeyAsync()
}
} catch (e: Exception) {
throw IllegalStateException("Failed to get public key from signer", e)
suspend fun switch(newSigner: AsyncNostrSigner) {
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
?: throw IllegalStateException("Failed to get public key from signer")
mutex.withLock {
signer = newSigner
_publicKeyFlow.value = pubkey
}
signer = newSigner
_publicKeyFlow.value = pubkey
}
override suspend fun getPublicKeyAsync(): PublicKey? {

View File

@@ -1,13 +0,0 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -23,15 +23,16 @@ import kotlin.time.Duration.Companion.seconds
data class AuthState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AuthViewModel(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
@@ -161,65 +162,98 @@ class AuthViewModel(
}
}
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
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
fun connectExternalSigner() {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler = externalSignerHandler
?: throw IllegalStateException("Signer not available")
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 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)
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
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_state.update { it.copy(signerRequired = false) }
// 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, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun createIdentity(
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
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
storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
// 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

@@ -1,10 +1,14 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
abstract class BaseViewModel : ViewModel() {
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 10)
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) {
ErrorRepository.showError(message)
_errorEvents.tryEmit(message)
}
}

View File

@@ -1,6 +1,7 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -29,9 +30,10 @@ data class ChatState(
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
class ChatViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _state = MutableStateFlow(ChatState())
val state = combine(
_state,
@@ -144,6 +146,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -175,6 +179,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
return
}
viewModelScope.launch {
try {
@@ -195,7 +200,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
}
}
suspend fun sendFileMessage(
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
@@ -203,11 +208,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
) {
if (file == null) return
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
viewModelScope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${e.message}")
}
}
}

View File

@@ -2,7 +2,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -10,11 +9,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -39,30 +36,12 @@ data class NostrAppState(
val isRelayListEmpty: Boolean = false,
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
class NostrViewModel(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
) : BaseViewModel() {
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> =
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
val appState: StateFlow<NostrAppState> = _appState.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
@@ -83,6 +62,10 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Launch continuous background observers
viewModelScope.launch { runObserver() }
viewModelScope.launch { runMetadataBatching() }
// Automatically reconnect bootstrap relays
reconnect()
@@ -116,7 +99,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
}
}
private suspend fun runMetadataBatching() = coroutineScope {
private suspend fun runMetadataBatching() {
// Wait until the client is ready
nostr.waitUntilInitialized()
@@ -192,9 +175,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
metadataRequestChannel.trySend(pubkey)
}
}
@@ -224,32 +205,36 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
suspend fun updateProfile(
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
viewModelScope.launch {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_appState.update { it.copy(isBusy = false) }
}
}
}