feat(tv-update): implement update check and dialog for available updates on android tv devices

This commit is contained in:
2026-05-03 14:49:19 +02:00
parent a5bb1f84dc
commit fe0b76a0ad
12 changed files with 139 additions and 34 deletions

View File

@@ -15,7 +15,7 @@ class AppUpdateRepository @Inject constructor(
private val json = Json { ignoreUnknownKeys = true }
suspend fun checkForUpdate(): AppUpdateInfo? {
val manifestUrl = "http://purefin.t.bbara.hu/app/update.json"
val manifestUrl = appVersionProvider.updateManifestUrl
if (manifestUrl.isBlank()) {
throw IllegalStateException("Update manifest URL not configured")
}

View File

@@ -29,7 +29,7 @@ class AppUpdateViewModel @Inject constructor(
private var isInstallingUpdate = false
fun checkForUpdates() {
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
if (_isCheckingForUpdates.value) {
return
}
@@ -39,7 +39,9 @@ class AppUpdateViewModel @Inject constructor(
try {
val update = appUpdateRepository.checkForUpdate()
if (update == null) {
_snackbarMessages.emit("Purefin is up to date")
if (showUpToDateMessage) {
_snackbarMessages.emit("Purefin is up to date")
}
} else {
_availableUpdate.value = update
}

View File

@@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update
interface AppVersionProvider {
val versionCode: Long
val updateManifestUrl: String
}

View File

@@ -0,0 +1,48 @@
package hu.bbara.purefin.update
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.feature.update.AppVersionProvider
import javax.inject.Inject
class AndroidAppVersionProvider @Inject constructor(
@ApplicationContext context: Context
) : AppVersionProvider {
private val appContext = context.applicationContext
override val versionCode: Long
get() = appContext.packageManager.currentPackageInfo().longVersionCode
override val updateManifestUrl: String
get() = appContext.packageManager.currentApplicationInfo()
.metaData
?.getString(UPDATE_MANIFEST_URL_META_DATA)
.orEmpty()
private fun PackageManager.currentPackageInfo(): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(appContext.packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
getPackageInfo(appContext.packageName, 0)
}
private fun PackageManager.currentApplicationInfo(): ApplicationInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getApplicationInfo(
appContext.packageName,
PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong())
)
} else {
@Suppress("DEPRECATION")
getApplicationInfo(appContext.packageName, PackageManager.GET_META_DATA)
}
private companion object {
const val UPDATE_MANIFEST_URL_META_DATA = "hu.bbara.purefin.UPDATE_MANIFEST_URL"
}
}

View File

@@ -0,0 +1,44 @@
package hu.bbara.purefin.update
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
class AppUpdateInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmIntent = intent.pendingUserActionIntent() ?: return
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(confirmIntent)
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "Install confirmation activity missing", e)
}
}
PackageInstaller.STATUS_SUCCESS -> Unit
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.e(TAG, "Install failed: $status $message")
}
}
}
private fun Intent.pendingUserActionIntent(): Intent? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(Intent.EXTRA_INTENT)
}
}
private companion object {
const val TAG = "AppUpdateInstaller"
}
}

View File

@@ -0,0 +1,138 @@
package hu.bbara.purefin.update
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.feature.update.AppUpdateInfo
import hu.bbara.purefin.feature.update.AppUpdateInstaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import javax.inject.Inject
class AndroidAppUpdateInstaller @Inject constructor(
@ApplicationContext context: Context
) : AppUpdateInstaller {
private val appContext = context.applicationContext
private val client = OkHttpClient()
override suspend fun installUpdate(update: AppUpdateInfo): String {
if (!appContext.packageManager.canRequestPackageInstalls()) {
openInstallPermissionSettings()
return "Allow app installs, then check again"
}
val apkFile = withContext(Dispatchers.IO) {
downloadApk(update)
.also { validateApk(it) }
}
withContext(Dispatchers.IO) {
commitInstallSession(apkFile, update.versionCode)
}
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return "Downloaded ${appLabel()} $versionLabel"
}
private fun downloadApk(update: AppUpdateInfo): File {
val apkUrl = update.manifestUrl.toHttpUrl().resolve(update.apkUrl)
?: throw IllegalStateException("APK URL invalid")
val request = Request.Builder()
.url(apkUrl)
.build()
val updateDir = appContext.cacheDir.resolve("updates")
updateDir.mkdirs()
updateDir.listFiles()?.forEach { it.delete() }
val apkFile = File(updateDir, "purefin-${update.versionCode}.apk")
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("APK download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IllegalStateException("APK download empty")
body.byteStream().use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
return apkFile
}
private fun validateApk(apkFile: File) {
appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
?: throw IllegalStateException("Downloaded file is not a valid APK")
}
private fun commitInstallSession(apkFile: File, versionCode: Long) {
val installer = appContext.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(appContext.packageName)
setInstallReason(PackageManager.INSTALL_REASON_USER)
setSize(apkFile.length())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
setPackageSource(PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE)
}
}
val sessionId = installer.createSession(params)
try {
installer.openSession(sessionId).use { session ->
apkFile.inputStream().use { input ->
session.openWrite("purefin-$versionCode.apk", 0, apkFile.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
session.commit(statusReceiver(sessionId))
}
} catch (e: Exception) {
installer.abandonSession(sessionId)
throw e
}
}
private fun statusReceiver(sessionId: Int): IntentSender {
val intent = Intent(appContext, AppUpdateInstallReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
appContext,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
return pendingIntent.intentSender
}
private fun openInstallPermissionSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${appContext.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
appContext.startActivity(intent)
} catch (_: ActivityNotFoundException) {
appContext.startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
private fun appLabel(): String =
appContext.applicationInfo.loadLabel(appContext.packageManager).toString()
}

View File

@@ -0,0 +1,19 @@
package hu.bbara.purefin.update
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.feature.update.AppUpdateInstaller
import hu.bbara.purefin.feature.update.AppVersionProvider
@Module
@InstallIn(SingletonComponent::class)
abstract class AppUpdateModule {
@Binds
abstract fun bindAppUpdateInstaller(impl: AndroidAppUpdateInstaller): AppUpdateInstaller
@Binds
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
}