mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +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"?>
|
<?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>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update
|
|||||||
|
|
||||||
interface AppVersionProvider {
|
interface AppVersionProvider {
|
||||||
val versionCode: Long
|
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) {
|
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()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user