optimize viewmodel

This commit is contained in:
2026-06-06 16:33:08 +07:00
parent 550fd3e527
commit 864b7f2a4e
5 changed files with 96 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ package su.reya.coop
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -31,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@@ -93,8 +94,8 @@ fun App(viewModel: NostrViewModel) {
val navigator = remember(backStack) { Navigator(backStack) } val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsState(initial = null) val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -105,7 +106,7 @@ fun App(viewModel: NostrViewModel) {
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context context
) )

View File

@@ -1,13 +1,13 @@
package su.reya.coop package su.reya.coop
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore import su.reya.coop.coop.storage.SecretStore
@@ -50,18 +50,16 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java) val serviceIntent = Intent(this, NostrForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent) startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null viewModel.signerRequired.value == null
} }
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent { setContent {
App(viewModel = viewModel) App(viewModel = viewModel)
} }

View File

@@ -30,6 +30,7 @@ kotlin {
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose) 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-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3") implementation("su.reya:nostr-sdk-kmp:0.2.3")

View File

@@ -63,7 +63,6 @@ object NostrManager {
val BOOTSTRAP_RELAYS = listOf( val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://purplepag.es" "wss://purplepag.es"
) )
@@ -612,7 +611,6 @@ class Nostr {
ReqTarget.manual( ReqTarget.manual(
mapOf( mapOf(
RelayUrl.parse("wss://purplepag.es") to listOf(filter), 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), RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
) )
) )

View File

@@ -1,19 +1,26 @@
package su.reya.coop package su.reya.coop
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -50,12 +57,6 @@ class NostrViewModel(
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.asStateFlow() 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) private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -74,6 +75,28 @@ class NostrViewModel(
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>() private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
private val manualRoomUpdates = MutableSharedFlow<Set<Room>>()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms: StateFlow<Set<Room>> = merge(
nostr.newEvents.map { event ->
processIncomingEvent(event)
_chatRooms.value
},
manualRoomUpdates
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptySet()
)
val contactList: StateFlow<Set<PublicKey>> = nostr.contactListUpdates
.map { it.toSet() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptySet()
)
init { init {
// Skip the splash screen if a user is already logged in // Skip the splash screen if a user is already logged in
@@ -95,12 +118,18 @@ class NostrViewModel(
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
}
// Observe new events from the Nostr client fun bindLifecycle(lifecycle: Lifecycle) {
runObserver() viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Wait and merge metadata requests into a single batch coroutineScope {
runMetadataBatching() launch { refreshChatRooms() }
launch { runObserver() }
launch { runMetadataBatching() }
}
}
}
} }
override fun onCleared() { override fun onCleared() {
@@ -134,28 +163,21 @@ class NostrViewModel(
} }
} }
private fun runObserver() { private fun processIncomingEvent(event: UnsignedEvent) {
viewModelScope.launch {
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId() val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) { if (existingRoom == null) {
val currentUser = nostr.signer.currentUser nostr.signer.currentUser?.let { user ->
if (currentUser != null) { val newRoom = Room.new(event, user)
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() } _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
} }
} else { } else {
updateRoomList(roomId, event) updateRoomList(roomId, event)
} }
_newEvents.emit(event)
}
} }
private suspend fun runObserver() = coroutineScope {
// Observe metadata updates // Observe metadata updates
launch { launch {
nostr.metadataUpdates.collect { (pubkey, metadata) -> nostr.metadataUpdates.collect { (pubkey, metadata) ->
@@ -163,13 +185,6 @@ class NostrViewModel(
} }
} }
// Observe contact list updates
launch {
nostr.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observes subscription close // Observes subscription close
launch { launch {
nostr.subscriptionClosed.collect { nostr.subscriptionClosed.collect {
@@ -178,10 +193,8 @@ class NostrViewModel(
} }
} }
} }
}
private fun runMetadataBatching() { private suspend fun runMetadataBatching() = coroutineScope {
viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -198,11 +211,13 @@ class NostrViewModel(
metadataRequestChannel.receive() metadataRequestChannel.receive()
} }
if (nextKey != null) { // Only add the key if it's not null
batch.add(nextKey) if (nextKey != null) batch.add(nextKey)
}
// Get current time
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList() val keysToRequest = batch.toList()
batch.clear() batch.clear()
@@ -212,7 +227,6 @@ class NostrViewModel(
} }
} }
} }
}
private fun getCacheMetadata() { private fun getCacheMetadata() {
viewModelScope.launch { viewModelScope.launch {