From 37762bb8757ac47518cdb92489b929e3fd8e82ab Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 28 May 2026 15:19:45 +0700 Subject: [PATCH] refactor nostr service --- .../su/reya/coop/NostrForegroundService.kt | 69 ++++++++--- .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 551 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 391 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 727 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 1124 bytes .../res/drawable-xxxhdpi/ic_notification.png | Bin 0 -> 1490 bytes .../commonMain/kotlin/su/reya/coop/Nostr.kt | 95 +++++---------- .../kotlin/su/reya/coop/NostrViewModel.kt | 115 +++++++++++------- .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- 9 files changed, 152 insertions(+), 129 deletions(-) create mode 100644 composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index fff1989..54a2349 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -32,7 +32,8 @@ class NostrForegroundService : Service() { @RequiresApi(Build.VERSION_CODES.O) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { createNotificationChannel() - val notification = createNotification("Connecting to Nostr...") + + val notification = createNotification() startForeground(1, notification) serviceScope.launch { @@ -44,11 +45,25 @@ class NostrForegroundService : Service() { // Connect to bootstrap relays nostr.connectBootstrapRelays() // Handle notifications - nostr.handleLiteNotifications { event -> - if (!isUserInApp()) { - showNewMessageNotification(event.roomId(), event.content()) + nostr.handleNotifications( + onMetadataUpdate = { pubkey, metadata -> + serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) } + }, + onContactListUpdate = { contacts -> + serviceScope.launch { nostr.emitContactListUpdate(contacts) } + }, + onSubscriptionClose = { + serviceScope.launch { nostr.emitSubscriptionClosed() } + }, + onNewMessage = { event -> + serviceScope.launch { + if (!isUserInApp()) { + showNewMessageNotification(event.roomId(), event.content()) + } + nostr.emitNewEvent(event) + } } - } + ) } catch (e: Exception) { println("Failed to start Nostr in background: ${e.message}") } @@ -59,22 +74,40 @@ class NostrForegroundService : Service() { @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel() { - val channel = NotificationChannel( - "nostr_service", - "Nostr Background Service", + val manager = getSystemService(NotificationManager::class.java) + + val serviceChannel = NotificationChannel( + "nostr_service_silent", + "Nostr Background Status", + NotificationManager.IMPORTANCE_MIN + ).apply { + setShowBadge(false) + } + manager?.createNotificationChannel(serviceChannel) + + val messageChannel = NotificationChannel( + "nostr_messages", + "New Messages", NotificationManager.IMPORTANCE_HIGH ) - val manager = getSystemService(NotificationManager::class.java) - manager?.createNotificationChannel(channel) + manager?.createNotificationChannel(messageChannel) } - private fun createNotification(content: String): Notification { - return NotificationCompat.Builder(this, "nostr_service") - .setContentTitle("Coop") - .setContentText(content) - .setSmallIcon(android.R.drawable.ic_menu_send) + private fun createNotification(content: String? = null): Notification { + val builder = NotificationCompat.Builder(this, "nostr_service") + .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) - .build() + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_SERVICE) + + if (content != null) { + builder.setContentTitle("Coop") + builder.setContentText(content) + } else { + builder.setContentTitle("Coop is active") + } + + return builder.build() } private fun showNewMessageNotification(roomId: Long, message: String) { @@ -90,11 +123,13 @@ class NostrForegroundService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(this, "nostr_service") + val notification = NotificationCompat.Builder(this, "nostr_messages") + .setSmallIcon(R.drawable.ic_notification) .setContentTitle("You received a new message") .setContentText(message) .setAutoCancel(true) .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_MESSAGE) .build() val manager = getSystemService(NotificationManager::class.java) diff --git a/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..c661ce60fd9bf798af1084c0de581b82355cb52c GIT binary patch literal 551 zcmV+?0@(eDP)nS?G-dZBXQ{uD}$B zt~eg1k<9_ewlr-B`xUQkq~y{uh+0@S#(BcfkjR0>|!0Y4#nUyf3(84RX9LsTB1 zeQBSy%H2jVm}jRW&biwN221R8M1#AHV6ehYN3^)x2nNgSbVQT8eTe?N+JJUMpB+sH zQX736T7xEe4L&?WKPYpz5z!}TKd5lG5ezoi>4*|{8^PciI~|edZX+06VW$Jssh#F- zBN)th2A5oLZ%1ILae`%LxU(ZL>0K?;H(jxM4@QG`xT;^e>3$wgNuLC`@QMco9Qq^Je) z>-gLTF3FpwO>aPa;2F~Mo;M|FZ>jWOFtbs-$8)^K1J3S2ht^EtlVgonjvu*A5Fsw*8gfs2+FkI1e01fT+FkIDi z0Pot{g24_RS@g?j2I%&-a9X=tF!-|KtZA=@;e)0FsA{jrqQ_H|aqaamT-0=rbUs}P z3|hX!EsJ^WOhITCKXfw(2ild1MQ4jU>+^DoW$oz*3v+mv>`CFi#2yamelFZ1F5@vi l;kQM9s4LvX3ElZ~egRmt${xy6e18A{002ovPDHLkV1lAYwlDwy literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..71caf070ff8661c06d1cf8645ed6f61c7586b0f9 GIT binary patch literal 727 zcmV;|0x127P)Nkl?gF6oOoM)Q3N!SF&0cd}Bx;X2%8jdx_9g&ytZ1%ul)EfIa%%?k!kXi_3BYd0?# zysb%zc%|LEVDP0TC1OImdBI>!lM*qh-MnCMRFe|%NxRj>+!g0m+=lz`J$|X!8eTMa z*EWc&wP#(-U6Z#07qpuf=1v@~z_@nvg25k}l!!6y<^_XqH7OAz+RY0F?`TpY&S^I< z7(Aj$iP)pvys&uJ|3%0SB+Ip%7YugbM+vN&$J(n91RU1HirKB53PHdUb2l6*7`G6Q zR0uhs$rZ6vyEFwsbMdLBN9HEBYnP@m_w@OzEQ!TCT&f*PWA3n;ed`#L&$v=|2ZQhp z=Dw7O!3XTsoxx$r3Vb2U%QAx9x+@wi>oRwaD0{`4{WtrtLw8gHi@Wipxx1$#WM}w8 z7UTJUGnjiYo85k%!Ut0Yr=MbHPupsE*WA}f+2<(DUwoEG`~@PvU14HRbld;{002ov JPDHLkV1jT=TKoV2 literal 0 HcmV?d00001 diff --git a/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..e7523a5665633abe6485329262e9e259f250f407 GIT binary patch literal 1124 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 literal 0 HcmV?d00001 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 54b5a70..6204303 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -8,9 +8,9 @@ import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import rust.nostr.sdk.AckPolicy @@ -62,9 +62,6 @@ object NostrManager { } class Nostr { - private val _isInitialized = MutableStateFlow(false) - val isInitialized: StateFlow = _isInitialized.asStateFlow() - var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -76,9 +73,35 @@ class Nostr { var rumorMap: MutableMap = mutableMapOf() private set + private val isInitialized = MutableStateFlow(false) + + // Add these to the Nostr class + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _metadataUpdates = + MutableSharedFlow>(extraBufferCapacity = 100) + val metadataUpdates = _metadataUpdates.asSharedFlow() + + private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + val contactListUpdates = _contactListUpdates.asSharedFlow() + + private val _subscriptionClosed = MutableSharedFlow(extraBufferCapacity = 10) + val subscriptionClosed = _subscriptionClosed.asSharedFlow() + + suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event) + + suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit) + + suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) = + _metadataUpdates.emit(pubkey to metadata) + + suspend fun emitContactListUpdate(contacts: List) = + _contactListUpdates.emit(contacts) + suspend fun init(dbPath: String) { try { - if (_isInitialized.value) return + if (isInitialized.value) return // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) @@ -108,14 +131,14 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() - _isInitialized.value = true + isInitialized.value = true } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } suspend fun waitUntilInitialized() { - _isInitialized.first { it } + isInitialized.first { it } } suspend fun connectBootstrapRelays() { @@ -216,61 +239,6 @@ class Nostr { } } - suspend fun handleLiteNotifications( - onNewMessage: (UnsignedEvent) -> Unit, - ) { - val now = Timestamp.now() - val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return - - while (true) { - val notification = notifications.next() ?: continue - - when (notification) { - is ClientNotification.Message -> { - val relayUrl = notification.relayUrl - - when (val message = notification.message.asEnum()) { - is RelayMessageEnum.EventMsg -> { - val event = message.event - val subscriptionId = message.subscriptionId - - // Ignore events not from the newest gift wraps subscription - if (subscriptionId != "newest-gift-wraps") continue - - // Prevent processing duplicate events - if (processedEvent.contains(event.id())) continue - processedEvent.add(event.id()) - - if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { - try { - val rumor = extractRumor(event) - - // Handle new message - rumor?.createdAt()?.asSecs()?.let { - if (it >= now.asSecs()) { - onNewMessage(rumor) - } - } - } catch (e: Exception) { - println("Failed to extract rumor: $e") - } - } - } - - else -> { - /* Ignore other event kinds */ - } - } - } - - else -> { - /* Ignore other message types */ - } - } - } - } - suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, onContactListUpdate: (List) -> Unit, @@ -293,7 +261,6 @@ class Nostr { when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event - val id = message.subscriptionId // Prevent processing duplicate events if (processedEvent.contains(event.id())) continue diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 80a687e..b353f76 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -71,11 +71,20 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() init { - startNotificationHandler() - startMetadataBatchHandler() - getCacheMetadata() + // Check local stored secret (secret key or bunker) login() + + // Observe the signer state and verify the relay list observeSignerAndCheckRelays() + + // Get all local stored metadata + getCacheMetadata() + + // Observe new events from the Nostr client + runObserver() + + // Wait and merge metadata requests into a single batch + runMetadataBatching() } override fun onCleared() { @@ -95,47 +104,53 @@ class NostrViewModel( } } - private fun startNotificationHandler() { + private fun runObserver() { viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() + // Observe new messages + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - nostr.handleNotifications( - onMetadataUpdate = { pubkey, metadata -> + if (existingRoom == null) { + val currentUser = nostr.signer.currentUser + if (currentUser != null) { + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + + // Observe metadata updates + launch { + nostr.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, metadata) - }, - onContactListUpdate = { contactList -> - _contactList.value = contactList.toSet() - }, - onSubscriptionClose = { + } + } + + // Observe contact list updates + launch { + nostr.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + + // Observes subscription close + launch { + nostr.subscriptionClosed.collect { getChatRooms() _isPartialProcessedGiftWrap.value = true - }, - onNewMessage = { event -> - viewModelScope.launch { - val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - - if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { currentRooms -> - currentRooms + newRoom - } - } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } - }, - ) + } + } } } - private fun startMetadataBatchHandler() { + private fun runMetadataBatching() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() @@ -210,8 +225,7 @@ class NostrViewModel( val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) val timeout = Duration.parse("50s") // 50 seconds timeout - val remote = - NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null) nostr.setSigner(remote) } catch (e: Exception) { showError("Error: ${e.message}") @@ -435,11 +449,13 @@ class NostrViewModel( if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + val currentUser = nostr.signer.currentUser!! + // Construct the rumor event val rumor = EventBuilder .privateMsgRumor(to.first(), "") .tags(to.map { Tag.publicKey(it) }) - .build(nostr.signer.currentUser!!) + .build(currentUser) // Check if the room already exists val id = rumor.roomId() @@ -451,7 +467,7 @@ class NostrViewModel( } // Create a room from the rumor event - val room = Room.new(rumor, nostr.signer.currentUser!!) + val room = Room.new(rumor, currentUser) // Update the chat rooms state _chatRooms.update { currentRooms -> @@ -546,13 +562,18 @@ class NostrViewModel( } private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.value = _chatRooms.value.map { room -> - if (room.id == roomId) { - room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) - } else { - room - } - }.toSet() + _chatRooms.update { currentRooms -> + currentRooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + } } suspend fun searchByAddress(query: String): PublicKey? { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 3e2ff19..373f787 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -40,10 +40,10 @@ data class Room( val subject = rumor.tags().find(TagKind.Subject)?.content() // Collect the author's public key and all public keys from tags - // Also remove the user's public key from the list, current user is always a member val pubkeys: MutableSet = mutableSetOf() pubkeys.add(rumor.author()) pubkeys.addAll(rumor.tags().publicKeys()) + // Also remove the user's public key from the list, current user is always a member pubkeys.remove(userPubkey) // Create a new Room instance