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 okhttp3.OkHttpClient24import okhttp3.Request25import java.io.File26import java.io.IOException27import java.security.MessageDigest28import java.util.concurrent.TimeUnit29import javax.inject.Inject30import javax.inject.Singleton3132333435363738394041@Singleton42class UpdateRepository @Inject constructor(43 @ApplicationContext private val context: Context,44) {4546 private val client: OkHttpClient by lazy {47 OkHttpClient.Builder()48 .callTimeout(5, TimeUnit.MINUTES)49 .build()50 }5152 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5354 55 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5657 private val _available = MutableStateFlow(cachedAvailable())58 59 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()6061 private val _whatsNew = MutableStateFlow<String?>(null)62 63 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6465 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)6667 private val _openRequested = MutableStateFlow(false)68 69 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()7071 init {72 refreshWhatsNew()73 }7475 fun requestOpen() { _openRequested.value = true }7677 fun consumeOpenRequest() { _openRequested.value = false }7879 8081 828384858687 suspend fun check(): ReleaseManifest? {88 val manifest = fetchLatest()89 val newer = isNewerVersion(manifest.version, currentVersion)9091 _available.value = manifest.takeIf { newer }92 prefs.edit().apply {93 if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)94 }.apply()9596 97 98 if (normalizeVersion(manifest.version) == currentVersion) {99 if (manifest.notes == null) {100 101 102 markChangelogSeen()103 } else {104 prefs.edit()105 .putString(KEY_NOTES_VERSION, currentVersion)106 .putString(KEY_NOTES, manifest.notes)107 .apply()108 refreshWhatsNew()109 }110 }111 return _available.value112 }113114 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {115 val request = Request.Builder()116 .url(LATEST_RELEASE_URL)117 .header("User-Agent", USER_AGENT)118 .build()119 client.newCall(request).execute().use { response ->120 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")121 val platform = androidPlatformKey(Build.SUPPORTED_ABIS)122 ?: throw ManifestFormatException("this Android ABI is not supported")123 parseReleaseManifest(response.body?.string().orEmpty(), platform)124 }125 }126127 128129 130131132133134 suspend fun downloadAndVerify(135 manifest: ReleaseManifest,136 onProgress: (Float) -> Unit = {},137 ): File = withContext(Dispatchers.IO) {138 val expected = parseChecksum(manifest.checksum)139 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")140141 142 143 144 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }145 val target = File(dir, "peach-${manifest.version}.apk")146 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {147 "refusing to write outside cache dir"148 }149150 151 152 dir.listFiles()?.forEach { if (it != target) it.delete() }153 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {154 155 156 157 verifyApkIdentity(target, manifest.version)158 onProgress(1f)159 return@withContext target160 }161 target.delete()162163 val request = Request.Builder()164 .url(manifest.url)165 .header("User-Agent", USER_AGENT)166 .build()167 client.newCall(request).execute().use { response ->168 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")169 val body = response.body ?: throw IOException("empty response")170 val advertised = body.contentLength()171 172 173 174 if (advertised in 1..MAX_APK_BYTES) {175 176 } else if (advertised > MAX_APK_BYTES) {177 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")178 }179 try {180 body.byteStream().use { input ->181 target.outputStream().use { output ->182 val buffer = ByteArray(64 * 1024)183 var written = 0L184 while (true) {185 val read = input.read(buffer)186 if (read <= 0) break187 written += read188 if (written > MAX_APK_BYTES) {189 190 191 192 193 throw DownloadSizeException(194 "APK exceeded $MAX_APK_BYTES bytes mid-download",195 )196 }197 output.write(buffer, 0, read)198 if (advertised > 0) onProgress(199 (written.toFloat() / advertised).coerceIn(0f, 1f),200 )201 }202 }203 }204 } catch (e: DownloadSizeException) {205 target.delete()206 throw e207 }208 }209210 val actual = hashFile(target, expected.algorithm)211 if (actual != expected.hex) {212 target.delete()213 throw ChecksumMismatchException(214 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",215 )216 }217 verifyApkIdentity(target, manifest.version)218 onProgress(1f)219 target220 }221222 223224225226227228229 private fun verifyApkIdentity(apk: File, expectedVersionName: String) {230 val packageManager = context.packageManager231 val info = packageManager.getPackageArchiveInfo(232 apk.absolutePath,233 signingInfoFlags(),234 ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")235 val archiveVersionCode = archiveVersionCode(info)236 if (!isExpectedApk(237 packageName = info.packageName,238 versionName = info.versionName,239 versionCode = archiveVersionCode,240 expectedPackage = context.packageName,241 expectedVersionName = expectedVersionName,242 currentVersionCode = BuildConfig.VERSION_CODE.toLong(),243 )244 ) {245 rejectIdentity(246 apk,247 "APK identity mismatch: package=${info.packageName} " +248 "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +249 "expected ${context.packageName} versionName=$expectedVersionName " +250 "and versionCode>${BuildConfig.VERSION_CODE}",251 )252 }253254 val installed = runCatching {255 packageManager.getPackageInfo(context.packageName, signingInfoFlags())256 }.getOrElse {257 rejectIdentity(apk, "installed Peach package could not be inspected")258 }259 val archiveSigning = signingCertificates(info)260 val installedSigning = signingCertificates(installed)261 if (!isSigningLineageCompatible(262 archiveCurrent = archiveSigning.current,263 archiveHistory = archiveSigning.history,264 installedCurrent = installedSigning.current,265 )266 ) {267 rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")268 }269 }270271 private data class SigningCertificates(272 val current: Set<String>,273 val history: Set<String>,274 )275276 277278279280281 @Suppress("DEPRECATION", "InlinedApi")282 private fun signingInfoFlags(): Int =283 packageSigningInfoFlagsForSdk(284 sdkInt = Build.VERSION.SDK_INT,285 legacyFlags = PackageManager.GET_SIGNATURES,286 modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,287 )288289 @Suppress("DEPRECATION", "InlinedApi")290 private fun archiveVersionCode(info: PackageInfo): Long =291 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {292 packageVersionCodeForSdk(293 sdkInt = Build.VERSION.SDK_INT,294 legacyVersionCode = info.versionCode,295 modernVersionCode = modernVersionCode(info),296 )297 } else {298 packageVersionCodeForSdk(299 sdkInt = Build.VERSION.SDK_INT,300 legacyVersionCode = info.versionCode,301 modernVersionCode = info.versionCode.toLong(),302 )303 }304305 @RequiresApi(Build.VERSION_CODES.P)306 private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode307308 @Suppress("DEPRECATION", "InlinedApi")309 private fun signingCertificates(info: PackageInfo): SigningCertificates {310 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {311 val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())312 val current = signingInfo.apkContentsSigners.orEmpty()313 .map(::certificateSha256)314 .toSet()315 val history = signingInfo.signingCertificateHistory.orEmpty()316 .map(::certificateSha256)317 .toSet()318 return SigningCertificates(current = current, history = history)319 }320 val current = info.signatures.orEmpty()321 .map(::certificateSha256)322 .toSet()323 return SigningCertificates(current = current, history = emptySet())324 }325326 private fun certificateSha256(signature: Signature): String =327 MessageDigest.getInstance("SHA-256")328 .digest(signature.toByteArray())329 .joinToString("") { byte -> "%02x".format(byte) }330331 private fun rejectIdentity(apk: File, message: String): Nothing {332 apk.delete()333 throw ApkIdentityException(message)334 }335336 fun discardDownloads() {337 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }338 }339340 341342 343 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()344345 fun unknownSourcesSettingsIntent(): Intent =346 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))347 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)348349 350351352353 fun installIntent(apk: File): Intent {354 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)355 return Intent(Intent.ACTION_VIEW)356 .setDataAndType(uri, APK_MIME)357 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)358 }359360 361362 363364365366367368 fun refreshWhatsNew() {369 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)370 if (lastSeen == null) {371 markChangelogSeen()372 return373 }374 if (lastSeen == currentVersion) {375 _whatsNew.value = null376 return377 }378 379 380 _whatsNew.value = prefs.getString(KEY_NOTES, null)381 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }382 scope.launch {383 val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion)384 if (accumulated != null) _whatsNew.value = accumulated385 }386 }387388 fun markChangelogSeen() {389 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()390 _whatsNew.value = null391 }392393 394395 396 fun shouldNotifyFor(version: String): Boolean =397 prefs.getString(KEY_NOTIFIED_VERSION, null) != version398399 fun markNotified(version: String) {400 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()401 }402403 private fun cachedAvailable(): ReleaseManifest? =404 prefs.getString(KEY_LATEST_MANIFEST, null)405 ?.let { runCatching { parseReleaseManifest(it, androidPlatformKey(Build.SUPPORTED_ABIS) ?: ANDROID_PLATFORM_KEY) }.getOrNull() }406 ?.takeIf { isNewerVersion(it.version, currentVersion) }407408 409410411412413 suspend fun fetchHistory(): List<ReleaseNote>? = withContext(Dispatchers.IO) {414 runCatching {415 val request = Request.Builder()416 .url(HISTORY_URL)417 .header("User-Agent", USER_AGENT)418 .build()419 client.newCall(request).execute().use { response ->420 if (!response.isSuccessful) return@runCatching null421 parseReleaseHistory(response.body?.string().orEmpty())422 }423 }.getOrNull()424 }425426 427428429430431 private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? {432 433 val inRange = mutableListOf<ReleaseNote>()434 for (entry in fetchHistory() ?: return null) {435 if (entry.version == lastSeen) break436 if (!entry.notes.isNullOrBlank()) inRange.add(entry)437 if (entry.version == current) break438 }439 if (inRange.isEmpty()) return null440 441 inRange.reverse()442 return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" }443 }444445 companion object {446 447 const val KEPR_REPO = "peach-android"448 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"449 const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history"450451 private const val APK_MIME = "application/vnd.android.package-archive"452 private const val USER_AGENT = "Peach-Android"453 private const val CACHE_DIR = "updates"454455 private const val PREFS = "peach_updates"456 private const val KEY_LATEST_MANIFEST = "latest_manifest"457 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"458 private const val KEY_NOTES_VERSION = "notes_version"459 private const val KEY_NOTES = "notes"460 private const val KEY_NOTIFIED_VERSION = "notified_version"461 }462}