1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.content.pm.PackageManager6import android.net.Uri7import android.provider.Settings8import androidx.core.content.FileProvider9import com.peachpasswords.BuildConfig10import dagger.hilt.android.qualifiers.ApplicationContext11import kotlinx.coroutines.Dispatchers12import kotlinx.coroutines.flow.MutableStateFlow13import kotlinx.coroutines.flow.StateFlow14import kotlinx.coroutines.flow.asStateFlow15import kotlinx.coroutines.withContext16import okhttp3.OkHttpClient17import okhttp3.Request18import java.io.File19import java.io.IOException20import java.util.concurrent.TimeUnit21import javax.inject.Inject22import javax.inject.Singleton2324252627282930313233@Singleton34class UpdateRepository @Inject constructor(35 @ApplicationContext private val context: Context,36) {3738 private val client: OkHttpClient by lazy {39 OkHttpClient.Builder()40 .callTimeout(5, TimeUnit.MINUTES)41 .build()42 }4344 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }4546 47 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)4849 private val _available = MutableStateFlow(cachedAvailable())50 51 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5253 private val _whatsNew = MutableStateFlow<String?>(null)54 55 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()5657 private val _openRequested = MutableStateFlow(false)58 59 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()6061 init {62 refreshWhatsNew()63 }6465 fun requestOpen() { _openRequested.value = true }6667 fun consumeOpenRequest() { _openRequested.value = false }6869 7071 727374757677 suspend fun check(): ReleaseManifest? {78 val manifest = fetchLatest()79 val newer = isNewerVersion(manifest.version, currentVersion)8081 _available.value = manifest.takeIf { newer }82 prefs.edit().apply {83 if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)84 }.apply()8586 87 88 if (normalizeVersion(manifest.version) == currentVersion) {89 if (manifest.notes == null) {90 91 92 markChangelogSeen()93 } else {94 prefs.edit()95 .putString(KEY_NOTES_VERSION, currentVersion)96 .putString(KEY_NOTES, manifest.notes)97 .apply()98 refreshWhatsNew()99 }100 }101 return _available.value102 }103104 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {105 val request = Request.Builder()106 .url(LATEST_RELEASE_URL)107 .header("User-Agent", USER_AGENT)108 .build()109 client.newCall(request).execute().use { response ->110 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")111 parseReleaseManifest(response.body?.string().orEmpty())112 }113 }114115 116117 118119120121122 suspend fun downloadAndVerify(123 manifest: ReleaseManifest,124 onProgress: (Float) -> Unit = {},125 ): File = withContext(Dispatchers.IO) {126 val expected = parseChecksum(manifest.checksum)127 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")128129 130 131 132 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }133 val target = File(dir, "peach-${manifest.version}.apk")134 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {135 "refusing to write outside cache dir"136 }137138 139 140 dir.listFiles()?.forEach { if (it != target) it.delete() }141 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {142 143 144 145 verifyApkIdentity(target)146 onProgress(1f)147 return@withContext target148 }149 target.delete()150151 val request = Request.Builder()152 .url(manifest.url)153 .header("User-Agent", USER_AGENT)154 .build()155 client.newCall(request).execute().use { response ->156 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")157 val body = response.body ?: throw IOException("empty response")158 val advertised = body.contentLength()159 160 161 162 if (advertised in 1..MAX_APK_BYTES) {163 164 } else if (advertised > MAX_APK_BYTES) {165 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")166 }167 try {168 body.byteStream().use { input ->169 target.outputStream().use { output ->170 val buffer = ByteArray(64 * 1024)171 var written = 0L172 while (true) {173 val read = input.read(buffer)174 if (read <= 0) break175 written += read176 if (written > MAX_APK_BYTES) {177 178 179 180 181 throw DownloadSizeException(182 "APK exceeded $MAX_APK_BYTES bytes mid-download",183 )184 }185 output.write(buffer, 0, read)186 if (advertised > 0) onProgress(187 (written.toFloat() / advertised).coerceIn(0f, 1f),188 )189 }190 }191 }192 } catch (e: DownloadSizeException) {193 target.delete()194 throw e195 }196 }197198 val actual = hashFile(target, expected.algorithm)199 if (actual != expected.hex) {200 target.delete()201 throw ChecksumMismatchException(202 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",203 )204 }205 verifyApkIdentity(target)206 onProgress(1f)207 target208 }209210 211212213214215216217 private fun verifyApkIdentity(apk: File) {218 val info = context.packageManager.getPackageArchiveInfo(219 apk.absolutePath,220 0,221 ) ?: throw ApkIdentityException("APK could not be parsed as an Android package")222 if (!isExpectedApk(223 packageName = info.packageName,224 versionCode = info.longVersionCode,225 expectedPackage = context.packageName,226 expectedVersionCode = BuildConfig.VERSION_CODE,227 )228 ) {229 apk.delete()230 throw ApkIdentityException(231 "APK identity mismatch: package=${info.packageName} " +232 "versionCode=${info.longVersionCode}, expected ${context.packageName} " +233 ">=${BuildConfig.VERSION_CODE}",234 )235 }236 }237238 fun discardDownloads() {239 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }240 }241242 243244 245 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()246247 fun unknownSourcesSettingsIntent(): Intent =248 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))249 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)250251 252253254255 fun installIntent(apk: File): Intent {256 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)257 return Intent(Intent.ACTION_VIEW)258 .setDataAndType(uri, APK_MIME)259 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)260 }261262 263264 265266267268269270 fun refreshWhatsNew() {271 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)272 if (lastSeen == null) {273 markChangelogSeen()274 return275 }276 if (lastSeen == currentVersion) {277 _whatsNew.value = null278 return279 }280 _whatsNew.value = prefs.getString(KEY_NOTES, null)281 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }282 }283284 fun markChangelogSeen() {285 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()286 _whatsNew.value = null287 }288289 290291 292 fun shouldNotifyFor(version: String): Boolean =293 prefs.getString(KEY_NOTIFIED_VERSION, null) != version294295 fun markNotified(version: String) {296 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()297 }298299 private fun cachedAvailable(): ReleaseManifest? =300 prefs.getString(KEY_LATEST_MANIFEST, null)301 ?.let { ReleaseManifest.fromCachedJson(it) }302 ?.takeIf { isNewerVersion(it.version, currentVersion) }303304 companion object {305 306 const val KEPR_REPO = "peach-android"307 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"308309 private const val APK_MIME = "application/vnd.android.package-archive"310 private const val USER_AGENT = "Peach-Android"311 private const val CACHE_DIR = "updates"312313 private const val PREFS = "peach_updates"314 private const val KEY_LATEST_MANIFEST = "latest_manifest"315 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"316 private const val KEY_NOTES_VERSION = "notes_version"317 private const val KEY_NOTES = "notes"318 private const val KEY_NOTIFIED_VERSION = "notified_version"319 }320}