mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-22 17:41:39 +00:00
feat(tv-update): implement update check and dialog for available updates on android tv devices
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ fun AppScreen(
|
||||
)
|
||||
},
|
||||
onProfileClick = {},
|
||||
onCheckForUpdates = updateViewModel::checkForUpdates,
|
||||
onCheckForUpdates = { updateViewModel.checkForUpdates() },
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = viewModel::openSettings,
|
||||
onLogoutClick = viewModel::logout,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update
|
||||
|
||||
interface AppVersionProvider {
|
||||
val versionCode: Long
|
||||
val updateManifestUrl: String
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user