feat: implement basic notification #6

Merged
reya merged 6 commits from feat/notifications into master 2026-05-29 06:56:48 +00:00
9 changed files with 152 additions and 129 deletions
Showing only changes of commit 37762bb875 - Show all commits

View File

@@ -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 ->
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)

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.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<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -76,9 +73,35 @@ class Nostr {
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
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) {
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<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(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> 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

View File

@@ -71,11 +71,20 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>()
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,24 +104,11 @@ class NostrViewModel(
}
}
private fun startNotificationHandler() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
},
onNewMessage = { event ->
private fun runObserver() {
viewModelScope.launch {
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
@@ -120,9 +116,7 @@ class NostrViewModel(
val currentUser = nostr.signer.currentUser
if (currentUser != null) {
val newRoom = Room.new(event, currentUser)
_chatRooms.update { currentRooms ->
currentRooms + newRoom
}
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
@@ -130,12 +124,33 @@ class NostrViewModel(
_newEvents.emit(event)
}
},
)
}
// Observe metadata updates
launch {
nostr.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
}
}
private fun startMetadataBatchHandler() {
// Observe contact list updates
launch {
nostr.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
}
}
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 ->
_chatRooms.update { currentRooms ->
currentRooms.map { room ->
if (room.id == roomId) {
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt())
room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else {
room
}
}.toSet()
}.sortedDescending().toSet()
}
}
suspend fun searchByAddress(query: String): PublicKey? {

View File

@@ -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<PublicKey> = 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