chore: optimize the battery usage (#16)
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -30,6 +30,7 @@ kotlin {
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
|
||||
@@ -38,6 +38,7 @@ import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayCapabilities
|
||||
import rust.nostr.sdk.RelayMessageEnum
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayStatus
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqExitPolicy
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
@@ -59,6 +60,17 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object NostrManager {
|
||||
val instance = Nostr()
|
||||
|
||||
val BOOTSTRAP_RELAYS = listOf(
|
||||
"wss://relay.primal.net",
|
||||
"wss://purplepag.es"
|
||||
)
|
||||
|
||||
val INDEXER_RELAY = listOf(
|
||||
"wss://indexer.coracle.social",
|
||||
)
|
||||
|
||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||
}
|
||||
|
||||
class Nostr {
|
||||
@@ -75,7 +87,6 @@ class Nostr {
|
||||
|
||||
private val isInitialized = MutableStateFlow(false)
|
||||
|
||||
// Add these to the Nostr class
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
@@ -99,12 +110,15 @@ class Nostr {
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||
_contactListUpdates.emit(contacts)
|
||||
|
||||
suspend fun init(dbPath: String) {
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
logLevel: LogLevel = LogLevel.WARN
|
||||
) {
|
||||
try {
|
||||
if (isInitialized.value) return
|
||||
|
||||
// Initialize the logger for nostr client
|
||||
initLogger(LogLevel.DEBUG)
|
||||
initLogger(logLevel)
|
||||
|
||||
// Initialize the database and gossip instance
|
||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||
@@ -141,24 +155,43 @@ class Nostr {
|
||||
}
|
||||
|
||||
suspend fun connectBootstrapRelays() {
|
||||
// Bootstrap relays
|
||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
||||
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
|
||||
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||
client?.addRelay(RelayUrl.parse(url))
|
||||
}
|
||||
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse(url),
|
||||
capabilities = RelayCapabilities.gossip()
|
||||
)
|
||||
}
|
||||
// Connect to all bootstrap relays
|
||||
client?.connect()
|
||||
}
|
||||
|
||||
|
||||
// Indexer relay for NIP-65 discovery
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
||||
capabilities = RelayCapabilities.gossip()
|
||||
)
|
||||
|
||||
// Connect to all bootstrap relays and wait for all connections to be established
|
||||
client?.connect(Duration.parse("2s"))
|
||||
suspend fun reconnect() {
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||
if (relay != null) {
|
||||
if (relay.status() != RelayStatus.CONNECTED) {
|
||||
relay.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to reconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
client?.shutdown()
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.disconnectRelay(RelayUrl.parse(url))
|
||||
} catch (e: Exception) {
|
||||
println("Failed to disconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exit() {
|
||||
@@ -578,7 +611,6 @@ class Nostr {
|
||||
ReqTarget.manual(
|
||||
mapOf(
|
||||
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
||||
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
|
||||
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -50,18 +53,18 @@ class NostrViewModel(
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn = _isLoggedIn.asStateFlow()
|
||||
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||
|
||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
@@ -87,22 +90,32 @@ class NostrViewModel(
|
||||
// Check local stored secret (secret key or bunker)
|
||||
login()
|
||||
|
||||
// Automatically reconnect bootstrap relays
|
||||
reconnect()
|
||||
|
||||
// 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()
|
||||
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { refreshChatRooms() }
|
||||
launch { runObserver() }
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Ensure all relays are disconnect
|
||||
|
||||
// Disconnect to all bootstrap relays
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
@@ -123,81 +136,100 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun runObserver() {
|
||||
private fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private fun processIncomingEvent(event: UnsignedEvent) {
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
nostr.signer.currentUser?.let { user ->
|
||||
val newRoom = Room.new(event, user)
|
||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||
}
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
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 { (it + newRoom).sortedDescending().toSet() }
|
||||
}
|
||||
|
||||
_newEvents.emit(event)
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe metadata updates
|
||||
launch {
|
||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
// Observe contact list updates
|
||||
launch {
|
||||
nostr.contactListUpdates.collect { contacts ->
|
||||
_contactList.value = contacts.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe contact list updates
|
||||
launch {
|
||||
nostr.contactListUpdates.collect { contacts ->
|
||||
_contactList.value = contacts.toSet()
|
||||
}
|
||||
// Observe metadata updates
|
||||
launch {
|
||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
getChatRooms()
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
getChatRooms()
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMetadataBatching() {
|
||||
viewModelScope.launch {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
val batch = mutableSetOf<PublicKey>()
|
||||
val timeout = 500L // 500ms timeout for batching
|
||||
val batch = mutableSetOf<PublicKey>()
|
||||
val timeout = 500L // 500ms timeout for batching
|
||||
|
||||
while (true) {
|
||||
val firstKey = metadataRequestChannel.receive()
|
||||
batch.add(firstKey)
|
||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||
while (true) {
|
||||
val firstKey = metadataRequestChannel.receive()
|
||||
batch.add(firstKey)
|
||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
while (batch.isNotEmpty()) {
|
||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||
metadataRequestChannel.receive()
|
||||
}
|
||||
while (batch.isNotEmpty()) {
|
||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||
metadataRequestChannel.receive()
|
||||
}
|
||||
|
||||
if (nextKey != null) {
|
||||
batch.add(nextKey)
|
||||
}
|
||||
// Only add the key if it's not null
|
||||
if (nextKey != null) batch.add(nextKey)
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
// Get current time
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
nostr.fetchMetadataBatch(keysToRequest)
|
||||
}
|
||||
// Check if the batch is full or timeout has passed
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
|
||||
nostr.fetchMetadataBatch(keysToRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,9 +548,8 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room {
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
?: throw IllegalArgumentException("Room not found")
|
||||
}
|
||||
|
||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||
@@ -560,14 +591,19 @@ class NostrViewModel(
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
||||
val room = getChatRoom(roomId)
|
||||
val members = room.members
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
val members = room.members
|
||||
|
||||
return runCatching {
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
}.getOrElse { e ->
|
||||
return runCatching {
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
}.getOrElse { e ->
|
||||
showError("Error: ${e.message}")
|
||||
members.associateWith { emptyList() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
members.associateWith { emptyList<RelayUrl>() }
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +613,7 @@ class NostrViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.sendMessage(
|
||||
to = room.members,
|
||||
content = message,
|
||||
|
||||
Reference in New Issue
Block a user