Sync using the new logic.

This commit is contained in:
Kgothatso Ngako
2026-04-19 04:30:29 +02:00
parent 56ba03c2b3
commit 403b245880
12 changed files with 193 additions and 188 deletions

View File

@@ -45,6 +45,7 @@ kotlin {
commonMain.dependencies {
api("fr.acinq.lightning:lightning-kmp-core:1.11.5")
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.viewmodelCompose)

View File

@@ -20,15 +20,28 @@ import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import ac.aux.compose.exceptions.NostrPublishException
import ac.aux.compose.network.dto.toRelayDTO
import ac.aux.compose.network.sockets.NostrIncomingMessage
import ac.aux.compose.network.sockets.NostrSocketClient
import ac.aux.compose.network.sockets.NostrSocketClientFactory
import ac.aux.compose.network.sockets.SocketConnectionClosedCallback
import ac.aux.compose.network.sockets.SocketConnectionOpenedCallback
import ac.aux.compose.network.sockets.filterByEventId
import ac.aux.compose.network.sockets.parseIncomingMessage
import ac.aux.compose.network.sockets.filterBySubscriptionId
import ac.aux.compose.network.sockets.verifyOrThrow
import ac.aux.compose.repository.CachingImportRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.transformWhile
import kotlin.time.Duration.Companion.seconds
/**
* As seen in Primal
@@ -114,10 +127,11 @@ class RelayPool(
val toAddRelayUrls = newRelayUrls.filter { it !in existingRelayUrls }
val toAddSocketClients = relays.filter { it.url in toAddRelayUrls }.mapAsNostrSocketClient()
logger.d("toAddSocketClients: ${toAddSocketClients.map { it.socketUrl }}" )
val newSocketClients = socketClients.toMutableList().apply {
addAll(toAddSocketClients)
}
logger.d("newSocketClients: ${newSocketClients.map { it.socketUrl }}")
socketClients = newSocketClients
@@ -165,30 +179,60 @@ class RelayPool(
}
}
private suspend fun handleBroadcastEventThroughCachingProxy(relayUrls: List<String>, nostrEvent: NostrEvent) {
val result =
cachingImportRepository.broadcastEvents(
events = listOf(nostrEvent),
relays = relayUrls,
).getOrNull()
?: throw NostrPublishException(
cause = NetworkException(message = "Primal NostrEvent 10_000_149 not found or invalid."),
)
result.find { response -> response.eventId == nostrEvent.id }?.responses
?.mapNotNull { relayResponse ->
val relay = relayResponse.firstOrNull()
val responseMessage = relayResponse.getOrNull(index = 1)?.parseIncomingMessage()
if (relay != null && responseMessage != null) {
relay to responseMessage
} else {
null
}
}
?.find { (_, relayMessage) -> relayMessage is NostrIncomingMessage.OkMessage && relayMessage.success }
?: throw NostrPublishException(
cause = NetworkException("Event broadcast failed. Could not find success response from relays."),
@OptIn(FlowPreview::class)
suspend fun query(reqCommand: ReqCmd, relayUrl: String): Pair<NostrIncomingMessage, List<NostrEvent>> {
addRelays(
setOf(
NormalizedRelayUrl(relayUrl).url.toRelayDTO()
)
)
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
val filterRequest = OptimizedJsonMapper.toJson(reqCommand)
if (nostrSocketClient == null) {
throw NetworkException("$relayUrl is not connected")
}
return coroutineScope {
val deferredQueryResult = async { nostrSocketClient.collectQueryResult(reqCommand.subId) }
with(nostrSocketClient) {
sendMESSAGE(filterRequest)
}
deferredQueryResult.await()
}
}
private fun Flow<NostrIncomingMessage>.transformWhileEventsAreIncoming() =
transformWhile {
emit(it)
it is NostrIncomingMessage.EventMessage || it is NostrIncomingMessage.EventsMessage
}
@OptIn(FlowPreview::class)
private suspend fun NostrSocketClient.collectQueryResult(subscriptionId: String): Pair<NostrIncomingMessage, List<NostrEvent>> {
val messages = this.incomingMessages
.filterBySubscriptionId(id = subscriptionId)
.transformWhileEventsAreIncoming()
.timeout(15.seconds)
.toList()
val terminationMessage = messages.lastOrNull()
terminationMessage.verifyOrThrow(subscriptionId)
checkNotNull(terminationMessage)
val eventMessages = messages.filterIsInstance<NostrIncomingMessage.EventMessage>()
val eventsMessage = messages.filterIsInstance<NostrIncomingMessage.EventsMessage>()
val allNostrEvents = eventMessages.mapNotNull { it.nostrEvent } +
eventsMessage.map { it.nostrEvents }.flatten()
return Pair(
terminationMessage,
allNostrEvents,
)
}
@OptIn(FlowPreview::class)

View File

@@ -16,6 +16,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ac.aux.compose.exceptions.NostrPublishException
import ac.aux.compose.managers.CredentialsManager
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.managers.bech32ToHexOrNull
import ac.aux.compose.managers.toHex
import ac.aux.compose.network.sockets.NostrIncomingMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
/**
@@ -26,17 +32,12 @@ import ac.aux.compose.exceptions.NostrPublishException
class RelaysSocketManager constructor(
private val nostrSocketClientFactory: NostrSocketClientFactory,
private val cachingImportRepository: CachingImportRepository,
// private val activeAccountStore: ActiveAccountStore,
// private val usersDatabase: UsersDatabase,
private val relayRepository: RelayRepository
) {
val logger = Logger.withTag("RelaysSocketManager")
private val scope = CoroutineScope(Dispatchers.IO)
private val relayPoolsMutex = Mutex()
private var relaysObserverJob: Job? = null
/**
* Concrete relay pool
*/
@@ -56,27 +57,26 @@ class RelaysSocketManager constructor(
observeActiveUserId()
}
private val observeRelayJobs = mutableMapOf<String, Job>()
private fun observeActiveUserId() =
scope.launch {
// TODO activeAccountStore.activeUserId.collect { userId ->
// when {
// userId.isEmpty() -> {
// relaysObserverJob?.cancel()
// relaysObserverJob = null
// clearRelayPools()
// }
SeedManager.activePublicKey().toHex()?.let { publicKey ->
observeRelayJobs[publicKey]?.cancel()
observeRelayJobs[publicKey] = observeRelays(publicKey)
}
// credentialsManager.credentials.collect { credentials ->
// credentials.forEach { credential ->
// credential.npub.bech32ToHexOrNull()?.let { publicKey ->
// observeRelayJobs[publicKey]?.cancel()
//
// else -> {
// relaysObserverJob?.cancel()
// relaysObserverJob = observeRelays(userId)
// observeRelayJobs[publicKey] = observeRelays(publicKey)
// }
// }
// }
}
private suspend fun isCachingProxyEnabled() = false // TODO: activeAccountStore.activeUserAccount().cachingProxyEnabled
private fun observeRelays(publicKey: String): Job =
scope.launch {
try {
@@ -129,7 +129,7 @@ class RelaysSocketManager constructor(
// customPool.closePool()
}
fun tryConnectingToAllUserRelays() {
fun tryConnectingToAllRelays() {
relayPool.relays.forEach {
scope.launch {
relayPool.tryConnectingToRelay(it.url)
@@ -138,4 +138,11 @@ class RelaysSocketManager constructor(
}
suspend fun tryConnectingToUserRelay(url: String) = relayPool.tryConnectingToRelay(url)
suspend fun query(reqCommand: ReqCmd, relayUrl: String): Pair<NostrIncomingMessage, List<NostrEvent>> {
return relayPool.query(
reqCommand = reqCommand,
relayUrl = relayUrl
)
}
}

View File

@@ -1,6 +1,8 @@
package ac.aux.compose.network.sockets
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.exceptions.NetworkException
import ac.aux.compose.exceptions.NostrNoticeException
sealed class NostrIncomingMessage {
@@ -38,3 +40,16 @@ sealed class NostrIncomingMessage {
val nostrEvents: List<NostrEvent> = emptyList(),
) : NostrIncomingMessage()
}
fun NostrIncomingMessage?.verifyOrThrow(subscriptionId: String) {
if (this == null) {
throw NetworkException("No messages received.")
}
if (this is NostrIncomingMessage.NoticeMessage) {
throw NostrNoticeException(
reason = this.message,
subscriptionId = subscriptionId,
)
}
}

View File

@@ -26,4 +26,6 @@ interface NostrSocketClient {
suspend fun sendEVENT(signedEvent: JsonObject)
suspend fun sendREQ(subscriptionId: String, data: JsonObject)
suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean = true)
}

View File

@@ -73,7 +73,7 @@ internal class NostrSocketClientImpl(
onSocketConnectionOpened?.invoke(url)
if (incomingCompressionEnabled) {
val id = Uuid.generateV4().toHexDashString()
sendMessage(
sendMESSAGE(
text = """["REQ","$id",{"cache":["set_primal_protocol",{"compression":"zlib"}]}]""",
ensureSessionBeforeSend = false,
)
@@ -155,7 +155,7 @@ internal class NostrSocketClientImpl(
}
}
private suspend fun sendMessage(text: String, ensureSessionBeforeSend: Boolean = true) {
override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) {
if (ensureSessionBeforeSend) {
ensureSocketConnectionOrThrow()
}
@@ -165,21 +165,21 @@ internal class NostrSocketClientImpl(
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) {
val reqMessage = data.buildNostrREQMessage(subscriptionId)
return sendMessage(text = reqMessage)
return sendMESSAGE(text = reqMessage)
}
override suspend fun sendCOUNT(data: JsonObject): String {
val subscriptionId: String = Uuid.generateV4().toHexDashString()
val reqMessage = data.buildNostrCOUNTMessage(subscriptionId)
sendMessage(text = reqMessage)
sendMESSAGE(text = reqMessage)
return subscriptionId
}
override suspend fun sendCLOSE(subscriptionId: String) = sendMessage(text = subscriptionId.buildNostrCLOSEMessage())
override suspend fun sendCLOSE(subscriptionId: String) = sendMESSAGE(text = subscriptionId.buildNostrCLOSEMessage())
override suspend fun sendEVENT(signedEvent: JsonObject) = sendMessage(text = signedEvent.buildNostrEVENTMessage())
override suspend fun sendEVENT(signedEvent: JsonObject) = sendMESSAGE(text = signedEvent.buildNostrEVENTMessage())
override suspend fun sendAUTH(signedEvent: JsonObject) = sendMessage(text = signedEvent.buildNostrAUTHMessage())
override suspend fun sendAUTH(signedEvent: JsonObject) = sendMESSAGE(text = signedEvent.buildNostrAUTHMessage())
private fun logLargeText(
text: String,

View File

@@ -35,7 +35,7 @@ object Relays {
val bootstrapInboxRelaySet = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
val eventFinderRelaySet = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
val eventPublishRelaySet = setOf(damus, primal, mom, nos, bitcoiner, oxtr)
val eventPublishRelaySet = setOf(primal, mom, nos, bitcoiner, oxtr)
val DefaultNIP65RelaySet = setOf(mom, nos, bitcoiner)

View File

@@ -7,11 +7,13 @@ import ac.aux.compose.exceptions.SignatureException
import ac.aux.compose.exceptions.SigningKeyNotFoundException
import ac.aux.compose.exceptions.SigningRejectedException
import ac.aux.compose.managers.CredentialsManager
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.managers.hexToNpubHrp
import ac.aux.compose.network.UserAgent
import ac.aux.compose.network.asClientTag
import ac.aux.compose.network.dto.RelayDTO
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
@@ -31,7 +33,6 @@ import kotlinx.coroutines.sync.withLock
*/
class NostrNotaryRepository(
private val nostrRepository: NostrRepository,
private val credentialsStore: CredentialsManager,
) {
private val scope = CoroutineScope(Dispatchers.Main)
@@ -75,13 +76,15 @@ class NostrNotaryRepository(
private fun findNsecOrThrow(pubkey: String): String =
runCatching {
val npub = Hex.decode(pubkey).toNpub()
credentialsStore.findOrThrow(npub = npub).nsec
// val npub = Hex.decode(pubkey).toNpub()
// credentialsStore.findOrThrow(npub = npub).nsec
SeedManager.activeKeyPair().privKey?.toNsec()
}.getOrNull() ?: throw SigningKeyNotFoundException()
private fun signNostrEvent(publicKey: String, event: UnsignedNostrEvent): NostrEvent? {
val isExternalSignerLogin = runCatching {
credentialsStore.isExternalSignerCredential(npub = publicKey.hexToNpubHrp())
// credentialsStore.isExternalSignerCredential(npub = publicKey.hexToNpubHrp())
false
}.getOrDefault(false)
if (isExternalSignerLogin) {

View File

@@ -59,11 +59,9 @@ class NavigationViewModel(
val relaysSocketManager = RelaysSocketManager(
nostrSocketClientFactory = NostrSocketClientFactory,
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
relayRepository = relayRepository
relayRepository = relayRepository,
)
companion object {
private const val TAG = "NavigationViewModel"
@@ -86,8 +84,6 @@ class NavigationViewModel(
private val logger = Logger.withTag(TAG)
private val syncingJobs = mutableMapOf<String, Job>()
val httpClient = HttpClient() {
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json {
@@ -98,10 +94,6 @@ class NavigationViewModel(
}
}
val nostrEventBroadcaster = NostrEventBroadcaster(
scope = scope
)
private val _navigationUIState = MutableStateFlow(
initialNavigationUIState
)
@@ -156,8 +148,6 @@ class NavigationViewModel(
private fun observePendingSyncNostrEventRequests() {
logger.i { "observePendingSyncNostrEventRequests" }
val syncEventCache = mutableMapOf<String, NostrEvent>()
scope.launch(Dispatchers.IO) {
nostrRepository.observePendingSynchronizeNostrEventRequests().collect { synchronizeNostrEventRequestOrNull ->
synchronizeNostrEventRequestOrNull?.let { synchronizeNostrEventRequest ->
@@ -179,120 +169,32 @@ class NavigationViewModel(
}
)
scope.launch {
try {
val webSocketSession = httpClient.webSocketSession(
urlString = synchronizeNostrEventRequest.relayURL
val result = relaysSocketManager.query(
reqCommand,
synchronizeNostrEventRequest.relayURL
)
// TODO: don't make a new sync job if there's an active job?
syncingJobs[synchronizeNostrEventRequest.relayURL] = scope.launch {
val filterRequest = OptimizedJsonMapper.toJson(reqCommand)
logger.d("Syncing: $filterRequest")
webSocketSession.send(
frame = Frame.Text(filterRequest)
logger.d("Result: $result")
result.second.forEach { nostrEvent ->
nostrRepository.saveNostrEvent(
nostrEvent = nostrEvent,
synchronizeNostrEventRequest,
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } + synchronizeNostrEventRequest.relayURL
)
nostrRepository.synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest)
webSocketSession.incoming.consumeAsFlow().collect { frame ->
when(frame) {
is Frame.Text -> {
frame.readText().let { text ->
try {
logger.d("WebSocket Read Text: $text")
val result = Json.decodeFromString<JsonArray>(text)
if (result.firstOrNull()?.text == "EVENT") {
result.getOrNull(1)?.text?.let { subscriptionId ->
if (subscriptionId == synchronizeNostrEventRequest.id) {
result.getOrNull(2)?.jsonObject?.toString()?.let { jsonText ->
logger.i("Processing: $jsonText")
val event = Event.fromJson(
jsonText
)
// TODO: Do profile specific cache (to avoid wiping an updated profile)
syncEventCache.getOrPut(event.id, {
val taggedEvent = event.firstTaggedEvent()
val taggedUser = event.firstTaggedUser()
val nostrEvent = NostrEvent(
id = event.id,
pubKey = event.pubKey,
kind = event.kind,
content = event.content,
tags = event.tags,
createdAt = Instant.fromEpochSeconds(event.createdAt),
unsignedNostrEventId = synchronizeNostrEventRequest.unsignedNostrEventId,
sig = event.sig,
taggedNostrEventId = taggedEvent?.eventId,
taggedNostrEventRelayUrl = taggedEvent?.relay?.url,
taggedPublicKey = taggedUser?.pubKey,
taggedPublicKeyRelayUrl = taggedUser?.relayHint?.url
)
nostrRepository.saveNostrEvent(
nostrEvent = nostrEvent,
synchronizeNostrEventRequest,
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }
)
nostrEvent
})
}
}
}
} else if(result.firstOrNull()?.text == "EOSE") {
result.getOrNull(1)?.text?.let { subscriptionId ->
if (subscriptionId == synchronizeNostrEventRequest.id) {
// Close the job
logger.d("We need to close the job")
}
}
} else {
logger.d("Unsupported: $text")
}
} catch (e: Throwable) {
logger.e("Error processing receipt: $text", e)
}
}
// Syncing doesn't necessary have to close after result..
webSocketSession.close()
syncingJobs.remove(synchronizeNostrEventRequest.relayURL)?.cancel()
}
is Frame.Close -> {
logger.d("Close $frame")
}
is Frame.Ping -> {
logger.d("Ping: $frame")
}
is Frame.Pong -> {
logger.d("Pong: $frame")
}
is Frame.Binary -> {
logger.d("Binary Message: $frame")
}
else -> {
logger.d("Unsupported frame: $frame")
}
}
}
logger.i("Websocket exit")
}
syncingJobs[synchronizeNostrEventRequest.relayURL]?.join()
} catch (e: Throwable) {
logger.e("Relay Error (${synchronizeNostrEventRequest.relayURL})", e)
// TODO: Have it failed instead of processed...
nostrRepository.synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest)
} catch (e: Throwable) {
logger.e("Failed to sync", e)
}
}
}
}
}
@@ -325,24 +227,6 @@ class NavigationViewModel(
)
)
)
// nostrEventBroadcaster.broadcastEvent(
// localBroadcastNostrEventRequest = localBroadcastNostrEventRequest,
// onBroadcastRequestProcessed = { broadcastNostrEventRequest ->
// scope.launch(Dispatchers.IO) {
// nostrRepository.broadcastProcessed(
// broadcastNostrEventRequest
// )
// }
// },
// onBroadcastReceipt = { broadcastNostrEventReceipt ->
// scope.launch(Dispatchers.IO) {
// nostrRepository.saveBroadcastReceipt(
// broadcastNostrEventReceipt
// )
// }
// }
// )
}
}
}

View File

@@ -0,0 +1,33 @@
package ac.aux.compose.managers
import ac.aux.compose.PlatformContext
import androidx.datastore.core.Storage
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import okio.FileSystem
import okio.Path.Companion.toPath
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
actual fun computeDatastoreStorage(
platformContext: PlatformContext,
dataStoreFileName: String
): Storage<Set<CredentialsManager.Credential>> {
return OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = OkioSerializer<Set<CredentialsManager.Credential>>,
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLsForDirectory(
directory = NSDocumentDirectory,
inDomains = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
)
(requireNotNull(documentDirectory).path + "/$dataStoreFileName").toPath()
}
)
}

View File

@@ -0,0 +1,15 @@
package ac.aux.compose.managers
import ac.aux.compose.PlatformContext
import androidx.datastore.core.Storage
import io.ktor.client.plugins.cache.storage.FileStorage
actual fun computeDatastoreStorage(
platformContext: PlatformContext,
dataStoreFileName: String
): Storage<Set<CredentialsManager.Credential>> {
FileStorage(
directory = ""
)
TODO("Not yet implemented")
}

View File

@@ -40,6 +40,7 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "pagingCommon" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCommon" }