refactor nostr service

This commit is contained in:
2026-05-28 15:19:45 +07:00
parent e2f4b454b9
commit 37762bb875
9 changed files with 152 additions and 129 deletions

View File

@@ -32,7 +32,8 @@ class NostrForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel() createNotificationChannel()
val notification = createNotification("Connecting to Nostr...")
val notification = createNotification()
startForeground(1, notification) startForeground(1, notification)
serviceScope.launch { serviceScope.launch {
@@ -44,11 +45,25 @@ class NostrForegroundService : Service() {
// Connect to bootstrap relays // Connect to bootstrap relays
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications
nostr.handleLiteNotifications { event -> nostr.handleNotifications(
if (!isUserInApp()) { onMetadataUpdate = { pubkey, metadata ->
showNewMessageNotification(event.roomId(), event.content()) 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) { } catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}") println("Failed to start Nostr in background: ${e.message}")
} }
@@ -59,22 +74,40 @@ class NostrForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = NotificationChannel( val manager = getSystemService(NotificationManager::class.java)
"nostr_service",
"Nostr Background Service", 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 NotificationManager.IMPORTANCE_HIGH
) )
val manager = getSystemService(NotificationManager::class.java) manager?.createNotificationChannel(messageChannel)
manager?.createNotificationChannel(channel)
} }
private fun createNotification(content: String): Notification { private fun createNotification(content: String? = null): Notification {
return NotificationCompat.Builder(this, "nostr_service") val builder = NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("Coop") .setSmallIcon(R.drawable.ic_notification)
.setContentText(content)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setOngoing(true) .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) { private fun showNewMessageNotification(roomId: Long, message: String) {
@@ -90,11 +123,13 @@ class NostrForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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") .setContentTitle("You received a new message")
.setContentText(message) .setContentText(message)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_MESSAGE)
.build() .build()
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -8,9 +8,9 @@ import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
@@ -62,9 +62,6 @@ object NostrManager {
} }
class Nostr { class Nostr {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -76,9 +73,35 @@ class Nostr {
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf() var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private set private set
private val isInitialized = MutableStateFlow(false)
// Add these to the Nostr class
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(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<PublicKey>) =
_contactListUpdates.emit(contacts)
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try { try {
if (_isInitialized.value) return if (isInitialized.value) return
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(LogLevel.DEBUG)
@@ -108,14 +131,14 @@ class Nostr {
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
_isInitialized.value = true isInitialized.value = true
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
} }
} }
suspend fun waitUntilInitialized() { suspend fun waitUntilInitialized() {
_isInitialized.first { it } isInitialized.first { it }
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
@@ -216,61 +239,6 @@ class Nostr {
} }
} }
suspend fun handleLiteNotifications(
onNewMessage: (UnsignedEvent) -> Unit,
) {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
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( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit, onContactListUpdate: (List<PublicKey>) -> Unit,
@@ -293,7 +261,6 @@ class Nostr {
when (val message = notification.message.asEnum()) { when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> { is RelayMessageEnum.EventMsg -> {
val event = message.event val event = message.event
val id = message.subscriptionId
// Prevent processing duplicate events // Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue if (processedEvent.contains(event.id())) continue

View File

@@ -71,11 +71,20 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
startNotificationHandler() // Check local stored secret (secret key or bunker)
startMetadataBatchHandler()
getCacheMetadata()
login() login()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays() 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() { override fun onCleared() {
@@ -95,47 +104,53 @@ class NostrViewModel(
} }
} }
private fun startNotificationHandler() { private fun runObserver() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Observe new messages
nostr.waitUntilInitialized() launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
nostr.handleNotifications( if (existingRoom == null) {
onMetadataUpdate = { pubkey, metadata -> 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) updateMetadata(pubkey, metadata)
}, }
onContactListUpdate = { contactList -> }
_contactList.value = contactList.toSet()
}, // Observe contact list updates
onSubscriptionClose = { launch {
nostr.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms() getChatRooms()
_isPartialProcessedGiftWrap.value = true _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 { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -210,8 +225,7 @@ class NostrViewModel(
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null)
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote) nostr.setSigner(remote)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
@@ -435,11 +449,13 @@ class NostrViewModel(
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
val currentUser = nostr.signer.currentUser!!
// Construct the rumor event // Construct the rumor event
val rumor = EventBuilder val rumor = EventBuilder
.privateMsgRumor(to.first(), "") .privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) }) .tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!) .build(currentUser)
// Check if the room already exists // Check if the room already exists
val id = rumor.roomId() val id = rumor.roomId()
@@ -451,7 +467,7 @@ class NostrViewModel(
} }
// Create a room from the rumor event // 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 // Update the chat rooms state
_chatRooms.update { currentRooms -> _chatRooms.update { currentRooms ->
@@ -546,13 +562,18 @@ class NostrViewModel(
} }
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.value = _chatRooms.value.map { room -> _chatRooms.update { currentRooms ->
if (room.id == roomId) { currentRooms.map { room ->
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) if (room.id == roomId) {
} else { room.copy(
room lastMessage = newMessage.content(),
} createdAt = newMessage.createdAt()
}.toSet() )
} else {
room
}
}.sortedDescending().toSet()
}
} }
suspend fun searchByAddress(query: String): PublicKey? { suspend fun searchByAddress(query: String): PublicKey? {

View File

@@ -40,10 +40,10 @@ data class Room(
val subject = rumor.tags().find(TagKind.Subject)?.content() val subject = rumor.tags().find(TagKind.Subject)?.content()
// Collect the author's public key and all public keys from tags // 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<PublicKey> = mutableSetOf() val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author()) pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys()) pubkeys.addAll(rumor.tags().publicKeys())
// Also remove the user's public key from the list, current user is always a member
pubkeys.remove(userPubkey) pubkeys.remove(userPubkey)
// Create a new Room instance // Create a new Room instance