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) <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-06-12 13:29:56 -03:00
co-authored by Claude Opus 4.8
parent b9883109d2
commit e21cef4011
3 changed files with 53 additions and 17 deletions
@@ -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()
@@ -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<Message>(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()
@@ -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,