152 lines
5.4 KiB
Kotlin
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
|
|
}
|
|
}
|