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

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.tv
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -11,6 +12,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -20,6 +24,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
@@ -38,10 +44,12 @@ import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.data.SessionBootstrapper import hu.bbara.purefin.data.SessionBootstrapper
import hu.bbara.purefin.data.UserSessionRepository 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.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
@@ -135,10 +143,13 @@ class TvActivity : ComponentActivity() {
fun MainApp( fun MainApp(
userSessionRepository: UserSessionRepository, userSessionRepository: UserSessionRepository,
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>, entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
navigationManager: NavigationManager navigationManager: NavigationManager,
updateViewModel: AppUpdateViewModel = hiltViewModel()
) { ) {
var sessionLoaded by remember { mutableStateOf(false) } var sessionLoaded by remember { mutableStateOf(false) }
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
userSessionRepository.isLoggedIn.collect { userSessionRepository.isLoggedIn.collect {
@@ -146,12 +157,19 @@ class TvActivity : ComponentActivity() {
} }
} }
if (!sessionLoaded) { LaunchedEffect(updateViewModel) {
PurefinWaitingScreen(modifier = Modifier.fillMaxSize()) updateViewModel.checkForUpdates(showUpToDateMessage = false)
return
} }
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") @Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route> val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val appEntryProvider = entryProvider { val appEntryProvider = entryProvider {
@@ -213,5 +231,44 @@ class TvActivity : ComponentActivity() {
} else { } else {
LoginScreen() 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:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin"> 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 <activity
android:name=".PurefinActivity" android:name=".PurefinActivity"
android:exported="true" android:exported="true"
@@ -40,7 +43,7 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<receiver <receiver
android:name=".update.AppUpdateInstallReceiver" android:name="hu.bbara.purefin.update.AppUpdateInstallReceiver"
android:exported="false" /> android:exported="false" />
</application> </application>

View File

@@ -92,7 +92,7 @@ fun AppScreen(
) )
}, },
onProfileClick = {}, onProfileClick = {},
onCheckForUpdates = updateViewModel::checkForUpdates, onCheckForUpdates = { updateViewModel.checkForUpdates() },
isCheckingForUpdates = isCheckingForUpdates, isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = viewModel::openSettings, onSettingsClick = viewModel::openSettings,
onLogoutClick = viewModel::logout, 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 } private val json = Json { ignoreUnknownKeys = true }
suspend fun checkForUpdate(): AppUpdateInfo? { suspend fun checkForUpdate(): AppUpdateInfo? {
val manifestUrl = "http://purefin.t.bbara.hu/app/update.json" val manifestUrl = appVersionProvider.updateManifestUrl
if (manifestUrl.isBlank()) { if (manifestUrl.isBlank()) {
throw IllegalStateException("Update manifest URL not configured") throw IllegalStateException("Update manifest URL not configured")
} }

View File

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

View File

@@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update
interface AppVersionProvider { interface AppVersionProvider {
val versionCode: Long 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) { val apkFile = withContext(Dispatchers.IO) {
downloadApk(update) downloadApk(update)
.also { validateApk(it, update.versionCode) } .also { validateApk(it) }
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -43,7 +43,7 @@ class AndroidAppUpdateInstaller @Inject constructor(
} }
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString() val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return "Downloaded Purefin $versionLabel" return "Downloaded ${appLabel()} $versionLabel"
} }
private fun downloadApk(update: AppUpdateInfo): File { private fun downloadApk(update: AppUpdateInfo): File {
@@ -72,18 +72,9 @@ class AndroidAppUpdateInstaller @Inject constructor(
return apkFile return apkFile
} }
private fun validateApk(apkFile: File, expectedVersionCode: Long) { private fun validateApk(apkFile: File) {
val packageInfo = appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) appContext.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
?: throw IllegalStateException("Downloaded file is not a valid APK") ?: 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) { 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 abstract fun bindAppUpdateInstaller(impl: AndroidAppUpdateInstaller): AppUpdateInstaller
@Binds @Binds
abstract fun bindAppVersionProvider(impl: BuildConfigAppVersionProvider): AppVersionProvider abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
} }