Files
castarr/app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
be-nj cbdac7ddb2 Make the club highlight visible and the updater honest
Three fixes.

The pulse ring never showed. It was drawn on a 30dp canvas inside a box
sized to the 20dp crest, so the parent's constraints shrank the canvas
to 20dp and the crest image covered what was left. The box is now wider
than the crest and the ring has room.

"läuft" was drawn straight onto the row, and a focused row is filled
with the accent colour — red on turquoise. The signal now sits on a
dark chip, which reads on an unfocused row, a selected one and a
focused one alike.

The updater handed the installer whatever had been written so far. It
now checks the response code, downloads to a .part file, compares the
byte count against Content-Length before renaming, and refuses a second
concurrent attempt instead of letting two writers share one file. A
half-written APK leaves the installer spinning with nothing to report,
which is what a hang looks like from the sofa.

Crests are also resampled to 128px with a proper filter when fetched;
Android's 8x downscale from the 330px originals left ragged edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:04:58 +02:00

150 lines
6.5 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 = ""
/** Guards against a second click landing on the same download. */
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
/** 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.
*
* The download lands in a .part file that is only renamed once the byte
* count matches what the server announced. Handing a half-written APK to
* the installer leaves it sitting on a spinner with nothing to report,
* which is indistinguishable from a hang.
*/
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
withContext(Dispatchers.IO) {
// A second click while the first download runs would have two
// writers on one file, and the installer reads whichever half won.
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
try {
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")
val part = File(dir, "castarr-update.apk.part")
part.delete()
val connection = URL(apkUrl).openConnection() as HttpURLConnection
val expected = try {
connection.connectTimeout = 15_000
connection.readTimeout = 120_000
connection.instanceFollowRedirects = true
val code = connection.responseCode
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
val announced = connection.contentLengthLong
connection.inputStream.use { input ->
part.outputStream().use { output -> input.copyTo(output) }
}
announced
} finally {
connection.disconnect()
}
require(part.length() > 0) { "empty download" }
require(expected <= 0 || part.length() == expected) {
"truncated: ${part.length()} of $expected"
}
file.delete()
require(part.renameTo(file)) { "rename failed" }
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}: ${it.message}")
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
"Update fehlgeschlagen — später erneut versuchen"
}
} finally {
busy.set(false)
}
}
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()
}
}
}