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

@@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
}
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -68,6 +72,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@Composable
fun App(
nostrViewModel: NostrViewModel,
authViewModel: AuthViewModel,
) {
val context = LocalContext.current
val activity = context as? ComponentActivity
@@ -76,7 +81,8 @@ fun App(
val qrScanResult = remember { QrScanResult() }
// Get the signer required state
val signerRequired by nostrViewModel.signerRequired.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
@@ -103,7 +109,7 @@ fun App(
}
LaunchedEffect(Unit) {
nostrViewModel.errorEvents.collect { message ->
ErrorManager.errors.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
@@ -147,6 +153,7 @@ fun App(
) {
CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel,
LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,

View File

@@ -27,11 +27,14 @@ class MainActivity : ComponentActivity() {
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val secretStore = SecretStore(this@MainActivity)
private val nostrViewModel =
NostrViewModel(NostrManager.instance, secretStore, androidSigner)
NostrViewModel(NostrManager.instance)
private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}
@@ -39,6 +42,7 @@ class MainActivity : ComponentActivity() {
}
private val nostrViewModel: NostrViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -77,12 +81,13 @@ class MainActivity : ComponentActivity() {
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
nostrViewModel.signerRequired.value == null
authViewModel.state.value.signerRequired == null
}
setContent {
App(
nostrViewModel = nostrViewModel,
authViewModel = authViewModel,
)
}
}

View File

@@ -90,6 +90,7 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
@@ -111,6 +112,7 @@ fun HomeScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
@@ -123,7 +125,9 @@ fun HomeScreen() {
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val isBannerDismissed by nostrViewModel.isNotificationBannerDismissed.collectAsState()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
@@ -275,7 +279,7 @@ fun HomeScreen() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { nostrViewModel.dismissNotificationBanner() },
onClick = { authViewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f),
) {
Text(text = "Maybe later")
@@ -615,6 +619,7 @@ fun HomeScreen() {
fun NewRequests(requests: List<Room>) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
@@ -693,6 +698,7 @@ fun NewRequests(requests: List<Room>) {
@Composable
fun ChatRoom(room: Room, onClick: () -> Unit) {
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val roomState by room.rememberUiState(nostrViewModel)
ListItem(
@@ -740,6 +746,7 @@ fun BottomMenuList(
) {
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
@@ -769,7 +776,7 @@ fun BottomMenuList(
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = { onDismiss { nostrViewModel.logout() } },
onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError

View File

@@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
@@ -74,10 +75,12 @@ fun ImportScreen() {
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
@@ -240,10 +243,10 @@ fun ImportScreen() {
onClick = {
scope.launch {
if (pubkey == null) {
nostrViewModel.verifyIdentity(secret).let { pubkey = it }
authViewModel.verifyIdentity(secret).let { pubkey = it }
} else {
// Import the identity
nostrViewModel.importIdentity(secret)
authViewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.Screen
@@ -13,9 +14,12 @@ import su.reya.coop.shared.ProfileEditor
@Composable
fun NewIdentityScreen() {
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
ProfileEditor(
title = "Create a new identity",
@@ -24,7 +28,7 @@ fun NewIdentityScreen() {
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
nostrViewModel.createIdentity(name, bio, bytes, type)
authViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
}

View File

@@ -45,6 +45,7 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
@@ -58,6 +59,7 @@ fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop)
@@ -158,9 +160,9 @@ fun OnboardingScreen() {
FilledTonalButton(
onClick = {
scope.launch {
if (nostrViewModel.isExternalSignerAvailable()) {
if (authViewModel.isExternalSignerAvailable()) {
try {
nostrViewModel.connectExternalSigner()
authViewModel.connectExternalSigner()
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }

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(
}
}
}