package com.racingapp.pitlog.data import android.content.ClipData import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider import java.io.File import java.net.HttpURLConnection import java.net.URL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject private const val LATEST_RELEASE = "https://git.ny.daprogs.com/api/v1/repos/RallyBlock/Pit-Log-App/releases/latest" private const val MAX_APK_BYTES = 80_000_000 private data class PostedRelease(val versionCode: Long, val apkUrl: String) fun installedVersionCode(context: Context): Long { val info = context.packageManager.getPackageInfo(context.packageName, 0) return if (Build.VERSION.SDK_INT >= 28) { info.longVersionCode } else { @Suppress("DEPRECATION") info.versionCode.toLong() } } suspend fun checkForAppUpdate(context: Context, onStatus: (String) -> Unit): String { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) { val settings = Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"), ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(settings) return "Allow Pit Log to install updates, then check again." } val release = withContext(Dispatchers.IO) { fetchLatest() } ?: return "Couldn't check for an update." if (release.versionCode == 0L) return "No update has been posted." if (release.versionCode <= installedVersionCode(context)) return "This is the latest version." onStatus("Downloading…") val file = withContext(Dispatchers.IO) { downloadApk(context, release.apkUrl) } ?: return "Couldn't download the update." promptInstall(context, file) return "Confirm the install when Android asks." } private fun fetchLatest(): PostedRelease? { val connection = open(LATEST_RELEASE) ?: return null return try { val code = connection.responseCode if (code == 404) return PostedRelease(0, "") if (code !in 200..299) return null val body = connection.inputStream.bufferedReader().use { it.readText() } parseRelease(body) } catch (_: Exception) { null } finally { connection.disconnect() } } private fun parseRelease(body: String): PostedRelease? { val json = JSONObject(body) val versionCode = versionCodeOf(json.optString("tag_name")) ?: return null val assets = json.optJSONArray("assets") ?: return null for (index in 0 until assets.length()) { val asset = assets.optJSONObject(index) ?: continue val name = asset.optString("name") val url = asset.optString("browser_download_url") if (name.endsWith(".apk") && url.startsWith("https://")) { return PostedRelease(versionCode, url) } } return null } private fun versionCodeOf(tag: String): Long? { val raw = tag.removePrefix("v") raw.toLongOrNull()?.let { return it } if (!raw.matches(Regex("""\d+\.\d{3}"""))) return null val whole = raw.substringBefore('.').toLong() val fraction = raw.substringAfter('.').toLong() return whole * 1000 + fraction } private fun downloadApk(context: Context, apkUrl: String): File? { val connection = open(apkUrl) ?: return null return try { if (connection.responseCode !in 200..299) return null val length = connection.contentLengthLong if (length > MAX_APK_BYTES) return null val dir = File(context.cacheDir, "updates").apply { mkdirs() } val file = File(dir, "pit-log.apk") connection.inputStream.use { input -> file.outputStream().use { output -> val buffer = ByteArray(8192) var total = 0L while (true) { val read = input.read(buffer) if (read < 0) break total += read if (total > MAX_APK_BYTES) { file.delete() return null } output.write(buffer, 0, read) } } } val zip = file.inputStream().use { stream -> stream.read() == 0x50 && stream.read() == 0x4B } if (!zip) { file.delete() return null } file } catch (_: Exception) { null } finally { connection.disconnect() } } private fun promptInstall(context: Context, file: File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val install = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) clipData = ClipData.newRawUri("Pit Log", uri) } context.startActivity(install) } private fun open(address: String): HttpURLConnection? { return try { (URL(address).openConnection() as HttpURLConnection).apply { instanceFollowRedirects = true connectTimeout = 20_000 readTimeout = 120_000 setRequestProperty("User-Agent", "PitLog") setRequestProperty("Accept", "application/json, application/vnd.android.package-archive") } } catch (_: Exception) { null } }