From e824aa7e16a83080b20754bc40dcb562ab29bd19 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 9 May 2026 09:07:27 +0700 Subject: [PATCH] fix --- .../androidMain/kotlin/su/reya/coop/App.kt | 1 + .../su/reya/coop/screens/ImportScreen.kt | 108 +++++++++++++----- .../su/reya/coop/screens/NewIdentityScreen.kt | 2 +- gradle/libs.versions.toml | 5 + shared/build.gradle.kts | 10 +- .../kotlin/su/reya/coop/CoopWebSocket.kt | 75 ++++++++++++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 80 +++++++------ .../kotlin/su/reya/coop/NostrViewModel.kt | 22 ++-- 8 files changed, 222 insertions(+), 81 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ef39dc8..9be31f4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -111,6 +111,7 @@ fun App(dbPath: String) { ImportScreen( isLoading = isCreating, + onBack = { navController.popBackStack() }, onSave = { secret -> viewModel.importIdentity(secret) } 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 7100c1e..9078a4e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -5,14 +5,25 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -21,43 +32,84 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportScreen( isLoading: Boolean, + onBack: () -> Unit, onSave: (secret: String) -> Unit ) { + val snackbarHostState = LocalSnackbarHostState.current var secret by remember { mutableStateOf("") } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - OutlinedTextField( - value = secret, - onValueChange = { secret = it }, - label = { Text("Enter nsec or bunker") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(secret) - }, - modifier = Modifier.fillMaxWidth(), - enabled = secret.isNotBlank() && !isLoading, - ) { - if (isLoading) { - LoadingIndicator() - } else { - Text("Save & Continue") + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("Import") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + ) + }, + content = { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text("Enter nsec or bunker") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(secret) + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.LargeContainerHeight), + enabled = secret.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text( + text = "Save & Continue", + style = MaterialTheme.typography.titleLargeEmphasized, + ) + } + } + } } } - } + ) } 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 2566071..ca02941 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -75,7 +75,7 @@ fun NewIdentityScreen( IconButton(onClick = onBack) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), - contentDescription = "User" + contentDescription = "Back" ) } }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cbba71..dc494c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ junit = "4.13.2" kotlin = "2.3.20" kotlinx-serialization = "1.8.0" material3 = "1.10.0-alpha05" +ktor = "3.4.3" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -36,6 +37,10 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index dc3593a..796f8a2 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -26,7 +26,15 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("su.reya:nostr-sdk-kmp:0.1.2") + implementation("su.reya:nostr-sdk-kmp:0.1.5") + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.websockets) + } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt b/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt new file mode 100644 index 0000000..ff2a658 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt @@ -0,0 +1,75 @@ +package su.reya.coop + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.url +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readBytes +import io.ktor.websocket.readText +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import rust.nostr.sdk.ConnectionMode +import rust.nostr.sdk.CustomWebSocketTransport +import rust.nostr.sdk.WebSocketAdapter +import rust.nostr.sdk.WebSocketAdapterWrapper +import rust.nostr.sdk.WebSocketMessage + +class KtorWebSocketAdapter( + private val client: HttpClient, + private val session: DefaultClientWebSocketSession +) : WebSocketAdapter { + + override suspend fun send(msg: WebSocketMessage) { + try { + when (msg) { + is WebSocketMessage.Text -> session.send(Frame.Text(msg.text)) + is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes)) + is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes)) + is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes)) + else -> {} + } + } catch (e: Exception) { + println("Attempted to send on a closed WebSocket: ${e.message}") + throw e + } + } + + override suspend fun recv(): WebSocketMessage? { + return try { + when (val frame = session.incoming.receive()) { + is Frame.Text -> WebSocketMessage.Text(frame.readText()) + is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes()) + is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes()) + is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes()) + else -> null + } + } catch (e: ClosedReceiveChannelException) { + null + } catch (e: Exception) { + throw e + } + } + + override suspend fun closeConnection() { + session.cancel() + session.close() + } +} + +class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport { + override fun supportPing(): Boolean = false + + override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper { + try { + val session = httpClient.webSocketSession { + url(url) + } + val adapter = KtorWebSocketAdapter(httpClient, session) + return WebSocketAdapterWrapper(adapter) + } catch (e: Exception) { + throw e + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bda2061..7a26456 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -1,5 +1,7 @@ package su.reya.coop +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientNotification @@ -12,6 +14,7 @@ import rust.nostr.sdk.GossipConfig import rust.nostr.sdk.Keys import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.LogLevel import rust.nostr.sdk.Metadata import rust.nostr.sdk.MetadataRecord import rust.nostr.sdk.NostrConnect @@ -32,6 +35,7 @@ import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.extractMessagingRelayList +import rust.nostr.sdk.initLogger import kotlin.time.Duration class Nostr { @@ -47,39 +51,37 @@ class Nostr { private set suspend fun init(dbPath: String) { - val lmdb = NostrDatabase.lmdb(dbPath) - val gossip = NostrGossip.inMemory() - val idleTimeout = Duration.parse("5m") - - client = - ClientBuilder() - .database(lmdb) - .gossip(gossip) - .gossipConfig(GossipConfig().noBackgroundRefresh()) - .maxRelays(20u) - .verifySubscriptions(false) - .automaticAuthentication(false) - .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) - .build() - } - - suspend fun connect() { try { - client?.addRelay( - url = RelayUrl.parse("wss://relay.primal.net"), - capabilities = RelayCapabilities.none() - ) - client?.addRelay( - url = RelayUrl.parse("wss://user.kindpag.es"), - capabilities = RelayCapabilities.none() - ) + // Initialize the logger for nostr client + initLogger(LogLevel.DEBUG) + + val lmdb = NostrDatabase.lmdb(dbPath) + val gossip = NostrGossip.inMemory() + val idleTimeout = Duration.parse("5m") + val httpClient = HttpClient { + install(WebSockets) + } + + client = + ClientBuilder() + .websocketTransport(CoopWebSocketClient(httpClient)) + .database(lmdb) + .gossip(gossip) + .gossipConfig(GossipConfig().noBackgroundRefresh()) + .verifySubscriptions(false) + .automaticAuthentication(false) + .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) + .build() + + client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) + client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), capabilities = RelayCapabilities.gossip() ) - client?.connect() + client?.connect(Duration.parse("10s")) } catch (e: Exception) { - println("Failed to connect to relays: ${e.message}") + println("Failed to initialize client: ${e.message}") } } @@ -98,6 +100,8 @@ class Nostr { try { signer = NostrSigner.keys(keys) userPubkey = signer?.getPublicKey() + + // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { println("Failed to set signer: ${e.message}") @@ -108,6 +112,8 @@ class Nostr { try { signer = NostrSigner.nostrConnect(remote) userPubkey = signer?.getPublicKey() + + // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { println("Failed to set remote signer: ${e.message}") @@ -123,19 +129,17 @@ class Nostr { } suspend fun getUserMetadata() { - val userPubkey = signer?.getPublicKey() ?: return - // Get the latest metadata event val metadataFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) // Get the latest contact list event val contactFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) // Get the latest messaging relay list event val msgRelayFilter = - Filter().author(userPubkey).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) // Construct a target that includes all filters val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) @@ -170,11 +174,11 @@ class Nostr { suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() - val notifications = client?.notifications() val processedEvent = mutableSetOf() - + val notifications = client?.notifications() ?: return + while (true) { - val notification = notifications?.next() ?: break + val notification = notifications.next() ?: continue when (notification) { is ClientNotification.Message -> { @@ -189,7 +193,7 @@ class Nostr { if (processedEvent.contains(event.id())) continue processedEvent.add(event.id()) - if (event.kind().asStd() == KindStandard.METADATA) { + if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) { try { val metadata = Metadata.fromJson(event.content()) onMetadataUpdate(event.author(), metadata) @@ -198,13 +202,13 @@ class Nostr { } } - if (event.kind().asStd() == KindStandard.INBOX_RELAYS) { + if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } } - if (event.kind().asStd() == KindStandard.GIFT_WRAP) { + if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { try { val rumor = extractRumor(event) // TODO: Handle rumor diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 82e55f5..ca6d014 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -101,21 +101,17 @@ class NostrViewModel( } fun getUserProfile(): StateFlow { - return getMetadata(nostr.userPubkey!!) + return nostr.userPubkey?.let { getMetadata(it) } ?: MutableStateFlow(null).asStateFlow() } - fun initAndConnect(dbPath: String) { - viewModelScope.launch { - try { - // Initialize nostr client - nostr.init(dbPath) - // Connect to bootstrap relays - nostr.connect() - // Get user's secret - getUserSecret() - } catch (e: Exception) { - showError("Failed to initialize Nostr: ${e.message}") - } + suspend fun initAndConnect(dbPath: String) { + try { + // Initialize nostr client + nostr.init(dbPath) + // Get user's secret + getUserSecret() + } catch (e: Exception) { + showError("Failed to initialize Nostr: ${e.message}") } }