diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 08a755fd..7600e0f5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt
index 6758c638..25049826 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt
@@ -45,6 +45,7 @@ import com.greenart7c3.nostrsigner.service.ConnectivityService
import com.greenart7c3.nostrsigner.service.NotificationSubscription
import com.greenart7c3.nostrsigner.service.ProfileSubscription
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.UnexpectedCrashSaver
import com.greenart7c3.nostrsigner.ui.ToastManager
@@ -131,6 +132,12 @@ class Amber :
// This runs on the foreground only
val profileSubscription = ProfileSubscription(client, this, applicationIOScope)
+ val zapstoreUpdater: ZapstoreUpdater? = if (!BuildFlavorChecker.isOfflineFlavor()) {
+ ZapstoreUpdater(client, applicationIOScope)
+ } else {
+ null
+ }
+
private var databases = ConcurrentHashMap()
private var logDatabases = ConcurrentHashMap()
private var historyDatabases = ConcurrentHashMap()
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreRelease.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreRelease.kt
new file mode 100644
index 00000000..74499584
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreRelease.kt
@@ -0,0 +1,8 @@
+package com.greenart7c3.nostrsigner.service
+
+data class ZapstoreRelease(
+ val version: String,
+ val url: String,
+ val hash: String?,
+ val createdAt: Long,
+)
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt
new file mode 100644
index 00000000..446a5a44
--- /dev/null
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ZapstoreUpdater.kt
@@ -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(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()
+ private val versionByEventId = mutableMapOf>()
+ 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
+ }
+ }
+}
diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
index c88d37b2..96509eaf 100644
--- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
+++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt
@@ -18,7 +18,9 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildConfig
@@ -56,6 +59,7 @@ import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.TorMode
+import com.greenart7c3.nostrsigner.service.DownloadState
import com.greenart7c3.nostrsigner.ui.actions.LogoutDialog
import com.greenart7c3.nostrsigner.ui.components.AmberButton
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))
val primaryColor = MaterialTheme.colorScheme.primary
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d1615cca..425f302b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -561,6 +561,11 @@
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.
Zapstore
https://zapstore.dev
+ Check for updates
+ Checking for updates…
+ Update available: v%1$s
+ Installing update…
+ Download failed. Tap to retry.
Discover more Nostr apps for android at
Backup
QR Code
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
new file mode 100644
index 00000000..c8b098e8
--- /dev/null
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+