From d379b60d47aaade817b053e7a441630da9087f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 13:12:09 +0000 Subject: [PATCH] Add stable/beta update channel toggle Release events on kind 30063 are now filtered by the `c` (channel) tag. Stable users skip events tagged `c=beta`; events with no `c` tag are treated as stable for backwards compatibility with legacy releases. Update discovery also now queries relay.damus.io and nos.lol alongside relay.zapstore.dev so beta/stable events published to mainstream relays are picked up. Switching channel resets the last-check timestamp so the next check runs immediately rather than waiting out the daily/weekly window. --- .../nostrsigner/LocalPreferences.kt | 18 +++++++++++++ .../nostrsigner/models/AmberSettings.kt | 1 + .../nostrsigner/models/UpdateChannel.kt | 8 ++++++ .../nostrsigner/service/ZapstoreUpdater.kt | 24 ++++++++++++----- .../nostrsigner/ui/UpdateSettingsScreen.kt | 27 +++++++++++++++++++ app/src/main/res/values/strings.xml | 4 +++ 6 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/models/UpdateChannel.kt diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index b3e6610c..3e1590b1 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -11,6 +11,7 @@ import androidx.core.os.LocaleListCompat import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.AmberSettings import com.greenart7c3.nostrsigner.models.TorMode +import com.greenart7c3.nostrsigner.models.UpdateChannel import com.greenart7c3.nostrsigner.models.UpdateCheckFrequency import com.greenart7c3.nostrsigner.models.defaultAppRelays import com.greenart7c3.nostrsigner.models.defaultIndexerRelays @@ -59,6 +60,7 @@ private enum class SettingsKeys(val key: String) { AUTH_WHITELIST("auth_whitelist"), AUTO_CHECK_UPDATES("auto_check_updates"), UPDATE_CHECK_FREQUENCY("update_check_frequency"), + UPDATE_CHANNEL("update_channel"), LAST_UPDATE_CHECK_TIME("last_update_check_time"), START_SERVICE_ON_BOOT("start_service_on_boot"), WEBDAV_URL("webdav_url"), @@ -264,6 +266,13 @@ object LocalPreferences { } catch (_: IllegalArgumentException) { UpdateCheckFrequency.DAILY }, + updateChannel = try { + UpdateChannel.valueOf( + getString(SettingsKeys.UPDATE_CHANNEL.key, UpdateChannel.STABLE.name)!!, + ) + } catch (_: IllegalArgumentException) { + UpdateChannel.STABLE + }, startServiceOnBoot = getBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, true), rateLimitEnabled = getBoolean(SettingsKeys.RATE_LIMIT_ENABLED.key, true), rateLimitMaxPerWindow = getInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, 5), @@ -535,6 +544,15 @@ object LocalPreferences { Amber.instance.settings = loadSettingsFromEncryptedStorage() } + fun updateUpdateChannel(context: Context, channel: UpdateChannel) { + sharedPrefs(context).edit { + apply { + putString(SettingsKeys.UPDATE_CHANNEL.key, channel.name) + } + } + Amber.instance.settings = loadSettingsFromEncryptedStorage() + } + fun getLastUpdateCheckTime(context: Context): Long = sharedPrefs(context).getLong(SettingsKeys.LAST_UPDATE_CHECK_TIME.key, 0L) fun setLastUpdateCheckTime(context: Context, time: Long) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt index fc59eee4..40084a59 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt @@ -32,6 +32,7 @@ data class AmberSettings( val authWhitelist: List = emptyList(), val autoCheckUpdates: Boolean = true, val updateCheckFrequency: UpdateCheckFrequency = UpdateCheckFrequency.DAILY, + val updateChannel: UpdateChannel = UpdateChannel.STABLE, val startServiceOnBoot: Boolean = true, val rateLimitEnabled: Boolean = true, val rateLimitMaxPerWindow: Int = 5, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/UpdateChannel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/UpdateChannel.kt new file mode 100644 index 00000000..4ed19d84 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/UpdateChannel.kt @@ -0,0 +1,8 @@ +package com.greenart7c3.nostrsigner.models + +import com.greenart7c3.nostrsigner.R + +enum class UpdateChannel(val resourceId: Int) { + STABLE(R.string.update_channel_stable), + BETA(R.string.update_channel_beta), +} 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 df64545f..979fb01f 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.models.UpdateChannel import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.verify @@ -34,7 +35,11 @@ import okhttp3.Request private const val EOSE_TIMEOUT_MS = 15_000L private const val RELEASE_KIND = 30063 -private const val ZAPSTORE_RELAY = "wss://relay.zapstore.dev" +private val UPDATE_RELAY_URLS = listOf( + "wss://relay.zapstore.dev", + "wss://relay.damus.io", + "wss://nos.lol", +) private const val APP_IDENTIFIER = "com.greenart7c3.nostrsigner" enum class DownloadState { @@ -57,7 +62,8 @@ class ZapstoreUpdater( private val fileSubId = UUID.randomUUID().toString() private val pendingFileEventIds = mutableListOf() private val versionByEventId = mutableMapOf>() - private val zapstoreRelay: NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(ZAPSTORE_RELAY) + private val updateRelays: List = + UPDATE_RELAY_URLS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } private var timeoutJob: Job? = null init { @@ -76,7 +82,7 @@ class ZapstoreUpdater( fun checkForUpdates() { if (BuildFlavorChecker.isOfflineFlavor()) return - val relay = zapstoreRelay ?: return + if (updateRelays.isEmpty()) return if (isChecking.value) return isChecking.value = true @@ -91,7 +97,7 @@ class ZapstoreUpdater( limit = 10, ) - client.subscribe(releaseSubId, mapOf(relay to listOf(releaseFilter))) + client.subscribe(releaseSubId, updateRelays.associateWith { listOf(releaseFilter) }) timeoutJob = scope.launch { delay(EOSE_TIMEOUT_MS) @@ -114,6 +120,12 @@ class ZapstoreUpdater( val isForThisApp = identifier.startsWith(APP_IDENTIFIER) || appId == APP_IDENTIFIER if (!isForThisApp) return + // Channel filter: stable users skip events tagged c=beta. Events with no `c` + // 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 version = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.getOrNull(1) ?: return if (!isNewerVersion(version)) return @@ -144,14 +156,14 @@ class ZapstoreUpdater( } private fun fetchFileEvents() { - val relay = zapstoreRelay ?: run { + if (updateRelays.isEmpty()) { finishCheck() return } val fileFilter = Filter( ids = pendingFileEventIds.toList(), ) - client.subscribe(fileSubId, mapOf(relay to listOf(fileFilter))) + client.subscribe(fileSubId, updateRelays.associateWith { listOf(fileFilter) }) } private fun processFileEvent(event: Event) { 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 3dcdd395..ff9777f2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/UpdateSettingsScreen.kt @@ -30,6 +30,7 @@ import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.models.UpdateChannel import com.greenart7c3.nostrsigner.models.UpdateCheckFrequency import com.greenart7c3.nostrsigner.service.DownloadState import com.greenart7c3.nostrsigner.ui.components.TitleExplainer @@ -68,6 +69,7 @@ fun UpdateSettingsScreen(modifier: Modifier = Modifier) { val dlProgress by updater.downloadProgress.collectAsStateWithLifecycle() var autoCheck by remember { mutableStateOf(Amber.instance.settings.autoCheckUpdates) } var frequency by remember { mutableStateOf(Amber.instance.settings.updateCheckFrequency) } + var channel by remember { mutableStateOf(Amber.instance.settings.updateChannel) } // Status row val statusText = when { @@ -168,5 +170,30 @@ fun UpdateSettingsScreen(modifier: Modifier = Modifier) { }, ) } + + Spacer(modifier = Modifier.height(8.dp)) + + val channelOptions = UpdateChannel.entries.map { + TitleExplainer(stringResource(it.resourceId)) + }.toImmutableList() + + SettingsRow( + name = R.string.update_channel, + description = R.string.update_channel_description, + selectedItems = channelOptions, + selectedIndex = UpdateChannel.entries.indexOf(channel), + onSelect = { index -> + val selected = UpdateChannel.entries[index] + if (selected != channel) { + channel = selected + scope.launch(Dispatchers.IO) { + LocalPreferences.updateUpdateChannel(context, selected) + // Reset last-check timestamp so the next check actually runs + // and surfaces a release on the newly selected channel. + LocalPreferences.setLastUpdateCheckTime(context, 0L) + } + } + }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53da0135..92978c89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -628,6 +628,10 @@ Every app start Daily Weekly + Update channel + Choose between stable and beta (pre-release) builds + Stable + Beta (pre-release) Current version: %1$s Updates are not available for this build. Discover more Nostr apps for android at