Files
castarr/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
be-nj 753ab3a3ab
Some checks failed
Build TV app / build (push) Failing after 2s
Follow the repo move to the be-nj organisation
Updater and README point at the new path. The old one redirects, but the
updater should not depend on that. Also drops a stale GitHub-specific
Accept header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 02:33:47 +02:00

113 lines
4.6 KiB
Kotlin

package dev.castarr.tv.update
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.FileProvider
import dev.castarr.tv.AppState
import dev.castarr.tv.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** In-app updater over Gitea releases (#12). Explicit, never automatic. */
object UpdateChecker {
private const val TAG = "UpdateChecker"
private const val LATEST =
"https://git.beckm4nn.net/api/v1/repos/be-nj/castarr/releases/latest"
// Release assets need an API token to upload, so the APK is served from
// an orphan branch instead — anonymously fetchable on a public repo.
private const val APK_FALLBACK =
"https://git.beckm4nn.net/be-nj/castarr/raw/branch/apk/castarr.apk"
private var apkUrl: String = ""
/** Returns the newer version tag, or null when current. Never throws. */
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
runCatching {
val json = JSONObject(get(LATEST))
val tag = json.optString("tag_name").removePrefix("v")
val assets = json.optJSONArray("assets")
var url = ""
for (i in 0 until (assets?.length() ?: 0)) {
val asset = assets!!.getJSONObject(i)
if (asset.optString("name").endsWith(".apk")) {
url = asset.optString("browser_download_url")
break
}
}
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url
val version = "v$tag"
withContext(Dispatchers.Main) { state.updateAvailable = version }
version
} else {
withContext(Dispatchers.Main) { state.updateAvailable = null }
null
}
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
}
/** Downloads the APK and hands it to the package installer. */
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) {
runCatching {
if (apkUrl.isEmpty()) check(state)
require(apkUrl.isNotEmpty()) { "no update available" }
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val file = File(dir, "castarr-update.apk")
(URL(apkUrl).openConnection() as HttpURLConnection).run {
connectTimeout = 15_000
readTimeout = 120_000
instanceFollowRedirects = true
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
disconnect()
}
val uri = FileProvider.getUriForFile(
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
)
val intent = 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)
}
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
"Update fehlgeschlagen — später erneut versuchen"
}
}
private fun isNewer(remote: String, local: String): Boolean {
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
val r = parts(remote)
val l = parts(local)
for (i in 0 until maxOf(r.size, l.size)) {
val a = r.getOrElse(i) { 0 }
val b = l.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
private fun get(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/json")
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}