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 val platform = androidPlatformKey(Build.SUPPORTED_ABIS)117 ?: throw ManifestFormatException("this Android ABI is not supported")118 parseReleaseManifest(response.body?.string().orEmpty(), platform)119 }120 }121122 123124 125126127128129 suspend fun downloadAndVerify(130 manifest: ReleaseManifest,131 onProgress: (Float) -> Unit = {},132 ): File = withContext(Dispatchers.IO) {133 val expected = parseChecksum(manifest.checksum)134 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")135136 137 138 139 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }140 val target = File(dir, "peach-${manifest.version}.apk")141 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {142 "refusing to write outside cache dir"143 }144145 146 147 dir.listFiles()?.forEach { if (it != target) it.delete() }148 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {149 150 151 152 verifyApkIdentity(target, manifest.version)153 onProgress(1f)154 return@withContext target155 }156 target.delete()157158 val request = Request.Builder()159 .url(manifest.url)160 .header("User-Agent", USER_AGENT)161 .build()162 client.newCall(request).execute().use { response ->163 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")164 val body = response.body ?: throw IOException("empty response")165 val advertised = body.contentLength()166 167 168 169 if (advertised in 1..MAX_APK_BYTES) {170 171 } else if (advertised > MAX_APK_BYTES) {172 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")173 }174 try {175 body.byteStream().use { input ->176 target.outputStream().use { output ->177 val buffer = ByteArray(64 * 1024)178 var written = 0L179 while (true) {180 val read = input.read(buffer)181 if (read <= 0) break182 written += read183 if (written > MAX_APK_BYTES) {184 185 186 187 188 throw DownloadSizeException(189 "APK exceeded $MAX_APK_BYTES bytes mid-download",190 )191 }192 output.write(buffer, 0, read)193 if (advertised > 0) onProgress(194 (written.toFloat() / advertised).coerceIn(0f, 1f),195 )196 }197 }198 }199 } catch (e: DownloadSizeException) {200 target.delete()201 throw e202 }203 }204205 val actual = hashFile(target, expected.algorithm)206 if (actual != expected.hex) {207 target.delete()208 throw ChecksumMismatchException(209 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",210 )211 }212 verifyApkIdentity(target, manifest.version)213 onProgress(1f)214 target215 }216217 218219220221222223224 private fun verifyApkIdentity(apk: File, expectedVersionName: String) {225 val packageManager = context.packageManager226 val info = packageManager.getPackageArchiveInfo(227 apk.absolutePath,228 signingInfoFlags(),229 ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")230 val archiveVersionCode = archiveVersionCode(info)231 if (!isExpectedApk(232 packageName = info.packageName,233 versionName = info.versionName,234 versionCode = archiveVersionCode,235 expectedPackage = context.packageName,236 expectedVersionName = expectedVersionName,237 currentVersionCode = BuildConfig.VERSION_CODE.toLong(),238 )239 ) {240 rejectIdentity(241 apk,242 "APK identity mismatch: package=${info.packageName} " +243 "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +244 "expected ${context.packageName} versionName=$expectedVersionName " +245 "and versionCode>${BuildConfig.VERSION_CODE}",246 )247 }248249 val installed = runCatching {250 packageManager.getPackageInfo(context.packageName, signingInfoFlags())251 }.getOrElse {252 rejectIdentity(apk, "installed Peach package could not be inspected")253 }254 val archiveSigning = signingCertificates(info)255 val installedSigning = signingCertificates(installed)256 if (!isSigningLineageCompatible(257 archiveCurrent = archiveSigning.current,258 archiveHistory = archiveSigning.history,259 installedCurrent = installedSigning.current,260 )261 ) {262 rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")263 }264 }265266 private data class SigningCertificates(267 val current: Set<String>,268 val history: Set<String>,269 )270271 272273274275276 @Suppress("DEPRECATION", "InlinedApi")277 private fun signingInfoFlags(): Int =278 packageSigningInfoFlagsForSdk(279 sdkInt = Build.VERSION.SDK_INT,280 legacyFlags = PackageManager.GET_SIGNATURES,281 modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,282 )283284 @Suppress("DEPRECATION", "InlinedApi")285 private fun archiveVersionCode(info: PackageInfo): Long =286 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {287 packageVersionCodeForSdk(288 sdkInt = Build.VERSION.SDK_INT,289 legacyVersionCode = info.versionCode,290 modernVersionCode = modernVersionCode(info),291 )292 } else {293 packageVersionCodeForSdk(294 sdkInt = Build.VERSION.SDK_INT,295 legacyVersionCode = info.versionCode,296 modernVersionCode = info.versionCode.toLong(),297 )298 }299300 @RequiresApi(Build.VERSION_CODES.P)301 private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode302303 @Suppress("DEPRECATION", "InlinedApi")304 private fun signingCertificates(info: PackageInfo): SigningCertificates {305 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {306 val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())307 val current = signingInfo.apkContentsSigners.orEmpty()308 .map(::certificateSha256)309 .toSet()310 val history = signingInfo.signingCertificateHistory.orEmpty()311 .map(::certificateSha256)312 .toSet()313 return SigningCertificates(current = current, history = history)314 }315 val current = info.signatures.orEmpty()316 .map(::certificateSha256)317 .toSet()318 return SigningCertificates(current = current, history = emptySet())319 }320321 private fun certificateSha256(signature: Signature): String =322 MessageDigest.getInstance("SHA-256")323 .digest(signature.toByteArray())324 .joinToString("") { byte -> "%02x".format(byte) }325326 private fun rejectIdentity(apk: File, message: String): Nothing {327 apk.delete()328 throw ApkIdentityException(message)329 }330331 fun discardDownloads() {332 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }333 }334335 336337 338 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()339340 fun unknownSourcesSettingsIntent(): Intent =341 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))342 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)343344 345346347348 fun installIntent(apk: File): Intent {349 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)350 return Intent(Intent.ACTION_VIEW)351 .setDataAndType(uri, APK_MIME)352 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)353 }354355 356357 358359360361362363 fun refreshWhatsNew() {364 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)365 if (lastSeen == null) {366 markChangelogSeen()367 return368 }369 if (lastSeen == currentVersion) {370 _whatsNew.value = null371 return372 }373 _whatsNew.value = prefs.getString(KEY_NOTES, null)374 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }375 }376377 fun markChangelogSeen() {378 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()379 _whatsNew.value = null380 }381382 383384 385 fun shouldNotifyFor(version: String): Boolean =386 prefs.getString(KEY_NOTIFIED_VERSION, null) != version387388 fun markNotified(version: String) {389 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()390 }391392 private fun cachedAvailable(): ReleaseManifest? =393 prefs.getString(KEY_LATEST_MANIFEST, null)394 ?.let { ReleaseManifest.fromCachedJson(it) }395 ?.takeIf { isNewerVersion(it.version, currentVersion) }396397 companion object {398 399 const val KEPR_REPO = "peach-android"400 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"401402 private const val APK_MIME = "application/vnd.android.package-archive"403 private const val USER_AGENT = "Peach-Android"404 private const val CACHE_DIR = "updates"405406 private const val PREFS = "peach_updates"407 private const val KEY_LATEST_MANIFEST = "latest_manifest"408 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"409 private const val KEY_NOTES_VERSION = "notes_version"410 private const val KEY_NOTES = "notes"411 private const val KEY_NOTIFIED_VERSION = "notified_version"412 }413}