package com.peachpasswords.updates import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import androidx.core.content.FileProvider import com.peachpasswords.BuildConfig import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton /** * The Kepr side of in-app updates: what the newest release is, fetching its APK, * and proving that APK is the one Kepr published before it is ever handed to the * package installer. * * Nothing here downloads or installs on its own. `check()` is the only thing the * background worker and app-open path call; everything past that is driven by a * user tap, per the "never do anything silently with the user's device" rule. */ @Singleton class UpdateRepository @Inject constructor( @ApplicationContext private val context: Context, ) { private val client: OkHttpClient by lazy { OkHttpClient.Builder() .callTimeout(5, TimeUnit.MINUTES) .build() } private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } /** The running build's release version, with the debug `-debug` suffix stripped. */ val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME) private val _available = MutableStateFlow(cachedAvailable()) /** The newest release, only when it is actually newer than [currentVersion]. */ val available: StateFlow = _available.asStateFlow() private val _whatsNew = MutableStateFlow(null) /** Notes for the running version, set only while they are still unread. */ val whatsNew: StateFlow = _whatsNew.asStateFlow() private val _openRequested = MutableStateFlow(false) /** Set when the app was launched by tapping the update notification. */ val openRequested: StateFlow = _openRequested.asStateFlow() init { refreshWhatsNew() } fun requestOpen() { _openRequested.value = true } fun consumeOpenRequest() { _openRequested.value = false } /* ──────────────────────────────── check ──────────────────────────────── */ /** * Fetch the release manifest and reconcile it against the running build. * Returns the manifest when a newer version exists, null when up to date. * Throws on network/parse failure — callers decide whether that is worth * surfacing (the periodic worker retries; the Settings "check now" reports). */ suspend fun check(): ReleaseManifest? { val manifest = fetchLatest() val newer = isNewerVersion(manifest.version, currentVersion) _available.value = manifest.takeIf { newer } prefs.edit().apply { if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST) }.apply() // The manifest for the version we're already running is the only place the // post-update changelog can come from, so cache it the moment we see it. if (normalizeVersion(manifest.version) == currentVersion) { if (manifest.notes == null) { // Genuinely no notes for this release — nothing to ever show, so // stop waiting for them rather than leaving the flag armed forever. markChangelogSeen() } else { prefs.edit() .putString(KEY_NOTES_VERSION, currentVersion) .putString(KEY_NOTES, manifest.notes) .apply() refreshWhatsNew() } } return _available.value } private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) { val request = Request.Builder() .url(LATEST_RELEASE_URL) .header("User-Agent", USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException("HTTP ${response.code}") parseReleaseManifest(response.body?.string().orEmpty()) } } /* ────────────────────────── download + verify ────────────────────────── */ /** * Download the release APK into app cache and verify its checksum. A file that * does not match is deleted and the call fails — an unverified APK is never * left on disk for something else to pick up, and never reaches the installer. */ suspend fun downloadAndVerify( manifest: ReleaseManifest, onProgress: (Float) -> Unit = {}, ): File = withContext(Dispatchers.IO) { val expected = parseChecksum(manifest.checksum) ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'") // parseReleaseManifest has already enforced strict SemVer, so the version // is guaranteed to be a clean `major.minor.patch` with no path-traversal // characters; using it in the filename is safe. val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() } val target = File(dir, "peach-${manifest.version}.apk") require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) { "refusing to write outside cache dir" } // A previously verified download of the same version is reusable; anything // else in here is a stale or half-finished attempt. dir.listFiles()?.forEach { if (it != target) it.delete() } if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) { // Even on a cache hit we re-prove identity: a tampered file in the // cache slot must not reach the installer any more than a tampered // download must. verifyApkIdentity(target) onProgress(1f) return@withContext target } target.delete() val request = Request.Builder() .url(manifest.url) .header("User-Agent", USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException("HTTP ${response.code}") val body = response.body ?: throw IOException("empty response") val advertised = body.contentLength() // Reject before streaming if the server is honest about being too // big; Content-Length can be -1 (chunked / unknown) and the // streaming cap below catches that case as bytes arrive. if (advertised in 1..MAX_APK_BYTES) { // known and within bounds — nothing to do } else if (advertised > MAX_APK_BYTES) { throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES") } try { body.byteStream().use { input -> target.outputStream().use { output -> val buffer = ByteArray(64 * 1024) var written = 0L while (true) { val read = input.read(buffer) if (read <= 0) break written += read if (written > MAX_APK_BYTES) { // Streaming cap: a server that lies about its // Content-Length, or chunks past it, is still // bounded here. The partial file is removed so // a later retry doesn't pick up half an APK. throw DownloadSizeException( "APK exceeded $MAX_APK_BYTES bytes mid-download", ) } output.write(buffer, 0, read) if (advertised > 0) onProgress( (written.toFloat() / advertised).coerceIn(0f, 1f), ) } } } } catch (e: DownloadSizeException) { target.delete() throw e } } val actual = hashFile(target, expected.algorithm) if (actual != expected.hex) { target.delete() throw ChecksumMismatchException( "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual", ) } verifyApkIdentity(target) onProgress(1f) target } /** * Confirm the cached APK is the package and version this updater belongs * to. PackageManager is happy to parse any well-formed APK the cache may * contain (or, in the future, a hostile Kepr build with a substituted * manifest); the package-name and version-code checks are what stop an * arbitrary binary from being handed to the installer. */ private fun verifyApkIdentity(apk: File) { val info = context.packageManager.getPackageArchiveInfo( apk.absolutePath, 0, ) ?: throw ApkIdentityException("APK could not be parsed as an Android package") if (!isExpectedApk( packageName = info.packageName, versionCode = info.longVersionCode, expectedPackage = context.packageName, expectedVersionCode = BuildConfig.VERSION_CODE, ) ) { apk.delete() throw ApkIdentityException( "APK identity mismatch: package=${info.packageName} " + "versionCode=${info.longVersionCode}, expected ${context.packageName} " + ">=${BuildConfig.VERSION_CODE}", ) } } fun discardDownloads() { File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() } } /* ──────────────────────────────── install ────────────────────────────── */ /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */ fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls() fun unknownSourcesSettingsIntent(): Intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) /** * Hand a verified APK to the system package installer. The file lives in app * cache, so it can only be read through the FileProvider grant this intent carries. */ fun installIntent(apk: File): Intent { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) return Intent(Intent.ACTION_VIEW) .setDataAndType(uri, APK_MIME) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } /* ────────────────────────────── what's new ───────────────────────────── */ /** * Arm the post-update changelog when the running version differs from the last * one whose notes were read. A fresh install has nothing to catch up on, so it * silently records the current version instead of showing a changelog for a * release the user never ran. */ fun refreshWhatsNew() { val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null) if (lastSeen == null) { markChangelogSeen() return } if (lastSeen == currentVersion) { _whatsNew.value = null return } _whatsNew.value = prefs.getString(KEY_NOTES, null) ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion } } fun markChangelogSeen() { prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply() _whatsNew.value = null } /* ───────────────────────────── notify-once ───────────────────────────── */ /** True the first time a given version is seen, so the same update notifies once. */ fun shouldNotifyFor(version: String): Boolean = prefs.getString(KEY_NOTIFIED_VERSION, null) != version fun markNotified(version: String) { prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply() } private fun cachedAvailable(): ReleaseManifest? = prefs.getString(KEY_LATEST_MANIFEST, null) ?.let { ReleaseManifest.fromCachedJson(it) } ?.takeIf { isNewerVersion(it.version, currentVersion) } companion object { /** Kepr repo name for this app; the generic release API is keyed on it. */ const val KEPR_REPO = "peach-android" const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest" private const val APK_MIME = "application/vnd.android.package-archive" private const val USER_AGENT = "Peach-Android" private const val CACHE_DIR = "updates" private const val PREFS = "peach_updates" private const val KEY_LATEST_MANIFEST = "latest_manifest" private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version" private const val KEY_NOTES_VERSION = "notes_version" private const val KEY_NOTES = "notes" private const val KEY_NOTIFIED_VERSION = "notified_version" } }