package com.peachpasswords.updates import android.content.Context import android.content.Intent 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}'") val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() } val target = File(dir, "peach-${manifest.version}.apk") // 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) { 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 total = body.contentLength() 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 output.write(buffer, 0, read) written += read if (total > 0) onProgress((written.toFloat() / total).coerceIn(0f, 1f)) } } } } val actual = hashFile(target, expected.algorithm) if (actual != expected.hex) { target.delete() throw ChecksumMismatchException( "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual", ) } onProgress(1f) target } 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" } }