From e5f3078c9cd840109c46e0619f35aade347c8532 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 13:36:12 +0700 Subject: [PATCH] unify error handler --- .../androidMain/kotlin/su/reya/coop/App.kt | 11 +- .../kotlin/su/reya/coop/MainActivity.kt | 9 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 13 +- .../su/reya/coop/screens/ImportScreen.kt | 9 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 8 +- .../su/reya/coop/screens/OnboardingScreen.kt | 6 +- .../kotlin/su/reya/coop/AuthViewModel.kt | 287 +++++++++++++++++ .../kotlin/su/reya/coop/BaseViewModel.kt | 46 +++ .../kotlin/su/reya/coop/ErrorManager.kt | 13 + .../kotlin/su/reya/coop/NostrViewModel.kt | 291 +----------------- 10 files changed, 396 insertions(+), 297 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 869ed60..ee7616c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalAuthViewModel = staticCompositionLocalOf { + error("No AuthViewModel provided") +} + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -68,6 +72,7 @@ val LocalScanResult = staticCompositionLocalOf { @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, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index fa3e047..e816366 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -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 create(modelClass: Class): 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, ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index ab8e59f..498e766 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -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) { 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) { @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 diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 707d705..aea7ee5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -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(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) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 51e6783..26e3837 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -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) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index 6ec363d..704a023 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt new file mode 100644 index 0000000..7233789 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt @@ -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 + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt new file mode 100644 index 0000000..c54c3af --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt new file mode 100644 index 0000000..b4fc51e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt @@ -0,0 +1,13 @@ +package su.reya.coop + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object ErrorManager { + private val _errors = Channel(Channel.BUFFERED) + val errors = _errors.receiveAsFlow() + + fun showError(message: String) { + _errors.trySend(message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 5c11eb3..fffec80 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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>>() val sentReport = _sentReports.asSharedFlow() - private val _errorEvents = Channel(Channel.BUFFERED) - val errorEvents = _errorEvents.receiveAsFlow() - private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - 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( } } } -