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>
150 lines
6.5 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|