Add zapstore self-updater for free flavor

- ZapstoreUpdater subscribes to wss://relay.zapstore.dev for kind 30063
  release events and kind 1063 file metadata events published by the
  developer. It compares the incoming version against the running
  BuildConfig.VERSION_NAME and exposes latestRelease as a StateFlow.
- When an update is found the updater downloads the APK via OkHttp,
  verifies the SHA-256 hash from the event tags, and installs it via
  Android PackageInstaller intent using a FileProvider URI.
- SettingsScreen shows a "Check for updates" row that changes to
  "Update available: vX.Y.Z" when a newer version is detected, with
  download progress and error state feedback.
- All self-update code is gated behind !BuildFlavorChecker.isOfflineFlavor()
  so F-Droid (offline) builds are completely unaffected.
- Added REQUEST_INSTALL_PACKAGES permission and FileProvider declaration
  to AndroidManifest.xml, and the file_provider_paths.xml resource.

https://claude.ai/code/session_01NmFcCFmEbY7NMradkEU5bv
This commit is contained in:
Claude
2026-04-06 13:59:24 -03:00
committed by greenart7c3
parent f581c5470e
commit 01b7d73d85
7 changed files with 394 additions and 0 deletions
+11
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".Amber" android:name=".Amber"
@@ -58,6 +59,16 @@
android:exported="true" android:exported="true"
tools:ignore="ExportedContentProvider" /> tools:ignore="ExportedContentProvider" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
@@ -45,6 +45,7 @@ import com.greenart7c3.nostrsigner.service.ConnectivityService
import com.greenart7c3.nostrsigner.service.NotificationSubscription import com.greenart7c3.nostrsigner.service.NotificationSubscription
import com.greenart7c3.nostrsigner.service.ProfileSubscription import com.greenart7c3.nostrsigner.service.ProfileSubscription
import com.greenart7c3.nostrsigner.service.TorManager import com.greenart7c3.nostrsigner.service.TorManager
import com.greenart7c3.nostrsigner.service.ZapstoreUpdater
import com.greenart7c3.nostrsigner.service.crashreports.CrashReportCache import com.greenart7c3.nostrsigner.service.crashreports.CrashReportCache
import com.greenart7c3.nostrsigner.service.crashreports.UnexpectedCrashSaver import com.greenart7c3.nostrsigner.service.crashreports.UnexpectedCrashSaver
import com.greenart7c3.nostrsigner.ui.ToastManager import com.greenart7c3.nostrsigner.ui.ToastManager
@@ -131,6 +132,12 @@ class Amber :
// This runs on the foreground only // This runs on the foreground only
val profileSubscription = ProfileSubscription(client, this, applicationIOScope) val profileSubscription = ProfileSubscription(client, this, applicationIOScope)
val zapstoreUpdater: ZapstoreUpdater? = if (!BuildFlavorChecker.isOfflineFlavor()) {
ZapstoreUpdater(client, applicationIOScope)
} else {
null
}
private var databases = ConcurrentHashMap<String, AppDatabase>() private var databases = ConcurrentHashMap<String, AppDatabase>()
private var logDatabases = ConcurrentHashMap<String, LogDatabase>() private var logDatabases = ConcurrentHashMap<String, LogDatabase>()
private var historyDatabases = ConcurrentHashMap<String, HistoryDatabase>() private var historyDatabases = ConcurrentHashMap<String, HistoryDatabase>()
@@ -0,0 +1,8 @@
package com.greenart7c3.nostrsigner.service
data class ZapstoreRelease(
val version: String,
val url: String,
val hash: String?,
val createdAt: Long,
)
@@ -0,0 +1,300 @@
package com.greenart7c3.nostrsigner.service
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.FileProvider
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildConfig
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import okhttp3.Request
private const val EOSE_TIMEOUT_MS = 15_000L
private const val RELEASE_KIND = 30063
private const val FILE_METADATA_KIND = 1063
private const val ZAPSTORE_RELAY = "wss://relay.zapstore.dev"
private const val APP_IDENTIFIER = "com.greenart7c3.nostrsigner"
enum class DownloadState {
IDLE,
DOWNLOADING,
INSTALLING,
ERROR,
}
class ZapstoreUpdater(
private val client: NostrClient,
private val scope: CoroutineScope,
) : IRelayClientListener {
val latestRelease = MutableStateFlow<ZapstoreRelease?>(null)
val isChecking = MutableStateFlow(false)
val downloadState = MutableStateFlow(DownloadState.IDLE)
val downloadProgress = MutableStateFlow(0f)
private val releaseSubId = UUID.randomUUID().toString()
private val fileSubId = UUID.randomUUID().toString()
private val pendingFileEventIds = mutableListOf<String>()
private val versionByEventId = mutableMapOf<String, Pair<String, Long>>()
private val zapstoreRelay: NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(ZAPSTORE_RELAY)
private var timeoutJob: Job? = null
init {
client.subscribe(this)
}
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
when {
msg is EventMessage && msg.subId == releaseSubId -> processReleaseEvent(msg.event)
msg is EventMessage && msg.subId == fileSubId -> processFileEvent(msg.event)
msg is EoseMessage && msg.subId == releaseSubId -> onReleaseEose()
msg is EoseMessage && msg.subId == fileSubId -> finishCheck()
}
super.onIncomingMessage(relay, msgStr, msg)
}
fun checkForUpdates() {
if (BuildFlavorChecker.isOfflineFlavor()) return
val relay = zapstoreRelay ?: return
if (isChecking.value) return
isChecking.value = true
pendingFileEventIds.clear()
versionByEventId.clear()
latestRelease.value = null
// Subscribe to release events (kind 30063) from the developer
val releaseFilter = Filter(
kinds = listOf(RELEASE_KIND),
authors = listOf(Amber.DEVELOPER_HEX_KEY),
limit = 10,
)
client.openReqSubscription(releaseSubId, mapOf(relay to listOf(releaseFilter)))
timeoutJob = scope.launch {
delay(EOSE_TIMEOUT_MS)
Log.w(Amber.TAG, "ZapstoreUpdater: timeout waiting for EOSE")
finishCheck()
}
}
private fun processReleaseEvent(event: com.vitorpamplona.quartz.nip01Core.core.Event) {
val tags = event.tags
// Only process events for this app
val identifier = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1) ?: ""
val appId = tags.firstOrNull { it.size > 1 && it[0] == "i" }?.getOrNull(1) ?: ""
val isForThisApp = identifier.startsWith(APP_IDENTIFIER) || appId == APP_IDENTIFIER
if (!isForThisApp) return
val version = tags.firstOrNull { it.size > 1 && it[0] == "version" }?.getOrNull(1) ?: return
if (!isNewerVersion(version)) return
Log.d(Amber.TAG, "ZapstoreUpdater: found release $version (current: ${BuildConfig.VERSION_NAME})")
// Try direct URL first (some release events include it)
val directUrl = tags.firstOrNull { it.size > 1 && it[0] == "url" }?.getOrNull(1)
if (directUrl != null) {
val hash = tags.firstOrNull { it.size > 1 && (it[0] == "x" || it[0] == "hash") }?.getOrNull(1)
updateLatestRelease(version, directUrl, hash, event.createdAt)
return
}
// Collect file event IDs from e-tags for second subscription
tags.filter { it.size > 1 && it[0] == "e" }.forEach { tag ->
val eventId = tag[1]
pendingFileEventIds.add(eventId)
versionByEventId[eventId] = Pair(version, event.createdAt)
}
}
private fun onReleaseEose() {
if (pendingFileEventIds.isNotEmpty()) {
fetchFileEvents()
} else {
finishCheck()
}
}
private fun fetchFileEvents() {
val relay = zapstoreRelay ?: run {
finishCheck()
return
}
val fileFilter = Filter(
kinds = listOf(FILE_METADATA_KIND),
ids = pendingFileEventIds.toList(),
)
client.openReqSubscription(fileSubId, mapOf(relay to listOf(fileFilter)))
}
private fun processFileEvent(event: com.vitorpamplona.quartz.nip01Core.core.Event) {
val tags = event.tags
val url = tags.firstOrNull { it.size > 1 && it[0] == "url" }?.getOrNull(1) ?: return
val mimeType = tags.firstOrNull { it.size > 1 && it[0] == "m" }?.getOrNull(1)
val platform = tags.firstOrNull { it.size > 1 && it[0] == "platform" }?.getOrNull(1)
// Only process Android APK files
val isApk = mimeType?.contains("android") == true || url.endsWith(".apk")
val isAndroid = platform == null || platform == "android"
if (!isApk || !isAndroid) return
// Skip ABI-specific builds that don't match the running device ABI (prefer universal)
val arch = tags.firstOrNull { it.size > 1 && it[0] == "arch" }?.getOrNull(1)
if (arch != null && arch != "universal" && !isMatchingArch(arch)) return
val hash = tags.firstOrNull { it.size > 1 && it[0] == "x" }?.getOrNull(1)
val versionInfo = versionByEventId[event.id] ?: return
val (version, createdAt) = versionInfo
updateLatestRelease(version, url, hash, createdAt)
}
private fun isMatchingArch(arch: String): Boolean {
val supportedAbis = android.os.Build.SUPPORTED_ABIS.toList()
return supportedAbis.any { it.equals(arch, ignoreCase = true) }
}
private fun updateLatestRelease(version: String, url: String, hash: String?, createdAt: Long) {
val current = latestRelease.value
if (current == null || createdAt > current.createdAt) {
latestRelease.value = ZapstoreRelease(version = version, url = url, hash = hash, createdAt = createdAt)
}
}
private fun finishCheck() {
timeoutJob?.cancel()
timeoutJob = null
Amber.instance.intentionalDisconnectTime = System.currentTimeMillis()
client.close(releaseSubId)
client.close(fileSubId)
isChecking.value = false
pendingFileEventIds.clear()
}
fun downloadAndInstall(context: Context, release: ZapstoreRelease) {
if (BuildFlavorChecker.isOfflineFlavor()) return
if (downloadState.value == DownloadState.DOWNLOADING) return
scope.launch(Dispatchers.IO) {
downloadState.value = DownloadState.DOWNLOADING
downloadProgress.value = 0f
try {
val apkFile = downloadApk(context, release)
if (apkFile != null) {
downloadState.value = DownloadState.INSTALLING
installApk(context, apkFile)
} else {
downloadState.value = DownloadState.ERROR
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(Amber.TAG, "ZapstoreUpdater: download failed", e)
downloadState.value = DownloadState.ERROR
}
}
}
private fun downloadApk(context: Context, release: ZapstoreRelease): File? {
val useProxy = Amber.instance.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()
if (!response.isSuccessful) {
Log.e(Amber.TAG, "ZapstoreUpdater: HTTP ${response.code} for ${release.url}")
return null
}
val body = response.body ?: return null
val contentLength = body.contentLength()
val apkFile = File(context.cacheDir, "amber-update-${release.version}.apk")
val digest = if (release.hash != null) MessageDigest.getInstance("SHA-256") else null
var bytesRead = 0L
apkFile.outputStream().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8 * 1024)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
digest?.update(buffer, 0, read)
bytesRead += read
if (contentLength > 0) {
downloadProgress.value = bytesRead.toFloat() / contentLength.toFloat()
}
}
}
}
if (digest != null && release.hash != null) {
val actualHash = digest.digest().joinToString("") { "%02x".format(it) }
val expectedHash = release.hash.removePrefix("sha256:")
if (!actualHash.equals(expectedHash, ignoreCase = true)) {
Log.e(Amber.TAG, "ZapstoreUpdater: hash mismatch! expected=$expectedHash actual=$actualHash")
apkFile.delete()
return null
}
Log.d(Amber.TAG, "ZapstoreUpdater: hash verified OK")
}
return apkFile
}
private fun installApk(context: Context, apkFile: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
scope.launch(Dispatchers.Main) {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
private fun isNewerVersion(version: String): Boolean {
val current = BuildConfig.VERSION_NAME.substringBefore("-")
if (version == current) return false
return try {
val newParts = version.substringBefore("-").split(".").map { it.toInt() }
val curParts = current.split(".").map { it.toInt() }
for (i in 0 until maxOf(newParts.size, curParts.size)) {
val n = newParts.getOrElse(i) { 0 }
val c = curParts.getOrElse(i) { 0 }
if (n > c) return true
if (n < c) return false
}
false
} catch (_: Exception) {
version > current
}
}
}
@@ -18,7 +18,9 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -48,6 +50,7 @@ import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.BuildConfig
@@ -56,6 +59,7 @@ import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.TorMode import com.greenart7c3.nostrsigner.models.TorMode
import com.greenart7c3.nostrsigner.service.DownloadState
import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog
import com.greenart7c3.nostrsigner.ui.components.AmberButton import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.components.IconRow import com.greenart7c3.nostrsigner.ui.components.IconRow
@@ -270,6 +274,59 @@ fun SettingsScreen(
) )
} }
if (!BuildFlavorChecker.isOfflineFlavor()) {
val updater = Amber.instance.zapstoreUpdater
if (updater != null) {
val latestRelease by updater.latestRelease.collectAsStateWithLifecycle()
val isChecking by updater.isChecking.collectAsStateWithLifecycle()
val dlState by updater.downloadState.collectAsStateWithLifecycle()
val dlProgress by updater.downloadProgress.collectAsStateWithLifecycle()
Box(Modifier.padding(vertical = 8.dp)) {
Column {
IconRow(
title = when {
latestRelease != null -> stringResource(R.string.update_available, latestRelease!!.version)
isChecking -> stringResource(R.string.checking_for_updates)
else -> stringResource(R.string.check_for_updates)
},
icon = Icons.Default.SystemUpdate,
tint = if (latestRelease != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
onClick = {
if (latestRelease != null && dlState == DownloadState.IDLE) {
updater.downloadAndInstall(context, latestRelease!!)
} else if (!isChecking) {
updater.checkForUpdates()
}
},
)
if (dlState == DownloadState.DOWNLOADING) {
LinearProgressIndicator(
progress = { dlProgress },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
if (dlState == DownloadState.INSTALLING) {
Text(
text = stringResource(R.string.installing_update),
modifier = Modifier.padding(start = 54.dp),
color = Color.Gray,
)
}
if (dlState == DownloadState.ERROR) {
Text(
text = stringResource(R.string.update_download_failed),
modifier = Modifier.padding(start = 54.dp),
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
+5
View File
@@ -561,6 +561,11 @@
<string name="connected_app_warning">Some applications may not work if you try to reuse your bunker URI. Delete this application and create a new one in case the login is not working.</string> <string name="connected_app_warning">Some applications may not work if you try to reuse your bunker URI. Delete this application and create a new one in case the login is not working.</string>
<string name="zapstore">Zapstore</string> <string name="zapstore">Zapstore</string>
<string name="zapstore_website">https://zapstore.dev</string> <string name="zapstore_website">https://zapstore.dev</string>
<string name="check_for_updates">Check for updates</string>
<string name="checking_for_updates">Checking for updates…</string>
<string name="update_available">Update available: v%1$s</string>
<string name="installing_update">Installing update…</string>
<string name="update_download_failed">Download failed. Tap to retry.</string>
<string name="discover_more">Discover more Nostr apps for android at</string> <string name="discover_more">Discover more Nostr apps for android at</string>
<string name="backup">Backup</string> <string name="backup">Backup</string>
<string name="qrCode">QR Code</string> <string name="qrCode">QR Code</string>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="apk_downloads"
path="." />
</paths>