From e21cef401121fc333ca840df7d53b0de632952cd Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 12 Jun 2026 13:29:56 -0300 Subject: [PATCH] Fix release update check missing file subscription on EOSE race Each incoming relay message was dispatched into its own coroutine, so the release EOSE could run before the release EVENT had populated pendingFileEventIds. onReleaseEose() then saw an empty list and called finishCheck() instead of opening the file subscription, so no update was ever found. Route messages through a FIFO channel consumed by a single coroutine so the release EVENT is always processed before its EOSE. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/greenart7c3/nostrsigner/Amber.kt | 2 +- .../nostrsigner/service/ZapstoreUpdater.kt | 56 +++++++++++++++---- .../nostrsigner/ui/UpdateSettingsScreen.kt | 12 ++-- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index 8f4d1455..3f9c0e2c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -626,7 +626,7 @@ class Amber : applicationIOScope.cancel() } - fun maybeCheckForUpdates() { + suspend fun maybeCheckForUpdates() { if (zapstoreUpdater == null) return val lastCheck = LocalPreferences.getLastUpdateCheckTime(this) val now = System.currentTimeMillis() diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt index 979fb01f..71fa5af3 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt @@ -8,6 +8,7 @@ import androidx.core.content.FileProvider import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.BuildFlavorChecker +import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.models.UpdateChannel import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.vitorpamplona.quartz.nip01Core.core.Event @@ -28,9 +29,11 @@ import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.Request private const val EOSE_TIMEOUT_MS = 15_000L @@ -66,21 +69,36 @@ class ZapstoreUpdater( UPDATE_RELAY_URLS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } private var timeoutJob: Job? = null + // Messages are processed sequentially in arrival order through this channel. + // Launching a coroutine per message let the release EOSE run before the release + // EVENT had populated pendingFileEventIds, so the file subscription never opened. + private val messages = Channel(Channel.UNLIMITED) + init { client.addConnectionListener(this) + scope.launch { + for (msg in messages) { + when (msg) { + is EventMessage if msg.subId == releaseSubId -> processReleaseEvent(msg.event) + is EventMessage if msg.subId == fileSubId -> processFileEvent(msg.event) + is EoseMessage if msg.subId == releaseSubId -> onReleaseEose() + is EoseMessage if msg.subId == fileSubId -> finishCheck() + else -> {} + } + } + } } override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { when (msg) { - is EventMessage if msg.subId == releaseSubId -> processReleaseEvent(msg.event) - is EventMessage if msg.subId == fileSubId -> processFileEvent(msg.event) - is EoseMessage if msg.subId == releaseSubId -> onReleaseEose() - is EoseMessage if msg.subId == fileSubId -> finishCheck() + is EventMessage if (msg.subId == releaseSubId || msg.subId == fileSubId) -> messages.trySend(msg) + is EoseMessage if (msg.subId == releaseSubId || msg.subId == fileSubId) -> messages.trySend(msg) + else -> {} } super.onIncomingMessage(relay, msgStr, msg) } - fun checkForUpdates() { + suspend fun checkForUpdates() { if (BuildFlavorChecker.isOfflineFlavor()) return if (updateRelays.isEmpty()) return if (isChecking.value) return @@ -94,10 +112,20 @@ class ZapstoreUpdater( val releaseFilter = Filter( kinds = listOf(RELEASE_KIND), authors = listOf(Amber.DEVELOPER_HEX_KEY), - limit = 10, + tags = mapOf("i" to listOf("com.greenart7c3.nostrsigner")), + limit = 1, ) - client.subscribe(releaseSubId, updateRelays.associateWith { listOf(releaseFilter) }) + val settings = withContext(Dispatchers.IO) { + LocalPreferences.loadSettingsFromEncryptedStorage() + } + if (settings.updateChannel == UpdateChannel.STABLE) { + Log.d("zapstore", "zapstore relay") + client.subscribe(releaseSubId, listOf(updateRelays.first()).associateWith { listOf(releaseFilter) }) + } else { + Log.d("zapstore", "other relays") + client.subscribe(releaseSubId, updateRelays.drop(1).associateWith { listOf(releaseFilter) }) + } timeoutJob = scope.launch { delay(EOSE_TIMEOUT_MS) @@ -106,7 +134,7 @@ class ZapstoreUpdater( } } - private fun processReleaseEvent(event: Event) { + private suspend fun processReleaseEvent(event: Event) { if (!event.verify()) { Log.w(Amber.TAG, "ZapstoreUpdater: invalid release event: ${event.toJson()}") return @@ -124,7 +152,10 @@ class ZapstoreUpdater( // tag (legacy releases) are treated as stable and shown on both channels. val channelTag = tags.firstOrNull { it.size > 1 && it[0] == "c" }?.getOrNull(1) val isBeta = channelTag.equals("beta", ignoreCase = true) - if (isBeta && Amber.instance.settings.updateChannel == UpdateChannel.STABLE) return + val settings = withContext(Dispatchers.IO) { + LocalPreferences.loadSettingsFromEncryptedStorage() + } + if (isBeta && settings.updateChannel == UpdateChannel.STABLE) return val version = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.getOrNull(1) ?: return if (!isNewerVersion(version)) return @@ -243,8 +274,11 @@ class ZapstoreUpdater( } } - private fun downloadApk(context: Context, release: ZapstoreRelease): File? { - val useProxy = Amber.instance.settings.torMode != com.greenart7c3.nostrsigner.models.TorMode.DISABLED + private suspend fun downloadApk(context: Context, release: ZapstoreRelease): File? { + val settings = withContext(Dispatchers.IO) { + LocalPreferences.loadSettingsFromEncryptedStorage() + } + val useProxy = settings.torMode != com.greenart7c3.nostrsigner.models.TorMode.DISABLED val client = HttpClientManager.getHttpClient(useProxy) val request = Request.Builder().url(release.url).build() val response = client.newCall(request).execute() diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt index ff9777f2..435b3e2c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt @@ -104,11 +104,13 @@ fun UpdateSettingsScreen(modifier: Modifier = Modifier) { // Action button Button( onClick = { - if (latestRelease != null && dlState == DownloadState.IDLE) { - updater.downloadAndInstall(context, latestRelease!!) - } else if (!isChecking && dlState != DownloadState.DOWNLOADING) { - LocalPreferences.setLastUpdateCheckTime(context, 0L) - updater.checkForUpdates() + Amber.instance.applicationIOScope.launch { + if (latestRelease != null && dlState == DownloadState.IDLE) { + updater.downloadAndInstall(context, latestRelease!!) + } else if (!isChecking && dlState != DownloadState.DOWNLOADING) { + LocalPreferences.setLastUpdateCheckTime(context, 0L) + updater.checkForUpdates() + } } }, enabled = !isChecking && dlState != DownloadState.DOWNLOADING,