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 0000000..c661ce6 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..3baa6d2 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png differ 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 0000000..71caf07 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png differ 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 0000000..e7523a5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..afc41ad Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png differ 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