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

@@ -20,6 +20,9 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin">
<meta-data
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
android:value="http://purefin.t.bbara.hu/app/update.json" />
<activity
android:name=".PurefinActivity"
android:exported="true"
@@ -40,7 +43,7 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".update.AppUpdateInstallReceiver"
android:name="hu.bbara.purefin.update.AppUpdateInstallReceiver"
android:exported="false" />
</application>

View File

@@ -92,7 +92,7 @@ fun AppScreen(
)
},
onProfileClick = {},
onCheckForUpdates = updateViewModel::checkForUpdates,
onCheckForUpdates = { updateViewModel.checkForUpdates() },
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = viewModel::openSettings,
onLogoutClick = viewModel::logout,

View File

@@ -1,44 +0,0 @@
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

@@ -1,144 +0,0 @@
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, update.versionCode) }
}
withContext(Dispatchers.IO) {
commitInstallSession(apkFile, update.versionCode)
}
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return "Downloaded Purefin $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, expectedVersionCode: Long) {
val packageInfo = appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
?: throw IllegalStateException("Downloaded file is not a valid APK")
// if (packageInfo.packageName != appContext.packageName) {
// throw IllegalStateException("Downloaded APK package does not match Purefin")
// }
// if (packageInfo.longVersionCode != expectedVersionCode) {
// throw IllegalStateException("Downloaded APK version does not match update manifest")
// }
// if (packageInfo.longVersionCode <= BuildConfig.VERSION_CODE.toLong()) {
// throw IllegalStateException("Downloaded APK is not newer")
// }
}
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)
)
}
}
}

View File

@@ -1,19 +0,0 @@
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: BuildConfigAppVersionProvider): AppVersionProvider
}

View File

@@ -1,9 +0,0 @@
package hu.bbara.purefin.update
import hu.bbara.purefin.BuildConfig
import hu.bbara.purefin.feature.update.AppVersionProvider
import javax.inject.Inject
class BuildConfigAppVersionProvider @Inject constructor() : AppVersionProvider {
override val versionCode: Long = BuildConfig.VERSION_CODE.toLong()
}