1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.net.Uri6import android.provider.Settings7import androidx.core.content.FileProvider8import com.peachpasswords.BuildConfig9import dagger.hilt.android.qualifiers.ApplicationContext10import kotlinx.coroutines.Dispatchers11import kotlinx.coroutines.flow.MutableStateFlow12import kotlinx.coroutines.flow.StateFlow13import kotlinx.coroutines.flow.asStateFlow14import kotlinx.coroutines.withContext15import okhttp3.OkHttpClient16import okhttp3.Request17import java.io.File18import java.io.IOException19import java.util.concurrent.TimeUnit20import javax.inject.Inject21import javax.inject.Singleton2223242526272829303132@Singleton33class UpdateRepository @Inject constructor(34 @ApplicationContext private val context: Context,35) {3637 private val client: OkHttpClient by lazy {38 OkHttpClient.Builder()39 .callTimeout(5, TimeUnit.MINUTES)40 .build()41 }4243 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }4445 46 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)4748 private val _available = MutableStateFlow(cachedAvailable())49 50 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5152 private val _whatsNew = MutableStateFlow<String?>(null)53 54 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()5556 private val _openRequested = MutableStateFlow(false)57 58 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()5960 init {61 refreshWhatsNew()62 }6364 fun requestOpen() { _openRequested.value = true }6566 fun consumeOpenRequest() { _openRequested.value = false }6768 6970 717273747576 suspend fun check(): ReleaseManifest? {77 val manifest = fetchLatest()78 val newer = isNewerVersion(manifest.version, currentVersion)7980 _available.value = manifest.takeIf { newer }81 prefs.edit().apply {82 if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)83 }.apply()8485 86 87 if (normalizeVersion(manifest.version) == currentVersion) {88 if (manifest.notes == null) {89 90 91 markChangelogSeen()92 } else {93 prefs.edit()94 .putString(KEY_NOTES_VERSION, currentVersion)95 .putString(KEY_NOTES, manifest.notes)96 .apply()97 refreshWhatsNew()98 }99 }100 return _available.value101 }102103 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {104 val request = Request.Builder()105 .url(LATEST_RELEASE_URL)106 .header("User-Agent", USER_AGENT)107 .build()108 client.newCall(request).execute().use { response ->109 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")110 parseReleaseManifest(response.body?.string().orEmpty())111 }112 }113114 115116 117118119120121 suspend fun downloadAndVerify(122 manifest: ReleaseManifest,123 onProgress: (Float) -> Unit = {},124 ): File = withContext(Dispatchers.IO) {125 val expected = parseChecksum(manifest.checksum)126 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")127128 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }129 val target = File(dir, "peach-${manifest.version}.apk")130131 132 133 dir.listFiles()?.forEach { if (it != target) it.delete() }134 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {135 onProgress(1f)136 return@withContext target137 }138 target.delete()139140 val request = Request.Builder()141 .url(manifest.url)142 .header("User-Agent", USER_AGENT)143 .build()144 client.newCall(request).execute().use { response ->145 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")146 val body = response.body ?: throw IOException("empty response")147 val total = body.contentLength()148 body.byteStream().use { input ->149 target.outputStream().use { output ->150 val buffer = ByteArray(64 * 1024)151 var written = 0L152 while (true) {153 val read = input.read(buffer)154 if (read <= 0) break155 output.write(buffer, 0, read)156 written += read157 if (total > 0) onProgress((written.toFloat() / total).coerceIn(0f, 1f))158 }159 }160 }161 }162163 val actual = hashFile(target, expected.algorithm)164 if (actual != expected.hex) {165 target.delete()166 throw ChecksumMismatchException(167 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",168 )169 }170 onProgress(1f)171 target172 }173174 fun discardDownloads() {175 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }176 }177178 179180 181 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()182183 fun unknownSourcesSettingsIntent(): Intent =184 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))185 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)186187 188189190191 fun installIntent(apk: File): Intent {192 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)193 return Intent(Intent.ACTION_VIEW)194 .setDataAndType(uri, APK_MIME)195 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)196 }197198 199200 201202203204205206 fun refreshWhatsNew() {207 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)208 if (lastSeen == null) {209 markChangelogSeen()210 return211 }212 if (lastSeen == currentVersion) {213 _whatsNew.value = null214 return215 }216 _whatsNew.value = prefs.getString(KEY_NOTES, null)217 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }218 }219220 fun markChangelogSeen() {221 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()222 _whatsNew.value = null223 }224225 226227 228 fun shouldNotifyFor(version: String): Boolean =229 prefs.getString(KEY_NOTIFIED_VERSION, null) != version230231 fun markNotified(version: String) {232 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()233 }234235 private fun cachedAvailable(): ReleaseManifest? =236 prefs.getString(KEY_LATEST_MANIFEST, null)237 ?.let { ReleaseManifest.fromCachedJson(it) }238 ?.takeIf { isNewerVersion(it.version, currentVersion) }239240 companion object {241 242 const val KEPR_REPO = "peach-android"243 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"244245 private const val APK_MIME = "application/vnd.android.package-archive"246 private const val USER_AGENT = "Peach-Android"247 private const val CACHE_DIR = "updates"248249 private const val PREFS = "peach_updates"250 private const val KEY_LATEST_MANIFEST = "latest_manifest"251 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"252 private const val KEY_NOTES_VERSION = "notes_version"253 private const val KEY_NOTES = "notes"254 private const val KEY_NOTIFIED_VERSION = "notified_version"255 }256}