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.withContext20import okhttp3.OkHttpClient21import okhttp3.Request22import java.io.File23import java.io.IOException24import java.security.MessageDigest25import java.util.concurrent.TimeUnit26import javax.inject.Inject27import javax.inject.Singleton2829303132333435363738@Singleton39class UpdateRepository @Inject constructor(40 @ApplicationContext private val context: Context,41) {4243 private val client: OkHttpClient by lazy {44 OkHttpClient.Builder()45 .callTimeout(5, TimeUnit.MINUTES)46 .build()47 }4849 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5051 52 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5354 private val _available = MutableStateFlow(cachedAvailable())55 56 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5758 private val _whatsNew = MutableStateFlow<String?>(null)59 60 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6162 private val _openRequested = MutableStateFlow(false)63 64 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()6566 init {67 refreshWhatsNew()68 }6970 fun requestOpen() { _openRequested.value = true }7172 fun consumeOpenRequest() { _openRequested.value = false }7374 7576 777879808182 suspend fun check(): ReleaseManifest? {83 val manifest = fetchLatest()84 val newer = isNewerVersion(manifest.version, currentVersion)8586 _available.value = manifest.takeIf { newer }87 prefs.edit().apply {88 if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)89 }.apply()9091 92 93 if (normalizeVersion(manifest.version) == currentVersion) {94 if (manifest.notes == null) {95 96 97 markChangelogSeen()98 } else {99 prefs.edit()100 .putString(KEY_NOTES_VERSION, currentVersion)101 .putString(KEY_NOTES, manifest.notes)102 .apply()103 refreshWhatsNew()104 }105 }106 return _available.value107 }108109 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {110 val request = Request.Builder()111 .url(LATEST_RELEASE_URL)112 .header("User-Agent", USER_AGENT)113 .build()114 client.newCall(request).execute().use { response ->115 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")116 parseReleaseManifest(response.body?.string().orEmpty())117 }118 }119120 121122 123124125126127 suspend fun downloadAndVerify(128 manifest: ReleaseManifest,129 onProgress: (Float) -> Unit = {},130 ): File = withContext(Dispatchers.IO) {131 val expected = parseChecksum(manifest.checksum)132 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")133134 135 136 137 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }138 val target = File(dir, "peach-${manifest.version}.apk")139 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {140 "refusing to write outside cache dir"141 }142143 144 145 dir.listFiles()?.forEach { if (it != target) it.delete() }146 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {147 148 149 150 verifyApkIdentity(target, manifest.version)151 onProgress(1f)152 return@withContext target153 }154 target.delete()155156 val request = Request.Builder()157 .url(manifest.url)158 .header("User-Agent", USER_AGENT)159 .build()160 client.newCall(request).execute().use { response ->161 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")162 val body = response.body ?: throw IOException("empty response")163 val advertised = body.contentLength()164 165 166 167 if (advertised in 1..MAX_APK_BYTES) {168 169 } else if (advertised > MAX_APK_BYTES) {170 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")171 }172 try {173 body.byteStream().use { input ->174 target.outputStream().use { output ->175 val buffer = ByteArray(64 * 1024)176 var written = 0L177 while (true) {178 val read = input.read(buffer)179 if (read <= 0) break180 written += read181 if (written > MAX_APK_BYTES) {182 183 184 185 186 throw DownloadSizeException(187 "APK exceeded $MAX_APK_BYTES bytes mid-download",188 )189 }190 output.write(buffer, 0, read)191 if (advertised > 0) onProgress(192 (written.toFloat() / advertised).coerceIn(0f, 1f),193 )194 }195 }196 }197 } catch (e: DownloadSizeException) {198 target.delete()199 throw e200 }201 }202203 val actual = hashFile(target, expected.algorithm)204 if (actual != expected.hex) {205 target.delete()206 throw ChecksumMismatchException(207 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",208 )209 }210 verifyApkIdentity(target, manifest.version)211 onProgress(1f)212 target213 }214215 216217218219220221222 private fun verifyApkIdentity(apk: File, expectedVersionName: String) {223 val packageManager = context.packageManager224 val info = packageManager.getPackageArchiveInfo(225 apk.absolutePath,226 signingInfoFlags(),227 ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")228 val archiveVersionCode = archiveVersionCode(info)229 if (!isExpectedApk(230 packageName = info.packageName,231 versionName = info.versionName,232 versionCode = archiveVersionCode,233 expectedPackage = context.packageName,234 expectedVersionName = expectedVersionName,235 currentVersionCode = BuildConfig.VERSION_CODE.toLong(),236 )237 ) {238 rejectIdentity(239 apk,240 "APK identity mismatch: package=${info.packageName} " +241 "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +242 "expected ${context.packageName} versionName=$expectedVersionName " +243 "and versionCode>${BuildConfig.VERSION_CODE}",244 )245 }246247 val installed = runCatching {248 packageManager.getPackageInfo(context.packageName, signingInfoFlags())249 }.getOrElse {250 rejectIdentity(apk, "installed Peach package could not be inspected")251 }252 val archiveSigning = signingCertificates(info)253 val installedSigning = signingCertificates(installed)254 if (!isSigningLineageCompatible(255 archiveCurrent = archiveSigning.current,256 archiveHistory = archiveSigning.history,257 installedCurrent = installedSigning.current,258 )259 ) {260 rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")261 }262 }263264 private data class SigningCertificates(265 val current: Set<String>,266 val history: Set<String>,267 )268269 270271272273274 @Suppress("DEPRECATION", "InlinedApi")275 private fun signingInfoFlags(): Int =276 packageSigningInfoFlagsForSdk(277 sdkInt = Build.VERSION.SDK_INT,278 legacyFlags = PackageManager.GET_SIGNATURES,279 modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,280 )281282 @Suppress("DEPRECATION", "InlinedApi")283 private fun archiveVersionCode(info: PackageInfo): Long =284 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {285 packageVersionCodeForSdk(286 sdkInt = Build.VERSION.SDK_INT,287 legacyVersionCode = info.versionCode,288 modernVersionCode = modernVersionCode(info),289 )290 } else {291 packageVersionCodeForSdk(292 sdkInt = Build.VERSION.SDK_INT,293 legacyVersionCode = info.versionCode,294 modernVersionCode = info.versionCode.toLong(),295 )296 }297298 @RequiresApi(Build.VERSION_CODES.P)299 private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode300301 @Suppress("DEPRECATION", "InlinedApi")302 private fun signingCertificates(info: PackageInfo): SigningCertificates {303 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {304 val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())305 val current = signingInfo.apkContentsSigners.orEmpty()306 .map(::certificateSha256)307 .toSet()308 val history = signingInfo.signingCertificateHistory.orEmpty()309 .map(::certificateSha256)310 .toSet()311 return SigningCertificates(current = current, history = history)312 }313 val current = info.signatures.orEmpty()314 .map(::certificateSha256)315 .toSet()316 return SigningCertificates(current = current, history = emptySet())317 }318319 private fun certificateSha256(signature: Signature): String =320 MessageDigest.getInstance("SHA-256")321 .digest(signature.toByteArray())322 .joinToString("") { byte -> "%02x".format(byte) }323324 private fun rejectIdentity(apk: File, message: String): Nothing {325 apk.delete()326 throw ApkIdentityException(message)327 }328329 fun discardDownloads() {330 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }331 }332333 334335 336 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()337338 fun unknownSourcesSettingsIntent(): Intent =339 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))340 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)341342 343344345346 fun installIntent(apk: File): Intent {347 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)348 return Intent(Intent.ACTION_VIEW)349 .setDataAndType(uri, APK_MIME)350 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)351 }352353 354355 356357358359360361 fun refreshWhatsNew() {362 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)363 if (lastSeen == null) {364 markChangelogSeen()365 return366 }367 if (lastSeen == currentVersion) {368 _whatsNew.value = null369 return370 }371 _whatsNew.value = prefs.getString(KEY_NOTES, null)372 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }373 }374375 fun markChangelogSeen() {376 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()377 _whatsNew.value = null378 }379380 381382 383 fun shouldNotifyFor(version: String): Boolean =384 prefs.getString(KEY_NOTIFIED_VERSION, null) != version385386 fun markNotified(version: String) {387 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()388 }389390 private fun cachedAvailable(): ReleaseManifest? =391 prefs.getString(KEY_LATEST_MANIFEST, null)392 ?.let { ReleaseManifest.fromCachedJson(it) }393 ?.takeIf { isNewerVersion(it.version, currentVersion) }394395 companion object {396 397 const val KEPR_REPO = "peach-android"398 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"399400 private const val APK_MIME = "application/vnd.android.package-archive"401 private const val USER_AGENT = "Peach-Android"402 private const val CACHE_DIR = "updates"403404 private const val PREFS = "peach_updates"405 private const val KEY_LATEST_MANIFEST = "latest_manifest"406 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"407 private const val KEY_NOTES_VERSION = "notes_version"408 private const val KEY_NOTES = "notes"409 private const val KEY_NOTIFIED_VERSION = "notified_version"410 }411}