diff --git a/app-tv/src/main/AndroidManifest.xml b/app-tv/src/main/AndroidManifest.xml index 61514307..a7354a47 100644 --- a/app-tv/src/main/AndroidManifest.xml +++ b/app-tv/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + + + + diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt index 835a7daa..832855f1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt @@ -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.() -> 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 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") + } + } + ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb74bc99..eb9534c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,9 @@ android:supportsRtl="true" android:usesCleartextTraffic="true" android:theme="@style/Theme.Purefin"> + diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt index 38a36cc0..1e1b42e1 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/AppScreen.kt @@ -92,7 +92,7 @@ fun AppScreen( ) }, onProfileClick = {}, - onCheckForUpdates = updateViewModel::checkForUpdates, + onCheckForUpdates = { updateViewModel.checkForUpdates() }, isCheckingForUpdates = isCheckingForUpdates, onSettingsClick = viewModel::openSettings, onLogoutClick = viewModel::logout, diff --git a/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt b/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt deleted file mode 100644 index 9452f625..00000000 --- a/app/src/main/java/hu/bbara/purefin/update/BuildConfigAppVersionProvider.kt +++ /dev/null @@ -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() -} diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt index d7b1e653..956c3645 100644 --- a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateRepository.kt @@ -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") } diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt index eaa4904e..eac78d9b 100644 --- a/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppUpdateViewModel.kt @@ -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 } diff --git a/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt b/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt index bb2d7ab2..bc0264ae 100644 --- a/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt +++ b/core/src/main/java/hu/bbara/purefin/feature/update/AppVersionProvider.kt @@ -2,4 +2,5 @@ package hu.bbara.purefin.feature.update interface AppVersionProvider { val versionCode: Long + val updateManifestUrl: String } diff --git a/core/src/main/java/hu/bbara/purefin/update/AndroidAppVersionProvider.kt b/core/src/main/java/hu/bbara/purefin/update/AndroidAppVersionProvider.kt new file mode 100644 index 00000000..559156ad --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/update/AndroidAppVersionProvider.kt @@ -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" + } +} diff --git a/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstallReceiver.kt b/core/src/main/java/hu/bbara/purefin/update/AppUpdateInstallReceiver.kt similarity index 100% rename from app/src/main/java/hu/bbara/purefin/update/AppUpdateInstallReceiver.kt rename to core/src/main/java/hu/bbara/purefin/update/AppUpdateInstallReceiver.kt diff --git a/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt b/core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt similarity index 86% rename from app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt rename to core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt index 9a9aa857..8fd7cafc 100644 --- a/app/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt +++ b/core/src/main/java/hu/bbara/purefin/update/AppUpdateInstaller.kt @@ -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() } diff --git a/app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt b/core/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt similarity index 82% rename from app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt rename to core/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt index 28f6735b..c272a53d 100644 --- a/app/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt +++ b/core/src/main/java/hu/bbara/purefin/update/AppUpdateModule.kt @@ -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 }