1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.content.pm.PackageInfo6import android.content.pm.PackageManager7import android.content.pm.Signature8import android.net.Uri9import android.os.Build10import android.provider.Settings11import androidx.core.content.FileProvider12import androidx.annotation.RequiresApi13import com.peachpasswords.BuildConfig14import dagger.hilt.android.qualifiers.ApplicationContext15import kotlinx.coroutines.Dispatchers16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.flow.asStateFlow19import kotlinx.coroutines.CoroutineScope20import kotlinx.coroutines.SupervisorJob21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import org.json.JSONArray24import okhttp3.OkHttpClient25import okhttp3.Request26import java.io.File27import java.io.IOException28import java.security.MessageDigest29import java.util.concurrent.TimeUnit30import javax.inject.Inject31import javax.inject.Singleton3233343536373839404142@Singleton43class UpdateRepository @Inject constructor(44 @ApplicationContext private val context: Context,45) {4647 private val client: OkHttpClient by lazy {48 OkHttpClient.Builder()49 .callTimeout(5, TimeUnit.MINUTES)50 .build()51 }5253 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5455 56 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5758 private val _available = MutableStateFlow(cachedAvailable())59 60 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()6162 private val _whatsNew = MutableStateFlow<String?>(null)63 64 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6566 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)6768 private val _openRequested = MutableStateFlow(false)69 70 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()7172 init {73 refreshWhatsNew()74 }7576 fun requestOpen() { _openRequested.value = true }7778 fun consumeOpenRequest() { _openRequested.value = false }7980 8182 838485868788 suspend fun check(): ReleaseManifest? {89 val manifest = fetchLatest()90 val newer = isNewerVersion(manifest.version, currentVersion)9192 _available.value = manifest.takeIf { newer }93 prefs.edit().apply {94 if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)95 }.apply()9697 98 99 if (normalizeVersion(manifest.version) == currentVersion) {100 if (manifest.notes == null) {101 102 103 markChangelogSeen()104 } else {105 prefs.edit()106 .putString(KEY_NOTES_VERSION, currentVersion)107 .putString(KEY_NOTES, manifest.notes)108 .apply()109 refreshWhatsNew()110 }111 }112 return _available.value113 }114115 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {116 val request = Request.Builder()117 .url(LATEST_RELEASE_URL)118 .header("User-Agent", USER_AGENT)119 .build()120 client.newCall(request).execute().use { response ->121 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")122 val platform = androidPlatformKey(Build.SUPPORTED_ABIS)123 ?: throw ManifestFormatException("this Android ABI is not supported")124 parseReleaseManifest(response.body?.string().orEmpty(), platform)125 }126 }127128 129130 131132133134135 suspend fun downloadAndVerify(136 manifest: ReleaseManifest,137 onProgress: (Float) -> Unit = {},138 ): File = withContext(Dispatchers.IO) {139 val expected = parseChecksum(manifest.checksum)140 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")141142 143 144 145 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }146 val target = File(dir, "peach-${manifest.version}.apk")147 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {148 "refusing to write outside cache dir"149 }150151 152 153 dir.listFiles()?.forEach { if (it != target) it.delete() }154 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {155 156 157 158 verifyApkIdentity(target, manifest.version)159 onProgress(1f)160 return@withContext target161 }162 target.delete()163164 val request = Request.Builder()165 .url(manifest.url)166 .header("User-Agent", USER_AGENT)167 .build()168 client.newCall(request).execute().use { response ->169 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")170 val body = response.body ?: throw IOException("empty response")171 val advertised = body.contentLength()172 173 174 175 if (advertised in 1..MAX_APK_BYTES) {176 177 } else if (advertised > MAX_APK_BYTES) {178 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")179 }180 try {181 body.byteStream().use { input ->182 target.outputStream().use { output ->183 val buffer = ByteArray(64 * 1024)184 var written = 0L185 while (true) {186 val read = input.read(buffer)187 if (read <= 0) break188 written += read189 if (written > MAX_APK_BYTES) {190 191 192 193 194 throw DownloadSizeException(195 "APK exceeded $MAX_APK_BYTES bytes mid-download",196 )197 }198 output.write(buffer, 0, read)199 if (advertised > 0) onProgress(200 (written.toFloat() / advertised).coerceIn(0f, 1f),201 )202 }203 }204 }205 } catch (e: DownloadSizeException) {206 target.delete()207 throw e208 }209 }210211 val actual = hashFile(target, expected.algorithm)212 if (actual != expected.hex) {213 target.delete()214 throw ChecksumMismatchException(215 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",216 )217 }218 verifyApkIdentity(target, manifest.version)219 onProgress(1f)220 target221 }222223 224225226227228229230 private fun verifyApkIdentity(apk: File, expectedVersionName: String) {231 val packageManager = context.packageManager232 val info = packageManager.getPackageArchiveInfo(233 apk.absolutePath,234 signingInfoFlags(),235 ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")236 val archiveVersionCode = archiveVersionCode(info)237 if (!isExpectedApk(238 packageName = info.packageName,239 versionName = info.versionName,240 versionCode = archiveVersionCode,241 expectedPackage = context.packageName,242 expectedVersionName = expectedVersionName,243 currentVersionCode = BuildConfig.VERSION_CODE.toLong(),244 )245 ) {246 rejectIdentity(247 apk,248 "APK identity mismatch: package=${info.packageName} " +249 "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +250 "expected ${context.packageName} versionName=$expectedVersionName " +251 "and versionCode>${BuildConfig.VERSION_CODE}",252 )253 }254255 val installed = runCatching {256 packageManager.getPackageInfo(context.packageName, signingInfoFlags())257 }.getOrElse {258 rejectIdentity(apk, "installed Peach package could not be inspected")259 }260 val archiveSigning = signingCertificates(info)261 val installedSigning = signingCertificates(installed)262 if (!isSigningLineageCompatible(263 archiveCurrent = archiveSigning.current,264 archiveHistory = archiveSigning.history,265 installedCurrent = installedSigning.current,266 )267 ) {268 rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")269 }270 }271272 private data class SigningCertificates(273 val current: Set<String>,274 val history: Set<String>,275 )276277 278279280281282 @Suppress("DEPRECATION", "InlinedApi")283 private fun signingInfoFlags(): Int =284 packageSigningInfoFlagsForSdk(285 sdkInt = Build.VERSION.SDK_INT,286 legacyFlags = PackageManager.GET_SIGNATURES,287 modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,288 )289290 @Suppress("DEPRECATION", "InlinedApi")291 private fun archiveVersionCode(info: PackageInfo): Long =292 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {293 packageVersionCodeForSdk(294 sdkInt = Build.VERSION.SDK_INT,295 legacyVersionCode = info.versionCode,296 modernVersionCode = modernVersionCode(info),297 )298 } else {299 packageVersionCodeForSdk(300 sdkInt = Build.VERSION.SDK_INT,301 legacyVersionCode = info.versionCode,302 modernVersionCode = info.versionCode.toLong(),303 )304 }305306 @RequiresApi(Build.VERSION_CODES.P)307 private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode308309 @Suppress("DEPRECATION", "InlinedApi")310 private fun signingCertificates(info: PackageInfo): SigningCertificates {311 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {312 val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())313 val current = signingInfo.apkContentsSigners.orEmpty()314 .map(::certificateSha256)315 .toSet()316 val history = signingInfo.signingCertificateHistory.orEmpty()317 .map(::certificateSha256)318 .toSet()319 return SigningCertificates(current = current, history = history)320 }321 val current = info.signatures.orEmpty()322 .map(::certificateSha256)323 .toSet()324 return SigningCertificates(current = current, history = emptySet())325 }326327 private fun certificateSha256(signature: Signature): String =328 MessageDigest.getInstance("SHA-256")329 .digest(signature.toByteArray())330 .joinToString("") { byte -> "%02x".format(byte) }331332 private fun rejectIdentity(apk: File, message: String): Nothing {333 apk.delete()334 throw ApkIdentityException(message)335 }336337 fun discardDownloads() {338 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }339 }340341 342343 344 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()345346 fun unknownSourcesSettingsIntent(): Intent =347 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))348 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)349350 351352353354 fun installIntent(apk: File): Intent {355 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)356 return Intent(Intent.ACTION_VIEW)357 .setDataAndType(uri, APK_MIME)358 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)359 }360361 362363 364365366367368369 fun refreshWhatsNew() {370 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)371 if (lastSeen == null) {372 markChangelogSeen()373 return374 }375 if (lastSeen == currentVersion) {376 _whatsNew.value = null377 return378 }379 380 381 _whatsNew.value = prefs.getString(KEY_NOTES, null)382 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }383 scope.launch {384 val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion)385 if (accumulated != null) _whatsNew.value = accumulated386 }387 }388389 fun markChangelogSeen() {390 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()391 _whatsNew.value = null392 }393394 395396 397 fun shouldNotifyFor(version: String): Boolean =398 prefs.getString(KEY_NOTIFIED_VERSION, null) != version399400 fun markNotified(version: String) {401 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()402 }403404 private fun cachedAvailable(): ReleaseManifest? =405 prefs.getString(KEY_LATEST_MANIFEST, null)406 ?.let { ReleaseManifest.fromCachedJson(it) }407 ?.takeIf { isNewerVersion(it.version, currentVersion) }408409 410411412413414 private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? =415 withContext(Dispatchers.IO) {416 runCatching {417 val request = Request.Builder()418 .url(HISTORY_URL)419 .header("User-Agent", USER_AGENT)420 .build()421 client.newCall(request).execute().use { response ->422 if (!response.isSuccessful) return@runCatching null423 val arr = JSONArray(response.body?.string().orEmpty())424 425 val inRange = mutableListOf<Pair<String, String>>()426 for (i in 0 until arr.length()) {427 val obj = arr.getJSONObject(i)428 val ver = obj.getString("version")429 val notes = obj.optString("notes", "")430 if (ver == lastSeen) break431 if (notes.isNotBlank()) inRange.add(ver to notes)432 if (ver == current) break433 }434 if (inRange.isEmpty()) return@runCatching null435 436 inRange.reverse()437 inRange.joinToString("\n\n") { (ver, notes) ->438 "## v$ver\n\n$notes"439 }440 }441 }.getOrNull()442 }443444 companion object {445 446 const val KEPR_REPO = "peach-android"447 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"448 const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history"449450 private const val APK_MIME = "application/vnd.android.package-archive"451 private const val USER_AGENT = "Peach-Android"452 private const val CACHE_DIR = "updates"453454 private const val PREFS = "peach_updates"455 private const val KEY_LATEST_MANIFEST = "latest_manifest"456 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"457 private const val KEY_NOTES_VERSION = "notes_version"458 private const val KEY_NOTES = "notes"459 private const val KEY_NOTIFIED_VERSION = "notified_version"460 }461}