diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index ed2fcca..fa3e047 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -10,16 +10,8 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager import kotlin.system.exitProcess @@ -31,14 +23,15 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { - private val secretStore = SecretStore(this@MainActivity) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val nostrVM = NostrViewModel(NostrManager.instance, secretStore, androidSigner) + private val secretStore = SecretStore(this@MainActivity) + private val nostrViewModel = + NostrViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { - modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrVM + modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } @@ -87,9 +80,6 @@ class MainActivity : ComponentActivity() { nostrViewModel.signerRequired.value == null } - // Bind the lifecycle of the ViewModels - nostrViewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) - setContent { App( nostrViewModel = nostrViewModel, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b6e4d7e..5c11eb3 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -1,14 +1,13 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -18,13 +17,17 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json @@ -52,25 +55,52 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +data class NostrAppState( + val isBusy: Boolean = false, + val isRelayListEmpty: Boolean = false, + val isSyncing: Boolean = false, + val isPartialProcessedGiftWrap: Boolean = false, + val isNotificationBannerDismissed: Boolean = false, + val signerRequired: Boolean? = null, +) + class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : ViewModel() { - private val _isNotificationBannerDismissed = MutableStateFlow(false) - val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } - private val _signerRequired = MutableStateFlow(null) - val signerRequired = _signerRequired.asStateFlow() + private val backgroundWorkTrigger = flow { + coroutineScope { + val observerJob = launch { runObserver() } + val batchingJob = launch { runMetadataBatching() } + try { + emit(Unit) + awaitCancellation() + } finally { + observerJob.cancel() + batchingJob.cancel() + } + } + } - private val _isBusy = MutableStateFlow(false) - val isBusy = _isBusy.asStateFlow() - - private val _isPartialProcessedGiftWrap = MutableStateFlow(false) - val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() - - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty = _isRelayListEmpty.asStateFlow() + private val _appState = MutableStateFlow(NostrAppState()) + val appState: StateFlow = combine( + _appState, + nostr.messages.messageSyncState.map { it.isSyncing }, + backgroundWorkTrigger + ) { state, isSyncing, _ -> + state.copy(isSyncing = isSyncing) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NostrAppState() + ) private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() @@ -87,16 +117,23 @@ class NostrViewModel( private val _errorEvents = Channel(Channel.BUFFERED) val errorEvents = _errorEvents.receiveAsFlow() + private val profilesMutex = Mutex() private val profiles = mutableMapOf>() - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messages.messageSyncState - .map { it.isSyncing } + val isNotificationBannerDismissed = appState.map { it.isNotificationBannerDismissed } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val signerRequired = appState.map { it.signerRequired } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val isBusy = appState.map { it.isBusy } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isRelayListEmpty = appState.map { it.isRelayListEmpty } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isSyncing = appState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile = nostr.signer.publicKeyFlow @@ -122,17 +159,6 @@ class NostrViewModel( getCacheMetadata() } - fun bindLifecycle(lifecycle: Lifecycle) { - viewModelScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - coroutineScope { - launch { runObserver() } - launch { runMetadataBatching() } - } - } - } - } - override fun onCleared() { super.onCleared() @@ -152,8 +178,8 @@ class NostrViewModel( private fun checkNotificationBannerDismissedStatus() { viewModelScope.launch { - _isNotificationBannerDismissed.value = - secretStore.get("notification_banner_dismissed") == "true" + val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" + _appState.update { it.copy(isNotificationBannerDismissed = dismissed) } } } @@ -170,7 +196,7 @@ class NostrViewModel( nostr.messages.messageSyncState.collect { state -> // When at least some messages are processed, allow UI to show the list if (state.processedCount > 0) { - _isPartialProcessedGiftWrap.value = true + _appState.update { it.copy(isPartialProcessedGiftWrap = true) } } // Refresh UI every 10 messages OR when sync is fully done @@ -270,11 +296,11 @@ class NostrViewModel( viewModelScope.launch { try { val secret = withTimeoutOrNull(3.seconds) { - secretStore.get("user_signer") + secretStore.get(KEY_USER_SIGNER) } if (secret == null) { - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } return@launch } @@ -282,14 +308,14 @@ class NostrViewModel( val signer = createSigner(secret) nostr.setSigner(signer) }.onSuccess { - _signerRequired.value = false + _appState.update { it.copy(signerRequired = false) } }.onFailure { e -> showError("Login failed: ${e.message}") - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } } } catch (e: Exception) { showError("Login failed: ${e.message}") - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } } } } @@ -304,7 +330,7 @@ class NostrViewModel( val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { mergeChatRooms(rooms) - _isPartialProcessedGiftWrap.value = true + _appState.update { it.copy(isPartialProcessedGiftWrap = true) } } // Get all metadata for the current user @@ -315,7 +341,7 @@ class NostrViewModel( // Check if the relay list is empty val relays = nostr.relays.getMsgRelays(currentUser) - if (relays.isEmpty()) _isRelayListEmpty.value = true + if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } break } @@ -334,7 +360,11 @@ class NostrViewModel( } private fun updateMetadata(pubkey: PublicKey, profile: Profile) { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + viewModelScope.launch { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + } + } } fun getMetadata(pubkey: PublicKey): StateFlow { @@ -347,7 +377,7 @@ class NostrViewModel( fun logout() { viewModelScope.launch { try { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } // Reset the nostr signer and prune the database nostr.signer.switch(Keys.generate()) nostr.prune() @@ -355,14 +385,13 @@ class NostrViewModel( showError("Logout encountered an error: ${e.message}") } finally { // Clear credentials from persistent storage - secretStore.clear("user_signer") - secretStore.clear("notification_banner_dismissed") + secretStore.clear(KEY_USER_SIGNER) + secretStore.clear(KEY_BANNER_DISMISSED) // Reset all UI states resetInternalState() - _isBusy.value = false - _signerRequired.value = true + _appState.update { it.copy(isBusy = false, signerRequired = true) } } } } @@ -370,24 +399,28 @@ class NostrViewModel( private fun resetInternalState() { _chatRooms.value = emptySet() _contactList.value = emptySet() - _isPartialProcessedGiftWrap.value = false - _isRelayListEmpty.value = false - _isNotificationBannerDismissed.value = false + _appState.update { + it.copy( + isPartialProcessedGiftWrap = false, + isRelayListEmpty = false, + isNotificationBannerDismissed = false + ) + } } fun dismissNotificationBanner() { viewModelScope.launch { - secretStore.set("notification_banner_dismissed", "true") - _isNotificationBannerDismissed.value = true + secretStore.set(KEY_BANNER_DISMISSED, "true") + _appState.update { it.copy(isNotificationBannerDismissed = true) } } } fun dismissRelayWarning() { - _isRelayListEmpty.value = false + _appState.update { it.copy(isRelayListEmpty = false) } } private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get("app_keys") + val secret = secretStore.get(KEY_APP_KEYS) // If app keys are already stored, use them if (secret != null) { @@ -396,7 +429,7 @@ class NostrViewModel( // Generate new app keys and save to the secret storage val keys = Keys.generate() - secretStore.set("app_keys", keys.secretKey().toBech32()) + secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) return keys } @@ -436,7 +469,7 @@ class NostrViewModel( picture: ByteArray? = null, contentType: String? = null ) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) @@ -444,7 +477,7 @@ class NostrViewModel( // Update the metadata state after successfully published updateMetadata(currentUser, Profile(currentUser, newMetadata)) // Update local state - _isBusy.value = false + _appState.update { it.copy(isBusy = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -456,7 +489,7 @@ class NostrViewModel( picture: ByteArray?, contentType: String? = null ) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } val keys = Keys.generate() val secret = keys.secretKey().toBech32() @@ -466,10 +499,9 @@ class NostrViewModel( // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Persist the secret in the secret storage - secretStore.set("user_signer", secret) + secretStore.set(KEY_USER_SIGNER, secret) // Update local states - _isBusy.value = false - _signerRequired.value = false + _appState.update { it.copy(isBusy = false, signerRequired = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -517,16 +549,15 @@ class NostrViewModel( } suspend fun importIdentity(secret: String) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val signer = createSigner(secret) // Update signer nostr.setSigner(signer) // Persist the secret in the secret storage - secretStore.set("user_signer", secret) + secretStore.set(KEY_USER_SIGNER, secret) // Update local states - _signerRequired.value = false - _isBusy.value = false + _appState.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -534,7 +565,7 @@ class NostrViewModel( suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val permissions = SignerPermissions.toJson( listOf( @@ -557,10 +588,12 @@ class NostrViewModel( // Update signer nostr.setSigner(signer) // Store the signer in the secret storage - secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") + secretStore.set( + KEY_USER_SIGNER, + "nip55://${result.packageName}/${result.pubkey.toHex()}" + ) // Update local states - _signerRequired.value = false - _isBusy.value = false + _appState.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { throw Exception("Notice: ${e.message}") }