chore: refactor the internal structure #33

Merged
reya merged 9 commits from refactor-core into master 2026-07-03 01:20:37 +00:00
2 changed files with 105 additions and 82 deletions
Showing only changes of commit 666f5c1795 - Show all commits

View File

@@ -10,16 +10,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
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.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider 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.coop.storage.SecretStore
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -31,14 +23,15 @@ class MainActivity : ComponentActivity() {
private val factory by lazy { private val factory by lazy {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
private val secretStore = SecretStore(this@MainActivity)
private val androidSigner = private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher) 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 <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when { return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrVM modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T } as T
} }
@@ -87,9 +80,6 @@ class MainActivity : ComponentActivity() {
nostrViewModel.signerRequired.value == null nostrViewModel.signerRequired.value == null
} }
// Bind the lifecycle of the ViewModels
nostrViewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent { setContent {
App( App(
nostrViewModel = nostrViewModel, nostrViewModel = nostrViewModel,

View File

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