diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index d98c73c..6eefaf2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -2,6 +2,7 @@ package su.reya.coop import android.app.Activity import android.content.Intent +import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme @@ -31,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.unit.dp import androidx.core.util.Consumer +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -93,8 +94,8 @@ fun App(viewModel: NostrViewModel) { val navigator = remember(backStack) { Navigator(backStack) } val qrScanResult = remember { QrScanResult() } - val signerRequired by viewModel.signerRequired.collectAsState(initial = null) - val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() + val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle() + val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle() // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -105,7 +106,7 @@ fun App(viewModel: NostrViewModel) { // Enabled the dynamic color scheme val colorScheme = when { // 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( context ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 58a9af1..0e51ee7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -1,13 +1,13 @@ package su.reya.coop import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore @@ -50,18 +50,16 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) val serviceIntent = Intent(this, NostrForegroundService::class.java) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } + startForegroundService(serviceIntent) // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { viewModel.signerRequired.value == null } + // Bind the lifecycle of the ViewModel to the Activity's lifecycle' + viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) + setContent { App(viewModel = viewModel) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index a627578..9f5936f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -10,13 +10,13 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.util.Log -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -25,6 +25,7 @@ import java.io.File class NostrForegroundService : Service() { private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val nostr by lazy { NostrManager.instance } + private var notificationJob: Job? = null override fun onBind(intent: Intent?): IBinder? = null @@ -46,10 +47,12 @@ class NostrForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - serviceScope.launch { + if (notificationJob?.isActive == true) return START_STICKY + + notificationJob = serviceScope.launch { try { Log.d("Coop", "Starting Nostr in background") - + // Create a database directory val dbDir = File(filesDir, "nostr") dbDir.mkdirs() @@ -82,10 +85,10 @@ class NostrForegroundService : Service() { Log.e("Coop", "Failed to start Nostr", e) } } + return START_STICKY } - @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel() { val manager = getSystemService(NotificationManager::class.java) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 1da9ef6..58b016e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -50,6 +51,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_send @@ -73,28 +75,37 @@ fun ChatScreen(id: Long) { val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current - val listState = rememberLazyListState() - val chatRooms by viewModel.chatRooms.collectAsState() - val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } } + // Get chat room by ID + val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val room by remember(id) { + derivedStateOf { chatRooms.firstOrNull { it.id == id } } + } + // Show empty screen if (room == null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - LoadingIndicator() + Text( + text = "Chat room not found", + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) } return } - val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") - val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) + val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...") + val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } + val listState = rememberLazyListState() val messages = remember { mutableStateListOf() } + val groupedMessages = remember(messages.toList()) { messages.groupBy { it.createdAt().formatAsGroupHeader() } } @@ -151,7 +162,7 @@ fun ChatScreen(id: Long) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { - room.members.firstOrNull()?.let { pubkey -> + room!!.members.firstOrNull()?.let { pubkey -> navigator.navigate(Screen.Profile(pubkey.toBech32())) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 1204079..3c85ade 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_qr @@ -108,8 +109,8 @@ fun HomeScreen() { val currentUser = viewModel.currentUser() ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return - val userProfile by currentUserProfile.collectAsState(initial = null) - val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) + val userProfile by currentUserProfile.collectAsStateWithLifecycle() + val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 61f2391..9e2d8c8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index ee5a364..33ecfe5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -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(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -99,12 +110,15 @@ class Nostr { suspend fun emitContactListUpdate(contacts: List) = _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), ) ) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 6a6afd1..edee2a8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() - - private val _contactList = MutableStateFlow>(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>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + private val _newEvents = MutableSharedFlow(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() - val timeout = 500L // 500ms timeout for batching + val batch = mutableSetOf() + 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) { @@ -560,14 +591,19 @@ class NostrViewModel( } suspend fun chatRoomConnect(roomId: Long): Map> { - 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() } + 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,