unify error handler

This commit is contained in:
2026-07-02 13:36:12 +07:00
parent 666f5c1795
commit e5f3078c9c
10 changed files with 396 additions and 297 deletions

View File

@@ -0,0 +1,287 @@
package su.reya.coop
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
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,
)
class AuthViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(3.seconds) {
secretStore.get(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
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()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// 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) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
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)
}
// 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 {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null)
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
try {
val signer = createSigner(secret)
if (secret.startsWith("bunker://")) {
showError("Please approve the connection.")
}
return signer.getPublicKeyAsync()
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun importIdentity(secret: String) {
_state.update { it.copy(isBusy = true) }
try {
val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_state.update { it.copy(isBusy = true) }
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
_state.update { it.copy(isBusy = false) }
showError("Notice: ${e.message}")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun createIdentity(
name: String,
bio: String?,
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 { blossomUpload(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) }
}
}
private suspend fun blossomUpload(
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
try {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
}

View File

@@ -0,0 +1,46 @@
package su.reya.coop
import androidx.lifecycle.ViewModel
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
abstract class BaseViewModel : ViewModel() {
protected val httpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
}
protected fun showError(message: String) {
ErrorManager.showError(message)
}
protected suspend fun blossomUpload(
signer: AsyncNostrSigner,
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: Exception) {
showError("Upload failed: ${e.message}")
null
}
}
}

View File

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

View File

@@ -1,10 +1,6 @@
package su.reya.coop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
@@ -22,7 +18,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -30,27 +25,17 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -60,21 +45,9 @@ data class NostrAppState(
val isRelayListEmpty: Boolean = false,
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false,
val isNotificationBannerDismissed: Boolean = false,
val signerRequired: Boolean? = null,
)
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val backgroundWorkTrigger = flow {
coroutineScope {
val observerJob = launch { runObserver() }
@@ -114,18 +87,11 @@ class NostrViewModel(
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isNotificationBannerDismissed = appState.map { it.isNotificationBannerDismissed }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val signerRequired = appState.map { it.signerRequired }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap }
@@ -143,12 +109,6 @@ class NostrViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
// Automatically reconnect bootstrap relays
reconnect()
@@ -170,19 +130,6 @@ class NostrViewModel(
}
}
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
}
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
_appState.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
@@ -292,34 +239,6 @@ class NostrViewModel(
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(3.seconds) {
secretStore.get(KEY_USER_SIGNER)
}
if (secret == null) {
_appState.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_appState.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_appState.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_appState.update { it.copy(signerRequired = true) }
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
while (true) {
@@ -374,95 +293,21 @@ class NostrViewModel(
return flow.asStateFlow()
}
fun logout() {
viewModelScope.launch {
try {
_appState.update { it.copy(isBusy = true) }
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED)
// Reset all UI states
resetInternalState()
_appState.update { it.copy(isBusy = false, signerRequired = true) }
}
}
}
private fun resetInternalState() {
fun resetInternalState() {
_chatRooms.value = emptySet()
_contactList.value = emptySet()
_appState.update {
it.copy(
isPartialProcessedGiftWrap = false,
isRelayListEmpty = false,
isNotificationBannerDismissed = false
)
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true")
_appState.update { it.copy(isNotificationBannerDismissed = true) }
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
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)
}
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
}
suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? {
try {
// Upload picture to Blossom
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
@@ -471,11 +316,14 @@ class NostrViewModel(
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val avatarUrl =
picture?.let { 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 local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
@@ -483,126 +331,6 @@ class NostrViewModel(
}
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_appState.update { it.copy(isBusy = false, signerRequired = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds // or Duration.parse("50s")
NostrConnect(uri = bunker, appKeys, timeout, null)
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey)
}
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) {
_appState.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
_appState.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_appState.update { it.copy(isBusy = true) }
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_appState.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
throw Exception("Notice: ${e.message}")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun refetchMsgRelays() {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
@@ -872,10 +600,8 @@ class NostrViewModel(
if (file == null) return
try {
val uri = blossomUpload(file, contentType)
?: throw IllegalArgumentException("Failed to upload file")
sendMessage(roomId, uri, replies)
val uri = blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
@@ -952,4 +678,3 @@ class NostrViewModel(
}
}
}