Files
Pit-Log-App/app/src/main/java/com/racingapp/pitlog/data/AppUpdate.kt
T

152 lines
5.4 KiB
Kotlin

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
}
}