chore: optimize the battery usage #16
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
startForegroundService(serviceIntent)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import android.content.pm.ServiceInfo
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -25,6 +25,7 @@ import java.io.File
|
|||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@ class NostrForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
serviceScope.launch {
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
Log.d("Coop", "Starting Nostr in background")
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class NostrForegroundService : Service() {
|
|||||||
Log.e("Coop", "Failed to start Nostr", e)
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
@@ -73,28 +75,37 @@ fun ChatScreen(id: Long) {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
// Get chat room by ID
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
|
val room by remember(id) {
|
||||||
|
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty screen
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
LoadingIndicator()
|
Text(
|
||||||
|
text = "Chat room not found",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
|
||||||
val groupedMessages = remember(messages.toList()) {
|
val groupedMessages = remember(messages.toList()) {
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||||
}
|
}
|
||||||
@@ -151,7 +162,7 @@ fun ChatScreen(id: Long) {
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
room.members.firstOrNull()?.let { pubkey ->
|
room!!.members.firstOrNull()?.let { pubkey ->
|
||||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
@@ -108,8 +109,8 @@ fun HomeScreen() {
|
|||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.RelayCapabilities
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
import rust.nostr.sdk.RelayMessageEnum
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.ReqExitPolicy
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
import rust.nostr.sdk.ReqTarget
|
import rust.nostr.sdk.ReqTarget
|
||||||
@@ -59,6 +60,17 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
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 {
|
class Nostr {
|
||||||
@@ -75,7 +87,6 @@ class Nostr {
|
|||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
// Add these to the Nostr class
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
@@ -99,12 +110,15 @@ class Nostr {
|
|||||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||||
_contactListUpdates.emit(contacts)
|
_contactListUpdates.emit(contacts)
|
||||||
|
|
||||||
suspend fun init(dbPath: String) {
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (isInitialized.value) return
|
if (isInitialized.value) return
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
// Initialize the logger for nostr client
|
||||||
initLogger(LogLevel.DEBUG)
|
initLogger(logLevel)
|
||||||
|
|
||||||
// Initialize the database and gossip instance
|
// Initialize the database and gossip instance
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
@@ -141,24 +155,43 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
// Bootstrap relays
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
|
}
|
||||||
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = RelayUrl.parse(url),
|
||||||
|
capabilities = RelayCapabilities.gossip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
// Indexer relay for NIP-65 discovery
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
client?.addRelay(
|
try {
|
||||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
capabilities = RelayCapabilities.gossip()
|
if (relay != null) {
|
||||||
)
|
if (relay.status() != RelayStatus.CONNECTED) {
|
||||||
|
relay.connect()
|
||||||
// Connect to all bootstrap relays and wait for all connections to be established
|
}
|
||||||
client?.connect(Duration.parse("2s"))
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to reconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun disconnect() {
|
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() {
|
suspend fun exit() {
|
||||||
@@ -578,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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
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
|
||||||
@@ -50,18 +53,18 @@ 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()
|
||||||
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
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)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
@@ -87,22 +90,32 @@ class NostrViewModel(
|
|||||||
// Check local stored secret (secret key or bunker)
|
// Check local stored secret (secret key or bunker)
|
||||||
login()
|
login()
|
||||||
|
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
// Observe the signer state and verify the relay list
|
// Observe the signer state and verify the relay list
|
||||||
observeSignerAndCheckRelays()
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
// 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() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
// Ensure all relays are disconnect
|
|
||||||
|
// Disconnect to all bootstrap relays
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
nostr.disconnect()
|
nostr.disconnect()
|
||||||
@@ -123,81 +136,100 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runObserver() {
|
private fun reconnect() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Observe new messages
|
nostr.waitUntilInitialized()
|
||||||
launch {
|
nostr.reconnect()
|
||||||
nostr.newEvents.collect { event ->
|
}
|
||||||
val roomId = event.roomId()
|
}
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
private fun processIncomingEvent(event: UnsignedEvent) {
|
||||||
val currentUser = nostr.signer.currentUser
|
val roomId = event.roomId()
|
||||||
if (currentUser != null) {
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||||
val newRoom = Room.new(event, currentUser)
|
|
||||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
if (existingRoom == null) {
|
||||||
}
|
nostr.signer.currentUser?.let { user ->
|
||||||
} else {
|
val newRoom = Room.new(event, user)
|
||||||
updateRoomList(roomId, event)
|
_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() }
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
_newEvents.emit(event)
|
updateRoomList(roomId, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_newEvents.emit(event)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe metadata updates
|
// Observe contact list updates
|
||||||
launch {
|
launch {
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
nostr.contactListUpdates.collect { contacts ->
|
||||||
updateMetadata(pubkey, metadata)
|
_contactList.value = contacts.toSet()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe contact list updates
|
// Observe metadata updates
|
||||||
launch {
|
launch {
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
_contactList.value = contacts.toSet()
|
updateMetadata(pubkey, metadata)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observes subscription close
|
// Observes subscription close
|
||||||
launch {
|
launch {
|
||||||
nostr.subscriptionClosed.collect {
|
nostr.subscriptionClosed.collect {
|
||||||
getChatRooms()
|
getChatRooms()
|
||||||
_isPartialProcessedGiftWrap.value = true
|
_isPartialProcessedGiftWrap.value = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
val batch = mutableSetOf<PublicKey>()
|
||||||
val timeout = 500L // 500ms timeout for batching
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val firstKey = metadataRequestChannel.receive()
|
val firstKey = metadataRequestChannel.receive()
|
||||||
batch.add(firstKey)
|
batch.add(firstKey)
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
while (batch.isNotEmpty()) {
|
||||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
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)
|
||||||
}
|
|
||||||
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
// Get current time
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
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 }
|
return chatRooms.value.firstOrNull { it.id == id }
|
||||||
?: throw IllegalArgumentException("Room not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||||
@@ -560,14 +591,19 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
||||||
val room = getChatRoom(roomId)
|
try {
|
||||||
val members = room.members
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
val members = room.members
|
||||||
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
nostr.chatRoomConnect(members.toList())
|
nostr.chatRoomConnect(members.toList())
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
members.associateWith { emptyList() }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
members.associateWith { emptyList<RelayUrl>() }
|
return emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +613,7 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId)
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
nostr.sendMessage(
|
nostr.sendMessage(
|
||||||
to = room.members,
|
to = room.members,
|
||||||
content = message,
|
content = message,
|
||||||
|
|||||||
Reference in New Issue
Block a user