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

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.touchscreen"
@@ -23,7 +25,11 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/purefin_logo_round"
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-tv/update.json" />
<activity
android:name="hu.bbara.purefin.tv.TvActivity"
android:exported="true"
@@ -34,6 +40,9 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name="hu.bbara.purefin.update.AppUpdateInstallReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.tv
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -11,6 +12,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -20,6 +24,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
@@ -38,10 +44,12 @@ import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.data.SessionBootstrapper
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.feature.update.AppUpdateInfo
import hu.bbara.purefin.feature.update.AppUpdateViewModel
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen
@@ -135,10 +143,13 @@ class TvActivity : ComponentActivity() {
fun MainApp(
userSessionRepository: UserSessionRepository,
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
navigationManager: NavigationManager
navigationManager: NavigationManager,
updateViewModel: AppUpdateViewModel = hiltViewModel()
) {
var sessionLoaded by remember { mutableStateOf(false) }
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) {
userSessionRepository.isLoggedIn.collect {
@@ -146,12 +157,19 @@ class TvActivity : ComponentActivity() {
}
}
if (!sessionLoaded) {
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
return
LaunchedEffect(updateViewModel) {
updateViewModel.checkForUpdates(showUpToDateMessage = false)
}
if (isLoggedIn) {
LaunchedEffect(updateViewModel, context) {
updateViewModel.snackbarMessages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
if (!sessionLoaded) {
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
} else if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val appEntryProvider = entryProvider {
@@ -213,5 +231,44 @@ class TvActivity : ComponentActivity() {
} else {
LoginScreen()
}
availableUpdate?.let { update ->
TvUpdateAvailableDialog(
update = update,
onAccept = updateViewModel::acceptUpdate,
onDecline = updateViewModel::declineUpdate
)
}
}
@Composable
private fun TvUpdateAvailableDialog(
update: AppUpdateInfo,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
val updateText = if (releaseNotes == null) {
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
} else {
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
}
AlertDialog(
onDismissRequest = onDecline,
title = { Text("Update available") },
text = { Text(updateText) },
confirmButton = {
TextButton(onClick = onAccept) {
Text("Install")
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text("Not now")
}
}
)
}
}

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,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()
}

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

@@ -35,7 +35,7 @@ class AndroidAppUpdateInstaller @Inject constructor(
val apkFile = withContext(Dispatchers.IO) {
downloadApk(update)
.also { validateApk(it, update.versionCode) }
.also { validateApk(it) }
}
withContext(Dispatchers.IO) {
@@ -43,7 +43,7 @@ class AndroidAppUpdateInstaller @Inject constructor(
}
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return "Downloaded Purefin $versionLabel"
return "Downloaded ${appLabel()} $versionLabel"
}
private fun downloadApk(update: AppUpdateInfo): File {
@@ -72,18 +72,9 @@ class AndroidAppUpdateInstaller @Inject constructor(
return apkFile
}
private fun validateApk(apkFile: File, expectedVersionCode: Long) {
val packageInfo = appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
private fun validateApk(apkFile: File) {
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) {
@@ -141,4 +132,7 @@ class AndroidAppUpdateInstaller @Inject constructor(
)
}
}
private fun appLabel(): String =
appContext.applicationInfo.loadLabel(appContext.packageManager).toString()
}

View File

@@ -15,5 +15,5 @@ abstract class AppUpdateModule {
abstract fun bindAppUpdateInstaller(impl: AndroidAppUpdateInstaller): AppUpdateInstaller
@Binds
abstract fun bindAppVersionProvider(impl: BuildConfigAppVersionProvider): AppVersionProvider
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
}