Some checks failed
Build TV app / build (push) Failing after 2s
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>
113 lines
4.6 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|