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 @@ + + + +