From 87bd722960b3f8a9391950c4af4f929d06315c1c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 18 Apr 2026 21:58:32 +0200 Subject: [PATCH] Add Primal like NostrPublisher --- composeApp/build.gradle.kts | 5 + .../ac/aux/compose/cryptography/Extensions.kt | 40 ++++ .../ac/aux/compose/database/AuxDatabase.kt | 4 + .../ac/aux/compose/database/dao/RelayDao.kt | 2 +- .../repository/DatabaseNostrRepository.kt | 8 +- .../InvalidNostrPrivateKeyException.kt | 3 + .../exceptions/NostrPublishException.kt | 3 + .../compose/exceptions/SignatureException.kt | 4 + .../exceptions/SigningKeyNotFoundException.kt | 8 + .../exceptions/SigningRejectedException.kt | 4 + .../compose/managers/CredentialsManager.kt | 171 ++++++++++++++++++ .../ac/aux/compose/network/UserAgent.kt | 15 ++ .../aux/compose/network/relays/RelayPool.kt | 7 +- .../network/relays/RelaysSocketManager.kt | 7 +- .../relays/errors/NostrPublishException.kt | 3 - .../repository/CachingImportRepository.kt | 31 ++++ .../repository/NostrNotaryRepository.kt | 153 ++++++++++++++++ .../repository/NostrPublisherRepository.kt | 73 ++++++++ .../ui/view/model/NavigationViewModel.kt | 2 + gradle/libs.versions.toml | 2 + 20 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/cryptography/Extensions.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/InvalidNostrPrivateKeyException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/NostrPublishException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SignatureException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningKeyNotFoundException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningRejectedException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/managers/CredentialsManager.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/network/UserAgent.kt delete mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/errors/NostrPublishException.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrNotaryRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrPublisherRepository.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 366fc62a..4d647d19 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -43,6 +43,10 @@ kotlin { implementation(libs.okhttp.coroutines) } commonMain.dependencies { + api("fr.acinq.lightning:lightning-kmp-core:1.11.5") + + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) @@ -61,6 +65,7 @@ kotlin { implementation(libs.compose.components.resources) implementation(libs.compose.uiToolingPreview) + implementation(libs.navigation.compose) implementation(libs.kermit) diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/cryptography/Extensions.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/cryptography/Extensions.kt new file mode 100644 index 00000000..0fa6b8d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/cryptography/Extensions.kt @@ -0,0 +1,40 @@ +package ac.aux.compose.cryptography + +import ac.aux.compose.database.model.NostrEvent +import ac.aux.compose.database.model.UnsignedNostrEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import kotlin.time.Instant + +fun UnsignedNostrEvent.signOrThrow(nsec: String): NostrEvent { + val hexPrivateKey = Bech32.decodeBytes(nsec).second + return this.signOrThrow(hexPrivateKey) +} + +fun UnsignedNostrEvent.signOrThrow(hexPrivateKey: ByteArray): NostrEvent { + val tempSigner = NostrSignerSync( + KeyPair( + privKey = hexPrivateKey + ) + ) + + val event = tempSigner.signNormal( + createdAt = this.createdAt.epochSeconds, + kind = this.kind, + tags = this.tags, + content = this.privateTags?.let { PrivateTagsInContent.encryptNip44(it, tempSigner) } ?: this.content + ) + return NostrEvent( + id = event.id, + pubKey = event.pubKey, + kind = event.kind, + tags = event.tags, + content = event.content, + createdAt = Instant.fromEpochSeconds(event.createdAt), + sig = event.sig, + unsignedNostrEventId = this.id + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt index bb4ebe3f..e8c39fe1 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/AuxDatabase.kt @@ -9,6 +9,7 @@ import ac.aux.compose.database.dao.PostDao import ac.aux.compose.database.dao.ProfileDao import ac.aux.compose.database.dao.ReactionDao import ac.aux.compose.database.dao.RecentSearchDao +import ac.aux.compose.database.dao.RelayDao import ac.aux.compose.database.dao.RepostDao import ac.aux.compose.database.dao.SynchronizeNostrEventRequestDao import ac.aux.compose.database.dao.SynchronizeNostrEventResultDao @@ -66,6 +67,9 @@ abstract class AuxDatabase: RoomDatabase() { abstract fun reactionDao(): ReactionDao abstract fun recentSearchDao(): RecentSearchDao + + abstract fun relayDao(): RelayDao + abstract fun repostDao(): RepostDao abstract fun synchronizeNostrEventRequestDao(): SynchronizeNostrEventRequestDao diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RelayDao.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RelayDao.kt index 114ca10d..f6d30f7e 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RelayDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/dao/RelayDao.kt @@ -13,7 +13,7 @@ interface RelayDao { fun getAllPosts(): List @Query("SELECT * FROM Relay WHERE publicKey = :publicKey") - fun observePublicKeyRelays(publicKey: String): Flow + fun observePublicKeyRelays(publicKey: String): Flow> @Upsert suspend fun upsert(relay: Relay) diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt index 1b526bba..38bdce6b 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt @@ -7,6 +7,7 @@ import ac.aux.compose.database.model.BroadcastNostrEventRequest import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.database.model.Profile import ac.aux.compose.database.model.RecentSearch +import ac.aux.compose.database.model.Relay import ac.aux.compose.database.model.SynchronizeNostrEventRequest import ac.aux.compose.database.model.UnsignedNostrEvent import ac.aux.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest @@ -16,6 +17,7 @@ import ac.aux.compose.database.model.intermdiate.LocalProfile import ac.aux.compose.database.model.typealiases.SynchronizationFilterArray import ac.aux.compose.nostr.Relays import ac.aux.compose.repository.NostrRepository +import ac.aux.compose.repository.RelayRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -43,7 +45,7 @@ import kotlin.time.Instant class DatabaseNostrRepository( private val database: AuxDatabase -): NostrRepository { +): NostrRepository, RelayRepository { companion object { const val TAG = "DatabaseNostrRepository" } @@ -350,4 +352,8 @@ class DatabaseNostrRepository( ) ) } + + override suspend fun observePublicKeyRelays(publicKey: String): Flow> { + return database.relayDao().observePublicKeyRelays(publicKey) + } } diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/InvalidNostrPrivateKeyException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/InvalidNostrPrivateKeyException.kt new file mode 100644 index 00000000..14f35500 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/InvalidNostrPrivateKeyException.kt @@ -0,0 +1,3 @@ +package ac.aux.compose.exceptions + +class InvalidNostrPrivateKeyException: RuntimeException() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/NostrPublishException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/NostrPublishException.kt new file mode 100644 index 00000000..63044405 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/NostrPublishException.kt @@ -0,0 +1,3 @@ +package ac.aux.compose.exceptions + +class NostrPublishException(override val cause: Throwable?) : RuntimeException() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SignatureException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SignatureException.kt new file mode 100644 index 00000000..4db85945 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SignatureException.kt @@ -0,0 +1,4 @@ +package ac.aux.compose.exceptions + +open class SignatureException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningKeyNotFoundException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningKeyNotFoundException.kt new file mode 100644 index 00000000..ad24bd28 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningKeyNotFoundException.kt @@ -0,0 +1,8 @@ +package ac.aux.compose.exceptions + +class SigningKeyNotFoundException(message: String? = null, cause: Throwable? = null) : SignatureException( + message, + cause, +) +{ +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningRejectedException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningRejectedException.kt new file mode 100644 index 00000000..5ce41615 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/exceptions/SigningRejectedException.kt @@ -0,0 +1,4 @@ +package ac.aux.compose.exceptions + +class SigningRejectedException(message: String? = null, cause: Throwable? = null) : SignatureException(message, cause) { +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/managers/CredentialsManager.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/managers/CredentialsManager.kt new file mode 100644 index 00000000..219110c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/managers/CredentialsManager.kt @@ -0,0 +1,171 @@ +package ac.aux.compose.managers + +import ac.aux.compose.exceptions.InvalidNostrPrivateKeyException +import androidx.datastore.core.DataStore +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.secp256k1.Hex +import io.ktor.utils.io.core.toByteArray +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable + +/*** + * TODO: Make this a Singleton + */ +class CredentialsManager( + private val persistence: DataStore>, +) { + + private val scope = CoroutineScope(Dispatchers.IO) + + val credentials = persistence.data + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { persistence.data.first() }, + ) + + private suspend fun addCredential(credential: Credential) = persistence.updateData { it + credential } + + suspend fun clearCredentials() = persistence.updateData { emptySet() } + + fun isExternalSignerCredential(npub: String) = + checkCredentialType(npub = npub, credentialType = CredentialType.ExternalSigner) + + fun isNpubCredential(npub: String) = checkCredentialType(npub = npub, credentialType = CredentialType.PublicKey) + + suspend fun getOrCreateInternalSignerCredentials() = + credentials.value.find { it.type == CredentialType.InternalSigner } + ?: Lightning.randomKey().let { privateKey -> + Credential( + nsec = privateKey.value.toByteArray().toNsec(), + npub = privateKey.publicKey().value.toByteArray().toNpub(), + type = CredentialType.InternalSigner + ) + } + + private fun checkCredentialType(npub: String, credentialType: CredentialType) = + credentials.value.find { it.npub == npub }?.type == credentialType + + suspend fun saveNsec(nostrKey: String): String { + val (nsec, pubkey) = nostrKey.extractKeyPairFromPrivateKeyOrThrow() + addCredential(Credential(nsec = nsec, npub = pubkey, type = CredentialType.PrivateKey)) + return pubkey.bech32ToHexOrThrow() + } + + suspend fun saveExternalSignerNpub(npub: String): String { + val (hexKey, bech32Key) = if (npub.startsWith("npub")) { + npub.bech32ToHexOrThrow() to npub + } else { + npub to npub.hexToNpubHrp() + } + + addCredential(Credential(nsec = null, npub = bech32Key, type = CredentialType.ExternalSigner)) + return hexKey + } + + suspend fun saveNpub(npub: String): String { + addCredential(Credential(nsec = null, npub = npub, type = CredentialType.PublicKey)) + return npub.bech32ToHexOrThrow() + } + + suspend fun removeCredentialByNsec(nsec: String) = + persistence.updateData { + it.filterNot { cred -> cred.nsec == nsec }.toSet() + } + + suspend fun removeCredentialByNpub(npub: String) = + persistence.updateData { + it.filterNot { cred -> cred.npub == npub }.toSet() + } + + fun findOrThrow(npub: String): Credential = + credentials.value.find { it.npub == npub } + ?: throw IllegalArgumentException("Credential not found for $npub.") + + @Serializable + data class Credential( + val nsec: String?, + val npub: String, + val type: CredentialType = CredentialType.PrivateKey, + ) + + enum class CredentialType { + InternalSigner, + ExternalSigner, + PrivateKey, + PublicKey, + } + +} + +fun String.assureValidNsec() = if (startsWith("nsec")) this else this.hexToNsecHrp() +fun String.assureValidNpub() = if (startsWith("npub")) this else this.hexToNpubHrp() +fun String.assureValidPubKeyHex() = if (startsWith("npub")) this.bech32ToHexOrThrow() else this + + +fun String.hexToNoteHrp() = + Bech32.encodeBytes( + hrp = "note", + data = Hex.decode(this), + encoding = Bech32.Encoding.Bech32, + ) + +fun String.hexToNpubHrp() = + Bech32.encodeBytes( + hrp = "npub", + data = Hex.decode(this), + encoding = Bech32.Encoding.Bech32, + ) + +fun String.hexToNsecHrp() = + Bech32.encodeBytes( + hrp = "nsec", + data = Hex.decode(this), + encoding = Bech32.Encoding.Bech32, + ) + +fun String.urlToLnUrlHrp() = + Bech32.encodeBytes( + hrp = "lnurl", + data = this.toByteArray(), + encoding = Bech32.Encoding.Bech32, + ) + + +fun String.bech32ToHexOrThrow() = Bech32.decodeBytes(bech32 = this).second.toHex() + +fun String.bech32ToHexOrNull() = runCatching { this.bech32ToHexOrThrow() }.getOrNull() + +fun ByteArray.toNpub() = Bech32.encodeBytes(hrp = "npub", this, Bech32.Encoding.Bech32) + +@OptIn(ExperimentalStdlibApi::class) +fun ByteArray.toHex() = Hex.encode(this) + +@Throws(IllegalArgumentException::class) +fun String.bechToBytesOrThrow(hrp: String? = null): ByteArray { + val decodedForm = Bech32.decodeBytes(this) + hrp?.also { require(it == decodedForm.first) } + return decodedForm.second +} + +fun String.extractKeyPairFromPrivateKeyOrThrow(): Pair { + return try { + val nsec = this.assureValidNsec() + val decoded = Bech32.decodeBytes(nsec) + val pubkey = PrivateKey(decoded.second).publicKey().value.toByteArray() + nsec to pubkey.toNpub() + } catch (error: IllegalArgumentException) { + Logger.withTag("String.extractKeyPairFromPrivateKeyOrThrow").w(error) { error.message ?: "" } + throw InvalidNostrPrivateKeyException() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/UserAgent.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/UserAgent.kt new file mode 100644 index 00000000..50e9038a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/UserAgent.kt @@ -0,0 +1,15 @@ +package ac.aux.compose.network + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray + +object UserAgent { + const val APP_NAME = "Aux" + const val CLIENT_NAME = "Aux" +} + +fun String.asClientTag(): Array = arrayOf( + "client", + this +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelayPool.kt index 191624ed..b12757d5 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelayPool.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch -import net.primal.android.networking.relays.errors.NostrPublishException +import ac.aux.compose.exceptions.NostrPublishException import ac.aux.compose.network.sockets.NostrIncomingMessage import ac.aux.compose.network.sockets.NostrSocketClient import ac.aux.compose.network.sockets.NostrSocketClientFactory @@ -30,6 +30,11 @@ import ac.aux.compose.network.sockets.parseIncomingMessage import ac.aux.compose.repository.CachingImportRepository import co.touchlab.kermit.Logger +/** + * As seen in Primal + * + * https://github.com/PrimalHQ/primal-android-app/blob/8a912053131764c39711cd0dc5012645498c9a4f/app/src/main/kotlin/net/primal/android/networking/relays/RelayPool.kt + */ class RelayPool( private val nostrSocketClientFactory: NostrSocketClientFactory, private val cachingImportRepository: CachingImportRepository, diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelaysSocketManager.kt index 5faee18d..7187063a 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/RelaysSocketManager.kt @@ -15,9 +15,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import net.primal.android.networking.relays.errors.NostrPublishException +import ac.aux.compose.exceptions.NostrPublishException +/** + * As Seen in Primal + * + * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt + */ class RelaysSocketManager constructor( private val nostrSocketClientFactory: NostrSocketClientFactory, private val cachingImportRepository: CachingImportRepository, diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/errors/NostrPublishException.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/errors/NostrPublishException.kt deleted file mode 100644 index 85a551b5..00000000 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/network/relays/errors/NostrPublishException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.primal.android.networking.relays.errors - -class NostrPublishException(override val cause: Throwable?) : RuntimeException() diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/CachingImportRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/CachingImportRepository.kt index 60d4e4d7..066b2889 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/CachingImportRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/CachingImportRepository.kt @@ -3,10 +3,41 @@ package ac.aux.compose.repository import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.network.relays.broadcast.BroadcastEventResponse +/** + * As seen in Primal + * + * https://github.com/PrimalHQ/primal-android-app/blob/a7e32e2203555d27e60a749238175cd1ee0fa2af/domain/primal/src/commonMain/kotlin/net/primal/domain/global/CachingImportRepository.kt + * + */ interface CachingImportRepository { suspend fun cacheNostrEvents(vararg events: NostrEvent) suspend fun cacheNostrEvents(events: List) suspend fun importEvents(events: List): Boolean suspend fun broadcastEvents(events: List, relays: List): Result> + + companion object { + val NO_OP_CACHING_IMPORT_REPOSITORY = object : CachingImportRepository { + override suspend fun cacheNostrEvents(vararg events: NostrEvent) { + } + + override suspend fun cacheNostrEvents(events: List) { + + } + + override suspend fun importEvents(events: List): Boolean { + return true + } + + override suspend fun broadcastEvents( + events: List, + relays: List + ): Result> { + return Result.success( + emptyList() + ) + } + + } + } } diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrNotaryRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrNotaryRepository.kt new file mode 100644 index 00000000..7eb697ce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrNotaryRepository.kt @@ -0,0 +1,153 @@ +package ac.aux.compose.repository + +import ac.aux.compose.cryptography.signOrThrow +import ac.aux.compose.database.model.NostrEvent +import ac.aux.compose.database.model.UnsignedNostrEvent +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.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.nip51Lists.relayLists.RelayFeedsListEvent +import fr.acinq.secp256k1.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * As seen in Primal + * + * https://github.com/PrimalHQ/primal-android-app/blob/86e8d29df7f780e8d19ccff2362d7dc6398da634/app/src/main/kotlin/net/primal/android/nostr/notary/NostrNotary.kt + * + * TODO: Make this a Singleton + */ +class NostrNotaryRepository( + private val nostrRepository: NostrRepository, + private val credentialsStore: CredentialsManager, +) { + private val scope = CoroutineScope(Dispatchers.Main) + + private val _effects = Channel() + val effects = _effects.receiveAsFlow() + private fun setEffect(effect: NotarySideEffect) = scope.launch { _effects.send(effect) } + + private val signMutex = Mutex() + + private val _responses = Channel() + private fun setResponse(response: SignResult) = scope.launch { _responses.send(response) } + + suspend fun signNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): SignResult { + val result = try { + signNostrEvent(publicKey = unsignedNostrEvent.pubKey, event = unsignedNostrEvent) + } catch (error: SignatureException) { + return SignResult.Rejected(error) + } + + return if (result != null) { + SignResult.Signed(result) + } else { + signMutex.withLock { + setEffect(NotarySideEffect.RequestSignature(unsignedNostrEvent)) + _responses.receive() + } + } + } + + fun verifySignature(nostrEvent: NostrEvent): Boolean { + throw NotImplementedError() + } + + fun onSuccess(nostrEvent: NostrEvent) { + setResponse(SignResult.Signed(nostrEvent)) + } + + fun onFailure() { + setResponse(SignResult.Rejected(SigningRejectedException())) + } + + private fun findNsecOrThrow(pubkey: String): String = + runCatching { + val npub = Hex.decode(pubkey).toNpub() + credentialsStore.findOrThrow(npub = npub).nsec + }.getOrNull() ?: throw SigningKeyNotFoundException() + + private fun signNostrEvent(publicKey: String, event: UnsignedNostrEvent): NostrEvent? { + val isExternalSignerLogin = runCatching { + credentialsStore.isExternalSignerCredential(npub = publicKey.hexToNpubHrp()) + }.getOrDefault(false) + + if (isExternalSignerLogin) { + throw NotImplementedError() + } + + return event.signOrThrow(nsec = findNsecOrThrow(publicKey)) + } + + suspend fun signRelayListMetadata(userId: String, relays: List): SignResult { + return signNostrEvent( + unsignedNostrEvent = UnsignedNostrEvent( + pubKey = userId, + content = "", + kind = RelayFeedsListEvent.KIND, + tags = relays.map { + arrayOf( + "r", + it.url, + when { + it.read -> "read" + it.write -> "write" + else -> "" + } + ) + }.toTypedArray() + listOf(UserAgent.CLIENT_NAME.asClientTag()), + ), + ) + } + + sealed class NotarySideEffect { + data class RequestSignature(val unsignedEvent: UnsignedNostrEvent) : NotarySideEffect() + } + + sealed class SignResult { + data class Signed(val event: NostrEvent) : SignResult() + data class Rejected(val error: SignatureException) : SignResult() + + fun unwrapOrThrow(onFailure: ((SignatureException) -> Unit)? = null): NostrEvent = + when (this) { + is Rejected -> { + onFailure?.invoke(this.error) + throw this.error + } + + is Signed -> { + this.event + } + } + + fun getOrNull(onFailure: ((SignatureException) -> Unit)? = null): NostrEvent? = + when (this) { + is Rejected -> { + onFailure?.invoke(this.error) + null + } + + is Signed -> { + this.event + } + } + + fun getOrThrow(error: Throwable) = + when (this) { + is Rejected -> throw error + is Signed -> this.event + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrPublisherRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrPublisherRepository.kt new file mode 100644 index 00000000..a9adf8a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrPublisherRepository.kt @@ -0,0 +1,73 @@ +package ac.aux.compose.repository + +import ac.aux.compose.database.model.NostrEvent +import ac.aux.compose.database.model.UnsignedNostrEvent +import ac.aux.compose.exceptions.SignatureException +import ac.aux.compose.network.dto.RelayDTO +import ac.aux.compose.network.relays.RelaysSocketManager +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import ac.aux.compose.exceptions.NostrPublishException + +/** + * As Seen in Primal + * + * https://github.com/PrimalHQ/primal-android-app/blob/ec22916b4e3472a20bade82d53957a7ef9b708b0/app/src/main/kotlin/net/primal/android/nostr/publish/NostrPublisher.kt#L20 + */ +class NostrPublisherRepository( + private val relaysSocketManager: RelaysSocketManager, + private val nostrNotary: NostrNotaryRepository, + private val cachingImportRepository: CachingImportRepository, +) { + val logger = Logger.withTag("NostrPublisherRepository") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private fun importEvent(event: NostrEvent) { + scope.launch { + runCatching { + cachingImportRepository.importEvents(events = listOf(event)) + }.onFailure { error -> + logger.w(throwable = error) { "Failed to import event ${event.id} to caching server." } + } + } + } + + @Throws(NostrPublishException::class) + private suspend fun publishAndImportEvent(signedNostrEvent: NostrEvent, outboxRelays: List = emptyList()) { + relaysSocketManager.publishEvent(signedNostrEvent) + importEvent(signedNostrEvent) + if (outboxRelays.isNotEmpty()) { + runCatching { + relaysSocketManager.publishEvent( + nostrEvent = signedNostrEvent, + relays = outboxRelays.map { RelayDTO(url = it, read = false, write = true) }, + ) + }.onFailure { error -> + logger.w(throwable = error) { "Failed to publish to outbox relays." } + } + } + } + + @Throws(NostrPublishException::class, SignatureException::class) + suspend fun signPublishImportNostrEvent( + unsignedNostrEvent: UnsignedNostrEvent, + outboxRelays: List, + ): NostrEvent { + val signedNostrEvent = nostrNotary.signNostrEvent(unsignedNostrEvent = unsignedNostrEvent).unwrapOrThrow() + publishAndImportEvent(signedNostrEvent = signedNostrEvent, outboxRelays = outboxRelays) + return signedNostrEvent + } + + + @Throws(NostrPublishException::class, SignatureException::class) + suspend fun publishRelayList(userId: String, relays: List): NostrEvent { + val signedNostrEvent = nostrNotary.signRelayListMetadata(userId = userId, relays = relays).unwrapOrThrow() + relaysSocketManager.publishEvent(nostrEvent = signedNostrEvent, relays = relays) + importEvent(signedNostrEvent) + return signedNostrEvent + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt index a43e565a..2a766c2f 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/NavigationViewModel.kt @@ -3,6 +3,8 @@ package ac.aux.compose.ui.view.model import ac.aux.compose.database.model.NostrEvent import ac.aux.compose.managers.SeedManager import ac.aux.compose.network.NostrEventBroadcaster +import ac.aux.compose.network.relays.RelaysSocketManager +import ac.aux.compose.network.sockets.NostrSocketClientFactory import ac.aux.compose.nostr.Relays import ac.aux.compose.repository.NostrRepository import ac.aux.compose.ui.view.state.NavigationUIState diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 812345f3..b81f1790 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" composeHotReload = "1.0.0" composeMultiplatform = "1.10.3" +datastorePreferences = "1.2.1" junit = "4.13.2" kermit = "2.1.0" kotlin = "2.3.10" @@ -39,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-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" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }