fix: nostr operations cause app crashing #20

Merged
reya merged 4 commits from fix-external-signer into master 2026-06-12 08:49:15 +00:00
6 changed files with 47 additions and 43 deletions

View File

@@ -24,7 +24,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.6") implementation("su.reya:nostr-sdk-kmp:0.2.7")
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")

View File

@@ -61,7 +61,7 @@ class AndroidExternalSigner(
): String? { ): String? {
// Try Content Resolver first // Try Content Resolver first
queryContentResolver(type, payload, pubkey, currentUser)?.let { queryContentResolver(type, payload, pubkey, currentUser)?.let {
return it.result return if (resultKey == "event") it.event else it.result
} }
// Fall back to Intent // Fall back to Intent

View File

@@ -4,23 +4,30 @@ import android.content.Intent
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class ExternalSignerLauncher { class ExternalSignerLauncher {
private var launcher: ActivityResultLauncher<Intent>? = null private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex()
fun register(launcher: ActivityResultLauncher<Intent>) { fun register(launcher: ActivityResultLauncher<Intent>) {
this.launcher = launcher this.launcher = launcher
} }
suspend fun launch(intent: Intent): ActivityResult { suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
val deferred = CompletableDeferred<ActivityResult>() withContext(Dispatchers.Main) {
pendingResult = deferred val deferred = CompletableDeferred<ActivityResult>()
launcher?.launch(intent) pendingResult = deferred
?: throw IllegalStateException("ExternalSignerLauncher not registered") launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
return deferred.await() deferred.await()
}
} }
fun onResult(result: ActivityResult) { fun onResult(result: ActivityResult) {
pendingResult?.complete(result) pendingResult?.complete(result)
pendingResult = null pendingResult = null

View File

@@ -110,17 +110,17 @@ fun ChatScreen(id: Long) {
// Start loading spinner // Start loading spinner
loading = true loading = true
// Get msg relays for each member
viewModel.chatRoomConnect(id)
// Get messages // Get messages
val initialMessages = viewModel.getChatRoomMessages(id) val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear() messages.clear()
messages.addAll(initialMessages) messages.addAll(initialMessages)
// Stop loading spinner // Stop loading spinner
loading = false loading = false
// Get msg relays for each member
viewModel.chatRoomConnect(id)
// Handle new messages // Handle new messages
viewModel.newEvents.collect { event -> viewModel.newEvents.collect { event ->
if (event.roomId() == id) { if (event.roomId() == id) {

View File

@@ -33,7 +33,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose) 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.6") implementation("su.reya:nostr-sdk-kmp:0.2.7")
implementation("com.squareup.okio:okio:3.16.2") implementation("com.squareup.okio:okio:3.16.2")
} }
androidMain.dependencies { androidMain.dependencies {

View File

@@ -379,27 +379,25 @@ class Nostr {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try { try {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Construct the room id // Construct the room id
val roomId = rumor.roomId() val roomId = rumor.roomId()
// Construct reference tags // Construct reference tags
val tags = listOf( val tags = listOf(
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.event(rumor.id()!!), Tag.event(rumor.id()!!),
Tag.custom("a", listOf(roomId.toString())), Tag.custom("r", listOf(roomId.toString())),
Tag.custom("k", listOf("14")) Tag.custom("k", listOf("14"))
) )
// Set event kind // Set event kind
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
// Construct event
val event = EventBuilder(kind, rumor.asJson()) val event = EventBuilder(kind, rumor.asJson())
.tags(tags) .tags(tags)
.finalizeUnsigned(currentUser) .finalizeAsync(Keys.generate())
.signAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Exception) { } catch (e: Exception) {
@@ -408,27 +406,22 @@ class Nostr {
} }
private suspend fun extractRumor(event: Event): UnsignedEvent? { private suspend fun extractRumor(event: Event): UnsignedEvent? {
// Check if the rumor is already cached try {
val cachedRumor = getCachedRumor(event.id()) // Check if the rumor is already cached
if (cachedRumor != null) return cachedRumor val cachedRumor = getCachedRumor(event.id())
if (cachedRumor != null) return cachedRumor
// Get all signers // Unwrap the gift with current signer
val signers = listOfNotNull(signer, deviceSigner) val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
if (signers.isEmpty()) return null val rumor = gift.rumor()
// Try to unwrap the gift with each signer // Save the rumor to the database
for (signer in signers) { setCachedRumor(event.id(), rumor)
try {
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) // Return the rumor
val rumor = gift.rumor() return rumor
// Save the rumor to the database } catch (e: Exception) {
setCachedRumor(event.id(), rumor) println("Failed to unwrap gift: ${e.message}")
// Return the rumor
return rumor
} catch (e: Exception) {
println("Failed to unwrap gift: ${e.message}")
continue
}
} }
return null return null
@@ -686,12 +679,14 @@ class Nostr {
suspend fun getChatRooms(): Set<Room>? { suspend fun getChatRooms(): Set<Room>? {
try { try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") val userPubkey =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K) val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user // Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)
// Collect rooms // Collect rooms
@@ -707,8 +702,9 @@ class Nostr {
// Check if the room already exists // Check if the room already exists
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
val filter = val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList()) val pubkeys = newRoom.members.toList()
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
// Determine if it's an ongoing room // Determine if it's an ongoing room
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
@@ -789,7 +785,7 @@ class Nostr {
) { ) {
try { try {
val currentUser = val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in") signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>() val tags = mutableListOf<Tag>()
@@ -816,6 +812,7 @@ class Nostr {
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.tags(tags) .tags(tags)
.finalizeUnsigned(currentUser) .finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen // Emit the rumor to the chat screen
if (receiver == currentUser) { if (receiver == currentUser) {