From 31acce014274bc2178ba84ff098bc18434212859 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 14 May 2026 11:14:33 +0530 Subject: [PATCH 1/6] feat: redesign app updater with global status management and premium UI --- .../com/arflix/tv/ui/components/AppTopBar.kt | 19 +- .../arflix/tv/ui/components/AppUpdateModal.kt | 524 ++++++++++++++++++ .../com/arflix/tv/ui/components/Sidebar.kt | 15 + .../arflix/tv/ui/screens/home/HomeScreen.kt | 14 +- .../tv/ui/screens/home/HomeViewModel.kt | 165 +++++- .../tv/ui/screens/settings/SettingsScreen.kt | 168 +----- .../ui/screens/settings/SettingsViewModel.kt | 203 +++---- .../arflix/tv/updater/ApkInstallReceiver.kt | 12 + .../arflix/tv/updater/UpdateStatusManager.kt | 0 9 files changed, 840 insertions(+), 280 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index 0092676d9..0761ddedf 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -96,6 +96,7 @@ fun AppTopBar( profileCount: Int = 1, clockFormat: String = "24h", syncStatus: com.arflix.tv.data.repository.CloudSyncStatus = com.arflix.tv.data.repository.CloudSyncStatus.NOT_SIGNED_IN, + hasUpdateBadge: Boolean = false, modifier: Modifier = Modifier ) { // Always show the profile avatar when a profile exists — it's clickable @@ -169,7 +170,8 @@ fun AppTopBar( // Settings gear icon (no text label) TopBarSettingsGear( isFocused = settingsFocused, - isSelected = settingsSelected + isSelected = settingsSelected, + hasBadge = hasUpdateBadge ) Text( @@ -264,7 +266,8 @@ private fun TopBarNavChip( @Composable private fun TopBarSettingsGear( isFocused: Boolean, - isSelected: Boolean + isSelected: Boolean, + hasBadge: Boolean = false ) { val iconColor by animateColorAsState( targetValue = when { @@ -307,6 +310,18 @@ private fun TopBarSettingsGear( tint = iconColor, modifier = Modifier.size(20.dp) ) + + // Update Badge + if (hasBadge) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 2.dp, y = (-2).dp) + .size(8.dp) + .clip(CircleShape) + .background(com.arflix.tv.ui.theme.AccentRed) + ) + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt new file mode 100644 index 000000000..9c4c10cb3 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -0,0 +1,524 @@ +package com.arflix.tv.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.NewReleases +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import com.arflix.tv.updater.AppUpdate +import com.arflix.tv.updater.UpdateStatus +import com.arflix.tv.ui.skin.ArvioFocusableSurface +import com.arflix.tv.ui.theme.AccentRed +import com.arflix.tv.ui.theme.ArflixTypography +import com.arflix.tv.ui.theme.BackgroundCard +import com.arflix.tv.ui.theme.SuccessGreen + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun AppUpdateModal( + status: UpdateStatus, + onDownload: () -> Unit, + onInstall: () -> Unit, + onDismiss: () -> Unit, + onIgnore: () -> Unit +) { + if (status is UpdateStatus.Idle || status is UpdateStatus.Checking || status is UpdateStatus.Success) { + return + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(480.dp) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF262626), + BackgroundCard + ) + ) + ) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(20.dp) + ) + .padding(32.dp) + .animateContentSize(animationSpec = tween(300)) + ) { + when (status) { + is UpdateStatus.UpdateAvailable -> { + UpdateAvailableContent( + update = status.update, + onDownload = onDownload, + onDismiss = onDismiss, + onIgnore = onIgnore + ) + } + is UpdateStatus.Downloading -> { + DownloadingContent( + update = status.update, + progress = status.progress + ) + } + is UpdateStatus.ReadyToInstall -> { + ReadyToInstallContent( + update = status.update, + onInstall = onInstall, + onDismiss = onDismiss + ) + } + is UpdateStatus.Installing -> { + InstallingContent( + onRetry = onInstall, + onDismiss = onDismiss + ) + } + is UpdateStatus.Failure -> { + FailureContent( + message = status.message, + onRetry = onDownload, // Retry download/check + onDismiss = onDismiss + ) + } + else -> {} + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun UpdateAvailableContent( + update: AppUpdate, + onDownload: () -> Unit, + onDismiss: () -> Unit, + onIgnore: () -> Unit +) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(SuccessGreen.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.NewReleases, + contentDescription = null, + tint = SuccessGreen, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = "New Update Available", + style = ArflixTypography.titleLarge, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Text( + text = "Version ${update.title}", + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (update.notes.isNotBlank()) { + Text( + text = "What's New:", + style = ArflixTypography.labelLarge, + color = Color.White.copy(alpha = 0.8f), + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + val notes = update.notes.split("\n").filter { it.isNotBlank() }.take(5) + notes.forEach { note -> + Row(modifier = Modifier.padding(bottom = 6.dp)) { + Text(text = "• ", color = SuccessGreen, fontSize = 14.sp) + Text( + text = note.removePrefix("- ").removePrefix("* ").trim(), + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + lineHeight = 20.sp + ) + } + } + if (update.notes.split("\n").size > 5) { + Text( + text = "and more improvements...", + style = ArflixTypography.bodySmall, + color = Color.White.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 12.dp, top = 4.dp) + ) + } + } else { + Text( + text = "Bug fixes and performance improvements.", + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.7f) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + ModalButton( + text = "Ignore Version", + icon = null, + onClick = onIgnore, + containerColor = Color.Transparent, + contentColor = Color.White.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.width(12.dp)) + ModalButton( + text = "Later", + icon = Icons.Filled.Close, + onClick = onDismiss, + containerColor = Color.White.copy(alpha = 0.1f), + contentColor = Color.White + ) + Spacer(modifier = Modifier.width(12.dp)) + ModalButton( + text = "Download", + icon = Icons.Filled.Download, + onClick = onDownload, + containerColor = SuccessGreen, + contentColor = Color.White + ) + } + } +} + +@Composable +private fun DownloadingContent(update: AppUpdate, progress: Float?) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Filled.SystemUpdate, + contentDescription = null, + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Downloading Update...", + style = ArflixTypography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(24.dp)) + + // Progress Bar + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color.White.copy(alpha = 0.1f)) + ) { + if (progress != null) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(SuccessGreen) + ) + } else { + // Indeterminate + CircularProgressIndicator( + color = SuccessGreen, + strokeWidth = 2.dp, + modifier = Modifier.size(6.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = if (progress != null) "${(progress * 100).toInt()}%" else "Connecting...", + style = ArflixTypography.labelMedium, + color = Color.White.copy(alpha = 0.5f) + ) + } +} + +@Composable +private fun ReadyToInstallContent( + update: AppUpdate, + onInstall: () -> Unit, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(SuccessGreen.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = SuccessGreen, + modifier = Modifier.size(32.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Download Complete", + style = ArflixTypography.titleMedium, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Version ${update.title} is ready to install.", + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ModalButton( + text = "Later", + icon = Icons.Filled.Close, + onClick = onDismiss, + containerColor = Color.White.copy(alpha = 0.1f), + contentColor = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + ModalButton( + text = "Install Now", + icon = Icons.Filled.SystemUpdate, + onClick = onInstall, + containerColor = SuccessGreen, + contentColor = Color.White + ) + } + } +} + +@Composable +private fun InstallingContent( + onRetry: () -> Unit, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = SuccessGreen, + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Finalizing Installation...", + style = ArflixTypography.titleMedium, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Please follow the system prompt to complete the update.\nThe app will restart once finished.", + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ModalButton( + text = "Cancel", + icon = Icons.Filled.Close, + onClick = onDismiss, + containerColor = Color.White.copy(alpha = 0.1f), + contentColor = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + ModalButton( + text = "Retry Prompt", + icon = Icons.Filled.Refresh, + onClick = onRetry, + containerColor = Color.White.copy(alpha = 0.15f), + contentColor = Color.White + ) + } + } +} + +@Composable +private fun FailureContent( + message: String, + onRetry: () -> Unit, + onDismiss: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(AccentRed.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + tint = AccentRed, + modifier = Modifier.size(32.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Update Failed", + style = ArflixTypography.titleMedium, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = ArflixTypography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ModalButton( + text = "Close", + icon = Icons.Filled.Close, + onClick = onDismiss, + containerColor = Color.White.copy(alpha = 0.1f), + contentColor = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + ModalButton( + text = "Retry", + icon = Icons.Filled.Refresh, + onClick = onRetry, + containerColor = AccentRed, + contentColor = Color.White + ) + } + } +} + +@Composable +private fun ModalButton( + text: String, + icon: ImageVector?, + onClick: () -> Unit, + containerColor: Color, + contentColor: Color +) { + ArvioFocusableSurface( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + color = containerColor, + focusedColor = Color.White, + modifier = Modifier.height(44.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = text, + style = ArflixTypography.labelLarge, + color = contentColor, + fontWeight = FontWeight.SemiBold + ) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index 6a171cba7..8c0ad091c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -71,6 +71,7 @@ fun Sidebar( isSidebarFocused: Boolean = false, focusedIndex: Int = 1, profile: Profile? = null, + hasUpdateBadge: Boolean = false, onProfileClick: () -> Unit = {}, onItemSelected: (SidebarItem) -> Unit = {}, modifier: Modifier = Modifier @@ -136,6 +137,7 @@ fun Sidebar( item = bottomItem, isSelected = bottomItem == selectedItem, isFocused = isSidebarFocused && settingsFocused, + hasBadge = hasUpdateBadge ) Spacer(modifier = Modifier.height(7.dp)) } @@ -207,6 +209,7 @@ private fun SidebarIcon( item: SidebarItem, isSelected: Boolean, isFocused: Boolean, + hasBadge: Boolean = false ) { // Animated icon color - dark grey normally, pure white when focused val iconColor by animateColorAsState( @@ -291,6 +294,18 @@ private fun SidebarIcon( scaleY = scale } ) + + // Update Badge + if (hasBadge) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = (-4).dp, y = 4.dp) + .size(8.dp) + .clip(CircleShape) + .background(com.arflix.tv.ui.theme.AccentRed) + ) + } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index edce7fb36..c59a2c738 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -1214,6 +1214,17 @@ fun HomeScreen( onDismiss = { viewModel.dismissToast() } ) } + + // App Update Modal + if (uiState.showAppUpdateDialog) { + com.arflix.tv.ui.components.AppUpdateModal( + status = uiState.updateStatus, + onDownload = { viewModel.downloadAppUpdate() }, + onInstall = { viewModel.installAppUpdateOrRequestPermission() }, + onDismiss = { viewModel.dismissAppUpdateDialog() }, + onIgnore = { viewModel.ignoreAppUpdate() } + ) + } } } @@ -2505,7 +2516,8 @@ private fun HomeInputLayer( focusedIndex = focusState.sidebarFocusIndex, profile = currentProfile, profileCount = profileCount, - clockFormat = clockFormat + clockFormat = clockFormat, + hasUpdateBadge = uiState.hasUpdateBadge ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index f3c2c6e35..da8ca5870 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -93,7 +93,11 @@ data class HomeUiState( val syncStatus: com.arflix.tv.data.repository.CloudSyncStatus = com.arflix.tv.data.repository.CloudSyncStatus.NOT_SIGNED_IN, // Toast val toastMessage: String? = null, - val toastType: ToastType = ToastType.INFO + val toastType: ToastType = ToastType.INFO, + // App Updates + val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, + val showAppUpdateDialog: Boolean = false, + val hasUpdateBadge: Boolean = false ) data class HomeCollectionRow( @@ -122,6 +126,10 @@ class HomeViewModel @Inject constructor( private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, private val realtimeSyncManager: com.arflix.tv.data.repository.RealtimeSyncManager, private val profileManager: ProfileManager, + private val appUpdateRepository: com.arflix.tv.updater.AppUpdateRepository, + private val apkDownloader: com.arflix.tv.updater.ApkDownloader, + private val updatePreferences: com.arflix.tv.updater.UpdatePreferences, + private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager, @ApplicationContext private val context: Context ) : ViewModel() { private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { @@ -1332,6 +1340,36 @@ class HomeViewModel @Inject constructor( loadHomeData() } } + + // Observe global update status to drive UI + viewModelScope.launch { + updateStatusManager.status.collect { status -> + // Dialog is open when we have an update or are downloading/installing/erroring + val showDialog = status !is com.arflix.tv.updater.UpdateStatus.Idle && status !is com.arflix.tv.updater.UpdateStatus.Checking && status !is com.arflix.tv.updater.UpdateStatus.Success + val hasBadge = status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable || status is com.arflix.tv.updater.UpdateStatus.ReadyToInstall || status is com.arflix.tv.updater.UpdateStatus.Downloading + + // If it's a new update, check if it was ignored + var shouldShowNow = showDialog + if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { + val ignoredTag = updatePreferences.ignoredTag.first() + if (ignoredTag == status.update.tag) { + shouldShowNow = false // User ignored this version + } + } + + _uiState.value = _uiState.value.copy( + updateStatus = status, + showAppUpdateDialog = shouldShowNow, + hasUpdateBadge = hasBadge && shouldShowNow + ) + } + } + + // Check for updates shortly after startup + viewModelScope.launch { + delay(if (isLowRamDevice) 15_000L else 10_000L) + checkForAppUpdates(silent = true) + } } /** @@ -3849,6 +3887,131 @@ class HomeViewModel @Inject constructor( } } } + + // --- App Update Methods --- + + fun checkForAppUpdates(silent: Boolean = false) { + if (!appUpdateRepository.supportsSelfUpdate()) return + + viewModelScope.launch { + if (!silent) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Checking) + } + + val result = appUpdateRepository.getLatestUpdate() + result.onSuccess { update -> + val localVer = appUpdateRepository.getInstalledVersionName() + val isNewer = com.arflix.tv.updater.VersionUtils.isRemoteNewer(update.tag, localVer) + + if (isNewer) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.UpdateAvailable(update)) + } else { + if (!silent) { + _uiState.value = _uiState.value.copy( + toastMessage = "You already have the latest version", + toastType = ToastType.INFO + ) + } + updateStatusManager.reset() + } + }.onFailure { error -> + if (!silent) { + _uiState.value = _uiState.value.copy( + toastMessage = error.message ?: "Failed to check for updates", + toastType = ToastType.ERROR + ) + } + updateStatusManager.reset() + } + } + } + + fun downloadAppUpdate() { + val currentStatus = updateStatusManager.status.value + val update = when (currentStatus) { + is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> currentStatus.update + is com.arflix.tv.updater.UpdateStatus.Failure -> currentStatus.update + else -> return + } ?: return + + if (!appUpdateRepository.supportsSelfUpdate()) return + + viewModelScope.launch { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) + + val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val dest = java.io.File(java.io.File(context.cacheDir, "updates"), safeName) + + val result = kotlinx.coroutines.withContext(Dispatchers.IO) { + apkDownloader.download(update.assetUrl, dest) { downloaded, total -> + val progress = if (total != null && total > 0L) { + (downloaded.toFloat() / total.toFloat()).coerceIn(0f, 1f) + } else null + + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(progress, update)) + } + } + + result.onSuccess { file -> + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.ReadyToInstall(file.absolutePath, update)) + // Automatically prompt install once downloaded + installAppUpdateOrRequestPermission() + }.onFailure { error -> + updateStatusManager.updateStatus( + com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: "Download failed", update) + ) + } + } + } + + fun installAppUpdateOrRequestPermission() { + val currentStatus = updateStatusManager.status.value + if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return + + val apkPath = if (currentStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) currentStatus.apkPath else return + val update = currentStatus.update + val apkFile = java.io.File(apkPath) + + if (!apkFile.exists()) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Failure("Downloaded file is missing", update)) + return + } + + if (!com.arflix.tv.updater.ApkInstaller.canRequestPackageInstalls(context)) { + // If we can't request package installs, we should let the user know, but for now + // launchInstall handles falling back to Intent.ACTION_VIEW + } + + val conflictMsg = com.arflix.tv.updater.ApkInstaller.checkSignatureConflict(context, apkFile) + if (conflictMsg != null) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Failure(conflictMsg, update)) + return + } + + com.arflix.tv.updater.ApkInstaller.launchInstall(context, apkFile) + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Installing(update)) + + // Mark this release as ignored so it doesn't pop up again if the user cancels the install + viewModelScope.launch { + updatePreferences.setIgnoredTag(update.tag) + } + } + + fun dismissAppUpdateDialog() { + _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) + // We do not reset updateStatusManager here, so the badge remains active + } + + fun ignoreAppUpdate() { + val currentStatus = updateStatusManager.status.value + if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { + viewModelScope.launch { + updatePreferences.setIgnoredTag(currentStatus.update.tag) + } + } + _uiState.value = _uiState.value.copy(showAppUpdateDialog = false, hasUpdateBadge = false) + updateStatusManager.reset() + } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 4ad971dad..1122bd88b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1699,19 +1699,12 @@ fun SettingsScreen( } if (uiState.showAppUpdateDialog) { - AppUpdateModal( - update = uiState.availableAppUpdate, - isChecking = uiState.isCheckingForUpdate, - isAppUpdateAvailable = uiState.isAppUpdateAvailable, - isDownloading = uiState.isDownloadingAppUpdate, - progress = uiState.appUpdateDownloadProgress, - errorMessage = uiState.appUpdateError, - downloadedApkPath = uiState.downloadedApkPath, - isSelfUpdateSupported = uiState.isSelfUpdateSupported, - onDismiss = { viewModel.dismissAppUpdateDialog() }, - onIgnore = { viewModel.ignoreAppUpdate() }, + com.arflix.tv.ui.components.AppUpdateModal( + status = uiState.updateStatus, onDownload = { viewModel.downloadAppUpdate() }, - onInstall = { viewModel.installAppUpdateOrRequestPermission() } + onInstall = { viewModel.installAppUpdateOrRequestPermission() }, + onDismiss = { viewModel.dismissAppUpdateDialog() }, + onIgnore = { viewModel.ignoreAppUpdate() } ) } @@ -3564,157 +3557,6 @@ private enum class Zone { @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun AppUpdateModal( - update: com.arflix.tv.updater.AppUpdate?, - isChecking: Boolean, - isAppUpdateAvailable: Boolean, - isDownloading: Boolean, - progress: Float?, - errorMessage: String?, - downloadedApkPath: String?, - isSelfUpdateSupported: Boolean, - onDismiss: () -> Unit, - onIgnore: () -> Unit, - onDownload: () -> Unit, - onInstall: () -> Unit -) { - val primaryEnabled = downloadedApkPath != null || isAppUpdateAvailable - var focusedIndex by remember(primaryEnabled) { mutableIntStateOf(if (primaryEnabled) 2 else 0) } - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - usePlatformDefaultWidth = false - ) - ) { - ModalScrim(onDismiss = onDismiss) { - Column( - modifier = Modifier - .then( - if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) - else Modifier.width(760.dp) - ) - .background(BackgroundElevated, RoundedCornerShape(18.dp)) - .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) - .focusRequester(focusRequester) - .focusable() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Back, Key.Escape -> { onDismiss(); true } - Key.DirectionLeft -> { - focusedIndex = (focusedIndex - 1).coerceAtLeast(0) - true - } - Key.DirectionRight -> { - focusedIndex = (focusedIndex + 1).coerceAtMost(2) - true - } - Key.Enter, Key.DirectionCenter -> { - when (focusedIndex) { - 0 -> onDismiss() - 1 -> onIgnore() - 2 -> if (primaryEnabled) { - if (downloadedApkPath != null) onInstall() else onDownload() - } - } - true - } - else -> false - } - } - ) { - Text(stringResource(R.string.app_update), style = ArflixTypography.sectionTitle, color = TextPrimary) - Spacer(modifier = Modifier.height(10.dp)) - - val subtitle = when { - !isSelfUpdateSupported -> "This install is managed by the Play Store." - downloadedApkPath != null && update != null -> "${update.title} is ready to install." - isAppUpdateAvailable && update != null -> "Update available: ${update.title} (${update.tag})" - update != null -> "You already have the latest version installed." - isChecking -> "Checking GitHub Releases..." - else -> "No release information available." - } - Text(subtitle, style = ArflixTypography.body, color = TextSecondary) - - if (update != null && !isChecking && !isDownloading) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (isAppUpdateAvailable) { - "Current version ${BuildConfig.VERSION_NAME} -> latest ${update.tag}" - } else { - "Current version ${BuildConfig.VERSION_NAME} is up to date" - }, - style = ArflixTypography.caption, - color = TextSecondary.copy(alpha = 0.78f) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - if (!errorMessage.isNullOrBlank()) { - Text(errorMessage, style = ArflixTypography.body, color = Pink) - Spacer(modifier = Modifier.height(12.dp)) - } - - when { - isDownloading -> { - Text("Downloading update...", style = ArflixTypography.body, color = TextPrimary) - Spacer(modifier = Modifier.height(8.dp)) - androidx.compose.material3.LinearProgressIndicator( - progress = progress ?: 0f, - modifier = Modifier.fillMaxWidth(), - color = SuccessGreen, - trackColor = Color.White.copy(alpha = 0.08f) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = progress?.let { "${(it * 100).toInt()}%" } ?: "Preparing...", - style = ArflixTypography.caption, - color = TextSecondary - ) - } - downloadedApkPath != null -> { - Text("The latest ARVIO update has been downloaded and is ready to install.", style = ArflixTypography.body, color = TextPrimary) - } - !update?.notes.isNullOrBlank() -> { - Text( - text = update!!.notes.take(900), - style = ArflixTypography.caption.copy(lineHeight = 18.sp), - color = TextSecondary, - modifier = Modifier.heightIn(max = 260.dp) - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - UpdateActionButton("Close", focusedIndex == 0, onDismiss) - UpdateActionButton("Ignore", focusedIndex == 1, onIgnore) - UpdateActionButton( - when { - downloadedApkPath != null -> "Install" - isAppUpdateAvailable -> "Download" - else -> "Latest" - }, - focusedIndex == 2, - if (downloadedApkPath != null) onInstall else onDownload, - highlighted = true, - enabled = isSelfUpdateSupported && !isChecking && primaryEnabled - ) - } - } - } - } -} @OptIn(ExperimentalTvMaterial3Api::class) @Composable diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 75209138b..b34c0042f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -148,16 +148,9 @@ data class SettingsUiState( val iptvProgressText: String? = null, val iptvProgressPercent: Int = 0, // App updates - val isSelfUpdateSupported: Boolean = true, - val isCheckingForUpdate: Boolean = false, - val availableAppUpdate: AppUpdate? = null, - val isAppUpdateAvailable: Boolean = false, - val isDownloadingAppUpdate: Boolean = false, - val appUpdateDownloadProgress: Float? = null, - val downloadedApkPath: String? = null, + val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, val showAppUpdateDialog: Boolean = false, val showUnknownSourcesDialog: Boolean = false, - val appUpdateError: String? = null, // Catalogs val catalogs: List = emptyList(), val catalogSearchQuery: String = "", @@ -219,7 +212,8 @@ class SettingsViewModel @Inject constructor( private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, private val appUpdateRepository: AppUpdateRepository, private val updatePreferences: UpdatePreferences, - private val apkDownloader: ApkDownloader + private val apkDownloader: ApkDownloader, + private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager ) : ViewModel() { private fun visibleCatalogs(catalogs: List): List { return catalogs.filter { config -> @@ -377,6 +371,28 @@ class SettingsViewModel @Inject constructor( } } } + + viewModelScope.launch { + updateStatusManager.status.collect { status -> + val showDialog = status !is com.arflix.tv.updater.UpdateStatus.Idle && status !is com.arflix.tv.updater.UpdateStatus.Checking && status !is com.arflix.tv.updater.UpdateStatus.Success + var shouldShowNow = showDialog + + if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { + val ignoredTag = updatePreferences.ignoredTag.first() + if (ignoredTag == status.update.tag) { + shouldShowNow = false // User ignored this version + } + } + + _uiState.value = _uiState.value.copy( + updateStatus = status, + showAppUpdateDialog = shouldShowNow + ) + } + } + } + } + } } private fun loadSettings() { @@ -2518,137 +2534,108 @@ class SettingsViewModel @Inject constructor( fun checkForAppUpdates(force: Boolean, showNoUpdateFeedback: Boolean) { if (!appUpdateRepository.supportsSelfUpdate()) { - _uiState.value = _uiState.value.copy( - isSelfUpdateSupported = false, - showAppUpdateDialog = force, - appUpdateError = if (force) "This install is managed by the Play Store." else null - ) + _uiState.value = _uiState.value.copy(showAppUpdateDialog = force) return } viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isCheckingForUpdate = true, - appUpdateError = null, - showAppUpdateDialog = false - ) - - val ignoredTag = updatePreferences.ignoredTag.first() + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Checking) val result = appUpdateRepository.getLatestUpdate() updatePreferences.setLastCheckAtMs(System.currentTimeMillis()) - result - .onSuccess { update -> - // Use the actually installed version from PackageManager, not BuildConfig, - // because on Android TV the old process can survive an APK install. - val installedVersion = appUpdateRepository.getInstalledVersionName() - val remoteNewer = VersionUtils.isRemoteNewer(update.tag, installedVersion) - val shouldShow = remoteNewer && (ignoredTag == null || ignoredTag != update.tag) + result.onSuccess { update -> + val localVer = appUpdateRepository.getInstalledVersionName() + val isNewer = com.arflix.tv.updater.VersionUtils.isRemoteNewer(update.tag, localVer) - _uiState.value = _uiState.value.copy( - isCheckingForUpdate = false, - availableAppUpdate = update, - isAppUpdateAvailable = remoteNewer, - isDownloadingAppUpdate = false, - appUpdateDownloadProgress = null, - downloadedApkPath = if (remoteNewer) _uiState.value.downloadedApkPath else null, - showAppUpdateDialog = shouldShow || force, - appUpdateError = null, - toastMessage = if (showNoUpdateFeedback && !remoteNewer) "You already have the latest version" else _uiState.value.toastMessage, - toastType = if (showNoUpdateFeedback && !remoteNewer) ToastType.INFO else _uiState.value.toastType - ) + if (isNewer) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.UpdateAvailable(update)) + // If force is true, we want to show the dialog even if ignored + if (force) { + _uiState.value = _uiState.value.copy(showAppUpdateDialog = true) + } + } else { + if (showNoUpdateFeedback) { + _uiState.value = _uiState.value.copy( + toastMessage = "You already have the latest version", + toastType = ToastType.INFO + ) + } + updateStatusManager.reset() } - .onFailure { error -> + }.onFailure { error -> + if (showNoUpdateFeedback) { _uiState.value = _uiState.value.copy( - isCheckingForUpdate = false, - availableAppUpdate = null, - isAppUpdateAvailable = false, - showAppUpdateDialog = force, - appUpdateError = error.message ?: "Update check failed" + toastMessage = error.message ?: "Failed to check for updates", + toastType = ToastType.ERROR ) } + updateStatusManager.reset() + } } } fun dismissAppUpdateDialog() { - _uiState.value = _uiState.value.copy(showAppUpdateDialog = false, showUnknownSourcesDialog = false, appUpdateError = null) + _uiState.value = _uiState.value.copy(showAppUpdateDialog = false, showUnknownSourcesDialog = false) } fun ignoreAppUpdate() { - viewModelScope.launch { - updatePreferences.setIgnoredTag(_uiState.value.availableAppUpdate?.tag) - _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) + val currentStatus = updateStatusManager.status.value + if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { + viewModelScope.launch { + updatePreferences.setIgnoredTag(currentStatus.update.tag) + } } + _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) + updateStatusManager.reset() } fun downloadAppUpdate() { - val update = _uiState.value.availableAppUpdate ?: return - if (!appUpdateRepository.supportsSelfUpdate()) { - _uiState.value = _uiState.value.copy( - toastMessage = "This install is managed by the Play Store.", - toastType = ToastType.INFO - ) - return - } - if (!_uiState.value.isAppUpdateAvailable) { - _uiState.value = _uiState.value.copy( - toastMessage = "You already have the latest version", - toastType = ToastType.INFO, - showAppUpdateDialog = true, - downloadedApkPath = null - ) - return - } + val currentStatus = updateStatusManager.status.value + val update = when (currentStatus) { + is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> currentStatus.update + is com.arflix.tv.updater.UpdateStatus.Failure -> currentStatus.update + else -> return + } ?: return + + if (!appUpdateRepository.supportsSelfUpdate()) return viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isDownloadingAppUpdate = true, - appUpdateDownloadProgress = 0f, - appUpdateError = null, - showAppUpdateDialog = true - ) + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") val dest = File(File(context.cacheDir, "updates"), safeName) + val result = withContext(Dispatchers.IO) { apkDownloader.download(update.assetUrl, dest) { downloaded, total -> val progress = if (total != null && total > 0L) { (downloaded.toFloat() / total.toFloat()).coerceIn(0f, 1f) - } else { - null - } - _uiState.value = _uiState.value.copy(appUpdateDownloadProgress = progress) + } else null + + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(progress, update)) } } - result - .onSuccess { file -> - _uiState.value = _uiState.value.copy( - isDownloadingAppUpdate = false, - appUpdateDownloadProgress = 1f, - downloadedApkPath = file.absolutePath, - appUpdateError = null, - showAppUpdateDialog = true - ) - installAppUpdateOrRequestPermission() - } - .onFailure { error -> - _uiState.value = _uiState.value.copy( - isDownloadingAppUpdate = false, - appUpdateDownloadProgress = null, - downloadedApkPath = null, - appUpdateError = error.message ?: "Download failed", - showAppUpdateDialog = true - ) - } + result.onSuccess { file -> + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.ReadyToInstall(file.absolutePath, update)) + installAppUpdateOrRequestPermission() + }.onFailure { error -> + updateStatusManager.updateStatus( + com.arflix.tv.updater.UpdateStatus.Failure(error.message ?: "Download failed", update) + ) + } } } fun installAppUpdateOrRequestPermission() { - val apkPath = _uiState.value.downloadedApkPath ?: return + val currentStatus = updateStatusManager.status.value + if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return + + val apkPath = if (currentStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) currentStatus.apkPath else return + val update = currentStatus.update val apkFile = File(apkPath) + if (!apkFile.exists()) { - _uiState.value = _uiState.value.copy(appUpdateError = "Downloaded file is missing", showAppUpdateDialog = true) + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Failure("Downloaded file is missing", update)) return } @@ -2657,28 +2644,18 @@ class SettingsViewModel @Inject constructor( return } - // Check for signature conflict before installing val conflictMsg = ApkInstaller.checkSignatureConflict(context, apkFile) if (conflictMsg != null) { - _uiState.value = _uiState.value.copy(appUpdateError = conflictMsg, showAppUpdateDialog = true) + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Failure(conflictMsg, update)) return } ApkInstaller.launchInstall(context, apkFile) - // Mark this release as "installed" so we don't re-show the update after the - // system installer returns the user to the old still-running process. + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Installing(update)) + viewModelScope.launch { - _uiState.value.availableAppUpdate?.tag?.let { tag -> - updatePreferences.setIgnoredTag(tag) - } + updatePreferences.setIgnoredTag(update.tag) } - _uiState.value = _uiState.value.copy( - downloadedApkPath = null, - showAppUpdateDialog = false, - isAppUpdateAvailable = false, - toastMessage = "Installing update...", - toastType = ToastType.INFO - ) } fun openUnknownSourcesSettings() { diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt index 81653754c..ec42ef1c5 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt @@ -7,6 +7,8 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject /** * Handles PackageInstaller session callbacks for the in-app APK updater. @@ -19,8 +21,12 @@ import android.widget.Toast * hangs forever and no install ever happens — which is exactly what was reported in * issues #116, #99, and #75 for versions 1.9.3 through 1.9.73. */ +@AndroidEntryPoint class ApkInstallReceiver : BroadcastReceiver() { + @Inject + lateinit var updateStatusManager: UpdateStatusManager + override fun onReceive(context: Context, intent: Intent) { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999) val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) @@ -48,6 +54,10 @@ class ApkInstallReceiver : BroadcastReceiver() { try { context.startActivity(confirmIntent) + // We are waiting for the user to confirm in the system UI + updateStatusManager.updateStatus( + UpdateStatus.Installing(AppUpdate("", "", "", "", "", "")) // We don't have the full AppUpdate object here, but the status type indicates what's happening + ) } catch (e: Exception) { // Some Android TV forks (particularly Chinese AOSP variants) don't // handle the system confirm intent correctly. Log but don't crash. @@ -58,6 +68,7 @@ class ApkInstallReceiver : BroadcastReceiver() { PackageInstaller.STATUS_SUCCESS -> { Log.i(TAG, "Update installed successfully.") + updateStatusManager.updateStatus(UpdateStatus.Success) // No toast needed — the new APK is installing/replacing the running process. } @@ -78,6 +89,7 @@ class ApkInstallReceiver : BroadcastReceiver() { PackageInstaller.STATUS_FAILURE_STORAGE -> "Not enough storage to install update." else -> message ?: "Update install failed." } + updateStatusManager.updateStatus(UpdateStatus.Failure(userMessage)) showToast(context, userMessage) } diff --git a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt new file mode 100644 index 000000000..e69de29bb From cada4e5d8443f0afcf6afc2d5a0d86c4abbd06f3 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 14 May 2026 14:51:35 +0530 Subject: [PATCH 2/6] feat: Finalize robust app updater implementation - Restore original ARVIO layout and typography to AppUpdateModal while incorporating new UX retry flows - Integrate global UpdateStatusManager across UI layers (HomeScreen, Settings, Modals) - Add proactive red notification badges to TopBar and Sidebar for available updates - Gracefully handle PackageInstaller callback states through ApkInstallReceiver - Remove temporary test code and clean up legacy inline dialog fields --- app/build.gradle.kts | 2 +- .../com/arflix/tv/ui/components/AppTopBar.kt | 1 + .../arflix/tv/ui/components/AppUpdateModal.kt | 665 ++++++------------ .../arflix/tv/ui/screens/home/HomeScreen.kt | 4 +- .../tv/ui/screens/home/HomeViewModel.kt | 1 + .../tv/ui/screens/settings/SettingsScreen.kt | 33 +- .../ui/screens/settings/SettingsViewModel.kt | 4 +- .../arflix/tv/updater/ApkInstallReceiver.kt | 2 +- .../arflix/tv/updater/UpdateStatusManager.kt | 32 + 9 files changed, 271 insertions(+), 473 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e40b13833..344546db1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ android { minSdk = 23 targetSdk = 35 versionCode = 271 - versionName = "1.9.92" + versionName = "1.9.91" buildConfigField("String", "GITHUB_OWNER", "\"ProdigyV21\"") buildConfigField("String", "GITHUB_REPO", "\"ARVIO\"") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index 0761ddedf..64d2a6603 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index 9c4c10cb3..473a8a132 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -1,58 +1,59 @@ package com.arflix.tv.ui.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.NewReleases -import androidx.compose.material.icons.filled.SystemUpdate -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.focusable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.tv.foundation.ExperimentalTvFoundationApi import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Text -import com.arflix.tv.updater.AppUpdate -import com.arflix.tv.updater.UpdateStatus -import com.arflix.tv.ui.skin.ArvioFocusableSurface -import com.arflix.tv.ui.theme.AccentRed +import com.arflix.tv.BuildConfig +import com.arflix.tv.R +import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.ui.theme.ArflixTypography -import com.arflix.tv.ui.theme.BackgroundCard -import com.arflix.tv.ui.theme.SuccessGreen +import com.arflix.tv.ui.theme.BackgroundElevated +import com.arflix.tv.ui.theme.Pink +import com.arflix.tv.ui.theme.TextPrimary +import com.arflix.tv.ui.theme.TextSecondary +import com.arflix.tv.updater.UpdateStatus -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalTvFoundationApi::class) @Composable fun AppUpdateModal( status: UpdateStatus, @@ -61,464 +62,236 @@ fun AppUpdateModal( onDismiss: () -> Unit, onIgnore: () -> Unit ) { - if (status is UpdateStatus.Idle || status is UpdateStatus.Checking || status is UpdateStatus.Success) { - return + val primaryEnabled = status is UpdateStatus.UpdateAvailable || status is UpdateStatus.ReadyToInstall || status is UpdateStatus.Installing + var focusedIndex by remember(primaryEnabled) { mutableIntStateOf(if (primaryEnabled) 2 else 0) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() } Dialog( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.6f)), - contentAlignment = Alignment.Center - ) { - Box( + ModalScrim(onDismiss = onDismiss) { + Column( modifier = Modifier - .width(480.dp) - .clip(RoundedCornerShape(20.dp)) - .background( - Brush.verticalGradient( - colors = listOf( - Color(0xFF262626), - BackgroundCard - ) - ) - ) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.1f), - shape = RoundedCornerShape(20.dp) + .then( + if (LocalDeviceType.current.isTouchDevice()) Modifier.fillMaxWidth(0.92f).widthIn(max = 600.dp) + else Modifier.width(760.dp) ) - .padding(32.dp) - .animateContentSize(animationSpec = tween(300)) + .background(BackgroundElevated, RoundedCornerShape(18.dp)) + .padding(if (LocalDeviceType.current.isTouchDevice()) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Back, Key.Escape -> { onDismiss(); true } + Key.DirectionLeft -> { + focusedIndex = (focusedIndex - 1).coerceAtLeast(0) + true + } + Key.DirectionRight -> { + focusedIndex = (focusedIndex + 1).coerceAtMost(2) + true + } + Key.Enter, Key.DirectionCenter -> { + when (focusedIndex) { + 0 -> onDismiss() + 1 -> onIgnore() + 2 -> if (primaryEnabled) { + when (status) { + is UpdateStatus.UpdateAvailable -> onDownload() + is UpdateStatus.ReadyToInstall, is UpdateStatus.Installing -> onInstall() + else -> {} + } + } + } + true + } + else -> false + } + } ) { + androidx.compose.material3.Text( + text = stringResource(R.string.app_update), + style = ArflixTypography.sectionTitle, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(10.dp)) + + val subtitle = when (status) { + is UpdateStatus.Checking -> "Checking GitHub Releases..." + is UpdateStatus.UpdateAvailable -> "Update available: ${status.update.title} (${status.update.tag})" + is UpdateStatus.Downloading -> "Downloading update..." + is UpdateStatus.ReadyToInstall -> "${status.update.title} is ready to install." + is UpdateStatus.Installing -> "Installing update... Please follow the system prompt." + is UpdateStatus.Failure -> "Update failed." + is UpdateStatus.Success -> "You already have the latest version installed." + is UpdateStatus.Idle -> "No release information available." + } + androidx.compose.material3.Text(subtitle, style = ArflixTypography.body, color = TextSecondary) + + if (status is UpdateStatus.UpdateAvailable) { + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.Text( + text = "Current version ${BuildConfig.VERSION_NAME} -> latest ${status.update.tag}", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.78f) + ) + } else if (status is UpdateStatus.Success) { + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.Text( + text = "Current version ${BuildConfig.VERSION_NAME} is up to date", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.78f) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (status is UpdateStatus.Failure) { + androidx.compose.material3.Text(status.message, style = ArflixTypography.body, color = Pink) + Spacer(modifier = Modifier.height(12.dp)) + } + when (status) { - is UpdateStatus.UpdateAvailable -> { - UpdateAvailableContent( - update = status.update, - onDownload = onDownload, - onDismiss = onDismiss, - onIgnore = onIgnore - ) - } is UpdateStatus.Downloading -> { - DownloadingContent( - update = status.update, - progress = status.progress + LinearProgressIndicator( + progress = status.progress ?: 0f, + modifier = Modifier.fillMaxWidth(), + color = Pink, // Uses ARVIO's Pink accent instead of SuccessGreen + trackColor = Color.White.copy(alpha = 0.08f) + ) + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.Text( + text = status.progress?.let { "${(it * 100).toInt()}%" } ?: "Preparing...", + style = ArflixTypography.caption, + color = TextSecondary ) } is UpdateStatus.ReadyToInstall -> { - ReadyToInstallContent( - update = status.update, - onInstall = onInstall, - onDismiss = onDismiss - ) + androidx.compose.material3.Text("The latest ARVIO update has been downloaded and is ready to install.", style = ArflixTypography.body, color = TextPrimary) } is UpdateStatus.Installing -> { - InstallingContent( - onRetry = onInstall, - onDismiss = onDismiss - ) + androidx.compose.material3.Text("The Android package installer should appear. If it does not, you can try pressing Install again.", style = ArflixTypography.body, color = TextPrimary) } - is UpdateStatus.Failure -> { - FailureContent( - message = status.message, - onRetry = onDownload, // Retry download/check - onDismiss = onDismiss - ) + is UpdateStatus.UpdateAvailable -> { + if (status.update.notes.isNotBlank()) { + androidx.compose.material3.Text( + text = status.update.notes.take(900), + style = ArflixTypography.caption.copy(lineHeight = 18.sp), + color = TextSecondary, + modifier = Modifier.heightIn(max = 260.dp) + ) + } } else -> {} } - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun UpdateAvailableContent( - update: AppUpdate, - onDownload: () -> Unit, - onDismiss: () -> Unit, - onIgnore: () -> Unit -) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(SuccessGreen.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.NewReleases, - contentDescription = null, - tint = SuccessGreen, - modifier = Modifier.size(24.dp) - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = "New Update Available", - style = ArflixTypography.titleLarge, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Text( - text = "Version ${update.title}", - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.6f) - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - if (update.notes.isNotBlank()) { - Text( - text = "What's New:", - style = ArflixTypography.labelLarge, - color = Color.White.copy(alpha = 0.8f), - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(8.dp)) - val notes = update.notes.split("\n").filter { it.isNotBlank() }.take(5) - notes.forEach { note -> - Row(modifier = Modifier.padding(bottom = 6.dp)) { - Text(text = "• ", color = SuccessGreen, fontSize = 14.sp) - Text( - text = note.removePrefix("- ").removePrefix("* ").trim(), - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - fontSize = 14.sp, - lineHeight = 20.sp + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + UpdateActionButton("Close", focusedIndex == 0, onDismiss) + if (status is UpdateStatus.UpdateAvailable || status is UpdateStatus.Failure) { + UpdateActionButton("Ignore", focusedIndex == 1, onIgnore) + } + UpdateActionButton( + label = when (status) { + is UpdateStatus.ReadyToInstall -> "Install" + is UpdateStatus.Installing -> "Retry Install" + is UpdateStatus.UpdateAvailable -> "Download" + else -> "Latest" + }, + isFocused = focusedIndex == 2, + onClick = { + when (status) { + is UpdateStatus.UpdateAvailable -> onDownload() + is UpdateStatus.ReadyToInstall, is UpdateStatus.Installing -> onInstall() + else -> {} + } + }, + highlighted = true, + enabled = primaryEnabled ) } } - if (update.notes.split("\n").size > 5) { - Text( - text = "and more improvements...", - style = ArflixTypography.bodySmall, - color = Color.White.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 12.dp, top = 4.dp) - ) - } - } else { - Text( - text = "Bug fixes and performance improvements.", - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.7f) - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - ModalButton( - text = "Ignore Version", - icon = null, - onClick = onIgnore, - containerColor = Color.Transparent, - contentColor = Color.White.copy(alpha = 0.5f) - ) - Spacer(modifier = Modifier.width(12.dp)) - ModalButton( - text = "Later", - icon = Icons.Filled.Close, - onClick = onDismiss, - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White - ) - Spacer(modifier = Modifier.width(12.dp)) - ModalButton( - text = "Download", - icon = Icons.Filled.Download, - onClick = onDownload, - containerColor = SuccessGreen, - contentColor = Color.White - ) - } - } -} - -@Composable -private fun DownloadingContent(update: AppUpdate, progress: Float?) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Filled.SystemUpdate, - contentDescription = null, - tint = Color.White.copy(alpha = 0.8f), - modifier = Modifier.size(48.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Downloading Update...", - style = ArflixTypography.titleMedium, - color = Color.White, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(24.dp)) - - // Progress Bar - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)) - .background(Color.White.copy(alpha = 0.1f)) - ) { - if (progress != null) { - Box( - modifier = Modifier - .fillMaxWidth(progress) - .height(6.dp) - .clip(RoundedCornerShape(3.dp)) - .background(SuccessGreen) - ) - } else { - // Indeterminate - CircularProgressIndicator( - color = SuccessGreen, - strokeWidth = 2.dp, - modifier = Modifier.size(6.dp) - ) - } } - - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = if (progress != null) "${(progress * 100).toInt()}%" else "Connecting...", - style = ArflixTypography.labelMedium, - color = Color.White.copy(alpha = 0.5f) - ) } } @Composable -private fun ReadyToInstallContent( - update: AppUpdate, - onInstall: () -> Unit, - onDismiss: () -> Unit +private fun ModalScrim( + onDismiss: () -> Unit, + content: @Composable BoxScope.() -> Unit ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + val scrimInteraction = remember { MutableInteractionSource() } + val contentInteraction = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable( + interactionSource = scrimInteraction, + indication = null, + onClick = onDismiss + ), + contentAlignment = Alignment.Center ) { Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(SuccessGreen.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = SuccessGreen, - modifier = Modifier.size(32.dp) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Download Complete", - style = ArflixTypography.titleMedium, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Version ${update.title} is ready to install.", - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.7f) + modifier = Modifier.clickable( + interactionSource = contentInteraction, + indication = null, + onClick = {} + ), + content = content ) - Spacer(modifier = Modifier.height(32.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - ModalButton( - text = "Later", - icon = Icons.Filled.Close, - onClick = onDismiss, - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White - ) - Spacer(modifier = Modifier.width(16.dp)) - ModalButton( - text = "Install Now", - icon = Icons.Filled.SystemUpdate, - onClick = onInstall, - containerColor = SuccessGreen, - contentColor = Color.White - ) - } } } @Composable -private fun InstallingContent( - onRetry: () -> Unit, - onDismiss: () -> Unit +private fun UpdateActionButton( + label: String, + isFocused: Boolean, + onClick: () -> Unit, + highlighted: Boolean = false, + enabled: Boolean = true ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = SuccessGreen, - modifier = Modifier.size(48.dp), - strokeWidth = 3.dp - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Finalizing Installation...", - style = ArflixTypography.titleMedium, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Please follow the system prompt to complete the update.\nThe app will restart once finished.", - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - ModalButton( - text = "Cancel", - icon = Icons.Filled.Close, - onClick = onDismiss, - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White - ) - Spacer(modifier = Modifier.width(16.dp)) - ModalButton( - text = "Retry Prompt", - icon = Icons.Filled.Refresh, - onClick = onRetry, - containerColor = Color.White.copy(alpha = 0.15f), - contentColor = Color.White - ) - } + val background = when { + !enabled -> Color.White.copy(alpha = 0.06f) + highlighted && isFocused -> Pink + isFocused -> Color.White.copy(alpha = 0.16f) + highlighted -> Pink.copy(alpha = 0.18f) + else -> Color.White.copy(alpha = 0.08f) } -} - -@Composable -private fun FailureContent( - message: String, - onRetry: () -> Unit, - onDismiss: () -> Unit -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(AccentRed.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ErrorOutline, - contentDescription = null, - tint = AccentRed, - modifier = Modifier.size(32.dp) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Update Failed", - style = ArflixTypography.titleMedium, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = message, - style = ArflixTypography.bodyMedium, - color = Color.White.copy(alpha = 0.7f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(modifier = Modifier.height(32.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - ModalButton( - text = "Close", - icon = Icons.Filled.Close, - onClick = onDismiss, - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White - ) - Spacer(modifier = Modifier.width(16.dp)) - ModalButton( - text = "Retry", - icon = Icons.Filled.Refresh, - onClick = onRetry, - containerColor = AccentRed, - contentColor = Color.White - ) - } + val textColor = when { + !enabled -> TextSecondary.copy(alpha = 0.6f) + highlighted && isFocused -> Color.Black + highlighted -> Color.White + isFocused -> TextPrimary + else -> TextSecondary } -} -@Composable -private fun ModalButton( - text: String, - icon: ImageVector?, - onClick: () -> Unit, - containerColor: Color, - contentColor: Color -) { - ArvioFocusableSurface( - onClick = onClick, - shape = RoundedCornerShape(12.dp), - color = containerColor, - focusedColor = Color.White, - modifier = Modifier.height(44.dp) + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(background) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - text = text, - style = ArflixTypography.labelLarge, - color = contentColor, - fontWeight = FontWeight.SemiBold - ) - } + androidx.compose.material3.Text( + text = label, + color = textColor, + style = ArflixTypography.button, + fontSize = 13.sp + ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index c59a2c738..0b3e12cb4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -1089,6 +1089,7 @@ fun HomeScreen( profileCount = profileCount, clockFormat = uiState.clockFormat, syncStatus = uiState.syncStatus, + hasUpdateBadge = uiState.hasUpdateBadge, onItemFocusedPrefetch = {}, onNavigateToDetails = onNavigateToDetails, onNavigateToCollection = onNavigateToCollection, @@ -2187,6 +2188,7 @@ private fun HomeInputLayer( profileCount: Int = 1, clockFormat: String = "24h", syncStatus: com.arflix.tv.data.repository.CloudSyncStatus = com.arflix.tv.data.repository.CloudSyncStatus.NOT_SIGNED_IN, + hasUpdateBadge: Boolean = false, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit, onNavigateToCollection: (String) -> Unit, @@ -2517,7 +2519,7 @@ private fun HomeInputLayer( profile = currentProfile, profileCount = profileCount, clockFormat = clockFormat, - hasUpdateBadge = uiState.hasUpdateBadge + hasUpdateBadge = hasUpdateBadge ) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index da8ca5870..b44e7ff4e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1370,6 +1370,7 @@ class HomeViewModel @Inject constructor( delay(if (isLowRamDevice) 15_000L else 10_000L) checkForAppUpdates(silent = true) } + } /** diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 1122bd88b..0645040da 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -911,7 +911,7 @@ fun SettingsScreen( viewModel.forceCloudSyncNow() } 3 -> { - if (uiState.downloadedApkPath != null) { + if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) @@ -1264,10 +1264,7 @@ fun SettingsScreen( isTraktPolling = uiState.isTraktPolling, isForceCloudSyncing = uiState.isForceCloudSyncing, isSelfUpdateSupported = uiState.isSelfUpdateSupported, - isCheckingForUpdate = uiState.isCheckingForUpdate, - isAppUpdateAvailable = uiState.isAppUpdateAvailable, - availableAppUpdate = uiState.availableAppUpdate, - downloadedApkPath = uiState.downloadedApkPath, + updateStatus = uiState.updateStatus, focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, onConnectCloud = { if (isTouchDevice) { @@ -3091,7 +3088,7 @@ private fun MobileSettingsMainPage( icon = Icons.Default.SystemUpdate, title = stringResource(R.string.app_version), subtitle = "V${BuildConfig.VERSION_NAME}", - value = if (uiState.isAppUpdateAvailable) "Update Available" else "Check Updates", + value = if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) "Update Available" else "Check Updates", isFocused = false, showDivider = false, onClick = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } @@ -3555,9 +3552,6 @@ private enum class Zone { SIDEBAR, SECTION, CONTENT } -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun UnknownSourcesModal( @@ -6425,10 +6419,7 @@ private fun AccountsSettings( isTraktPolling: Boolean, isForceCloudSyncing: Boolean, isSelfUpdateSupported: Boolean, - isCheckingForUpdate: Boolean, - isAppUpdateAvailable: Boolean, - availableAppUpdate: com.arflix.tv.updater.AppUpdate?, - downloadedApkPath: String?, + updateStatus: com.arflix.tv.updater.UpdateStatus, focusedIndex: Int, onConnectCloud: () -> Unit, onDisconnectCloud: () -> Unit, @@ -6505,22 +6496,22 @@ private fun AccountsSettings( title = stringResource(R.string.app_update), description = when { !isSelfUpdateSupported -> "This install is managed by the Play Store" - downloadedApkPath != null -> "Latest update downloaded and ready to install" - isCheckingForUpdate -> "Checking GitHub Releases for a newer APK" - isAppUpdateAvailable -> "Update available: ${availableAppUpdate?.title ?: availableAppUpdate?.tag ?: "latest release"}" - availableAppUpdate != null -> "You already have ARVIO v${BuildConfig.VERSION_NAME}" + updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> "Latest update downloaded and ready to install" + updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> "Checking GitHub Releases for a newer APK" + updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> "Update available: ${updateStatus.update.title.ifBlank { updateStatus.update.tag }}" + updateStatus is com.arflix.tv.updater.UpdateStatus.Success -> "You already have the latest ARVIO version" else -> "Check GitHub Releases for the latest ARVIO APK" }, actionLabel = when { !isSelfUpdateSupported -> "PLAY" - downloadedApkPath != null -> "INSTALL" - isCheckingForUpdate -> "CHECKING" - isAppUpdateAvailable -> "UPDATE" + updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall -> "INSTALL" + updateStatus is com.arflix.tv.updater.UpdateStatus.Checking -> "CHECKING" + updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> "UPDATE" else -> "CHECK" }, isFocused = focusedIndex == 3, onClick = { - if (downloadedApkPath != null) onInstallUpdate() else onCheckUpdates() + if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, modifier = Modifier.settingsFocusSlot(3) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index b34c0042f..047e8a2d2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -148,6 +148,7 @@ data class SettingsUiState( val iptvProgressText: String? = null, val iptvProgressPercent: Int = 0, // App updates + val isSelfUpdateSupported: Boolean = true, val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, val showAppUpdateDialog: Boolean = false, val showUnknownSourcesDialog: Boolean = false, @@ -389,9 +390,6 @@ class SettingsViewModel @Inject constructor( showAppUpdateDialog = shouldShowNow ) } - } - } - } } } diff --git a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt index ec42ef1c5..7062e609e 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt @@ -56,7 +56,7 @@ class ApkInstallReceiver : BroadcastReceiver() { context.startActivity(confirmIntent) // We are waiting for the user to confirm in the system UI updateStatusManager.updateStatus( - UpdateStatus.Installing(AppUpdate("", "", "", "", "", "")) // We don't have the full AppUpdate object here, but the status type indicates what's happening + UpdateStatus.Installing(null) // We don't have the full AppUpdate object here, but the status type indicates what's happening ) } catch (e: Exception) { // Some Android TV forks (particularly Chinese AOSP variants) don't diff --git a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt index e69de29bb..1b2ab854b 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt @@ -0,0 +1,32 @@ +package com.arflix.tv.updater + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +sealed class UpdateStatus { + object Idle : UpdateStatus() + object Checking : UpdateStatus() + data class UpdateAvailable(val update: AppUpdate) : UpdateStatus() + data class Downloading(val progress: Float?, val update: AppUpdate) : UpdateStatus() + data class ReadyToInstall(val apkPath: String, val update: AppUpdate) : UpdateStatus() + data class Installing(val update: AppUpdate?) : UpdateStatus() + object Success : UpdateStatus() + data class Failure(val message: String, val update: AppUpdate? = null) : UpdateStatus() +} + +@Singleton +class UpdateStatusManager @Inject constructor() { + private val _status = MutableStateFlow(UpdateStatus.Idle) + val status: StateFlow = _status.asStateFlow() + + fun updateStatus(newStatus: UpdateStatus) { + _status.value = newStatus + } + + fun reset() { + _status.value = UpdateStatus.Idle + } +} From 0bd5969bdcab00e1feab0ea8e28c79202e5f556d Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 14 May 2026 15:01:46 +0530 Subject: [PATCH 3/6] fix: Stop update dialog from repeatedly reopening and dynamically assign buttons - Remove force-open state from HomeViewModel and SettingsViewModel on every updateStatusManager progress emission. - Instead, only auto-open the dialog when first discovering a new un-ignored update. - Refactor AppUpdateModal buttons to use a dynamic ActionButtonConfig list, ensuring the correct labels ('Install', 'Download', 'Retry', 'Hide') appear depending on the exact UX state, and preventing the D-pad from focusing on hidden buttons. --- .../arflix/tv/ui/components/AppUpdateModal.kt | 81 +++++++++++-------- .../tv/ui/screens/home/HomeViewModel.kt | 21 +++-- .../ui/screens/settings/SettingsViewModel.kt | 13 +-- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index 473a8a132..f0b325918 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -53,6 +53,13 @@ import com.arflix.tv.ui.theme.TextPrimary import com.arflix.tv.ui.theme.TextSecondary import com.arflix.tv.updater.UpdateStatus +private data class ActionButtonConfig( + val label: String, + val action: () -> Unit, + val highlighted: Boolean = false, + val enabled: Boolean = true +) + @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalTvFoundationApi::class) @Composable fun AppUpdateModal( @@ -62,8 +69,35 @@ fun AppUpdateModal( onDismiss: () -> Unit, onIgnore: () -> Unit ) { - val primaryEnabled = status is UpdateStatus.UpdateAvailable || status is UpdateStatus.ReadyToInstall || status is UpdateStatus.Installing - var focusedIndex by remember(primaryEnabled) { mutableIntStateOf(if (primaryEnabled) 2 else 0) } + val buttons = remember(status) { + when (status) { + is UpdateStatus.UpdateAvailable -> listOf( + ActionButtonConfig("Close", onDismiss), + ActionButtonConfig("Ignore", onIgnore), + ActionButtonConfig("Download", onDownload, highlighted = true) + ) + is UpdateStatus.ReadyToInstall -> listOf( + ActionButtonConfig("Close", onDismiss), + ActionButtonConfig("Install", onInstall, highlighted = true) + ) + is UpdateStatus.Installing -> listOf( + ActionButtonConfig("Hide", onDismiss), + ActionButtonConfig("Retry Install", onInstall, highlighted = true) + ) + is UpdateStatus.Downloading -> listOf( + ActionButtonConfig("Hide", onDismiss) + ) + is UpdateStatus.Failure -> listOf( + ActionButtonConfig("Close", onDismiss), + ActionButtonConfig("Retry", onDownload, highlighted = true) + ) + else -> listOf( + ActionButtonConfig("Close", onDismiss) + ) + } + } + + var focusedIndex by remember(buttons) { mutableIntStateOf(buttons.lastIndex) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -98,21 +132,11 @@ fun AppUpdateModal( true } Key.DirectionRight -> { - focusedIndex = (focusedIndex + 1).coerceAtMost(2) + focusedIndex = (focusedIndex + 1).coerceAtMost(buttons.lastIndex) true } Key.Enter, Key.DirectionCenter -> { - when (focusedIndex) { - 0 -> onDismiss() - 1 -> onIgnore() - 2 -> if (primaryEnabled) { - when (status) { - is UpdateStatus.UpdateAvailable -> onDownload() - is UpdateStatus.ReadyToInstall, is UpdateStatus.Installing -> onInstall() - else -> {} - } - } - } + buttons.getOrNull(focusedIndex)?.action?.invoke() true } else -> false @@ -198,28 +222,15 @@ fun AppUpdateModal( Spacer(modifier = Modifier.height(24.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - UpdateActionButton("Close", focusedIndex == 0, onDismiss) - if (status is UpdateStatus.UpdateAvailable || status is UpdateStatus.Failure) { - UpdateActionButton("Ignore", focusedIndex == 1, onIgnore) + buttons.forEachIndexed { index, btn -> + UpdateActionButton( + label = btn.label, + isFocused = focusedIndex == index, + onClick = btn.action, + highlighted = btn.highlighted, + enabled = btn.enabled + ) } - UpdateActionButton( - label = when (status) { - is UpdateStatus.ReadyToInstall -> "Install" - is UpdateStatus.Installing -> "Retry Install" - is UpdateStatus.UpdateAvailable -> "Download" - else -> "Latest" - }, - isFocused = focusedIndex == 2, - onClick = { - when (status) { - is UpdateStatus.UpdateAvailable -> onDownload() - is UpdateStatus.ReadyToInstall, is UpdateStatus.Installing -> onInstall() - else -> {} - } - }, - highlighted = true, - enabled = primaryEnabled - ) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index b44e7ff4e..6cd6572a9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1341,27 +1341,32 @@ class HomeViewModel @Inject constructor( } } - // Observe global update status to drive UI viewModelScope.launch { + var previousStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle updateStatusManager.status.collect { status -> - // Dialog is open when we have an update or are downloading/installing/erroring - val showDialog = status !is com.arflix.tv.updater.UpdateStatus.Idle && status !is com.arflix.tv.updater.UpdateStatus.Checking && status !is com.arflix.tv.updater.UpdateStatus.Success val hasBadge = status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable || status is com.arflix.tv.updater.UpdateStatus.ReadyToInstall || status is com.arflix.tv.updater.UpdateStatus.Downloading - // If it's a new update, check if it was ignored - var shouldShowNow = showDialog + var shouldAutoOpen = false + var isIgnored = false if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { val ignoredTag = updatePreferences.ignoredTag.first() if (ignoredTag == status.update.tag) { - shouldShowNow = false // User ignored this version + isIgnored = true } } + // Auto-open only when we first discover a new unignored update + if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable && previousStatus !is com.arflix.tv.updater.UpdateStatus.UpdateAvailable && !isIgnored) { + shouldAutoOpen = true + } + _uiState.value = _uiState.value.copy( updateStatus = status, - showAppUpdateDialog = shouldShowNow, - hasUpdateBadge = hasBadge && shouldShowNow + showAppUpdateDialog = if (shouldAutoOpen) true else _uiState.value.showAppUpdateDialog, + hasUpdateBadge = hasBadge && !isIgnored ) + + previousStatus = status } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 047e8a2d2..8a9eec7e3 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -375,19 +375,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { updateStatusManager.status.collect { status -> - val showDialog = status !is com.arflix.tv.updater.UpdateStatus.Idle && status !is com.arflix.tv.updater.UpdateStatus.Checking && status !is com.arflix.tv.updater.UpdateStatus.Success - var shouldShowNow = showDialog - - if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { - val ignoredTag = updatePreferences.ignoredTag.first() - if (ignoredTag == status.update.tag) { - shouldShowNow = false // User ignored this version - } - } - _uiState.value = _uiState.value.copy( - updateStatus = status, - showAppUpdateDialog = shouldShowNow + updateStatus = status ) } } From 75cb0f98bf5d60ce1de2af8a5338f80e4d3ce630 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 14 May 2026 15:12:55 +0530 Subject: [PATCH 4/6] feat: Implement session-only ignore and cancel download functionality - Replaced persistent DataStore ignores with an in-memory sessionIgnoredTag in UpdateStatusManager. This ensures users are only unprompted during the current session, but will be notified again upon app restart. - Added a cancelDownloadAppUpdate() function to both HomeViewModel and SettingsViewModel. - Updated ApkDownloader and AppUpdateModal to show and handle the 'Cancel' button during active downloads. --- .../arflix/tv/ui/components/AppUpdateModal.kt | 4 +++- .../arflix/tv/ui/screens/home/HomeScreen.kt | 1 + .../tv/ui/screens/home/HomeViewModel.kt | 20 +++++++++++++------ .../tv/ui/screens/settings/SettingsScreen.kt | 1 + .../ui/screens/settings/SettingsViewModel.kt | 17 ++++++++++++---- .../arflix/tv/updater/UpdateStatusManager.kt | 2 ++ 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt index f0b325918..6f9dbef3a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -65,6 +65,7 @@ private data class ActionButtonConfig( fun AppUpdateModal( status: UpdateStatus, onDownload: () -> Unit, + onCancelDownload: () -> Unit, onInstall: () -> Unit, onDismiss: () -> Unit, onIgnore: () -> Unit @@ -85,7 +86,8 @@ fun AppUpdateModal( ActionButtonConfig("Retry Install", onInstall, highlighted = true) ) is UpdateStatus.Downloading -> listOf( - ActionButtonConfig("Hide", onDismiss) + ActionButtonConfig("Hide", onDismiss), + ActionButtonConfig("Cancel", onCancelDownload) ) is UpdateStatus.Failure -> listOf( ActionButtonConfig("Close", onDismiss), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 0b3e12cb4..56b775861 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -1221,6 +1221,7 @@ fun HomeScreen( com.arflix.tv.ui.components.AppUpdateModal( status = uiState.updateStatus, onDownload = { viewModel.downloadAppUpdate() }, + onCancelDownload = { viewModel.cancelDownloadAppUpdate() }, onInstall = { viewModel.installAppUpdateOrRequestPermission() }, onDismiss = { viewModel.dismissAppUpdateDialog() }, onIgnore = { viewModel.ignoreAppUpdate() } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 6cd6572a9..b46e0018c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1349,8 +1349,7 @@ class HomeViewModel @Inject constructor( var shouldAutoOpen = false var isIgnored = false if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { - val ignoredTag = updatePreferences.ignoredTag.first() - if (ignoredTag == status.update.tag) { + if (updateStatusManager.sessionIgnoredTag == status.update.tag) { isIgnored = true } } @@ -3932,6 +3931,8 @@ class HomeViewModel @Inject constructor( } } + private var downloadJob: kotlinx.coroutines.Job? = null + fun downloadAppUpdate() { val currentStatus = updateStatusManager.status.value val update = when (currentStatus) { @@ -3942,7 +3943,7 @@ class HomeViewModel @Inject constructor( if (!appUpdateRepository.supportsSelfUpdate()) return - viewModelScope.launch { + downloadJob = viewModelScope.launch { updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") @@ -3970,6 +3971,15 @@ class HomeViewModel @Inject constructor( } } + fun cancelDownloadAppUpdate() { + downloadJob?.cancel() + downloadJob = null + val currentStatus = updateStatusManager.status.value + if (currentStatus is com.arflix.tv.updater.UpdateStatus.Downloading) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.UpdateAvailable(currentStatus.update)) + } + } + fun installAppUpdateOrRequestPermission() { val currentStatus = updateStatusManager.status.value if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return @@ -4011,9 +4021,7 @@ class HomeViewModel @Inject constructor( fun ignoreAppUpdate() { val currentStatus = updateStatusManager.status.value if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { - viewModelScope.launch { - updatePreferences.setIgnoredTag(currentStatus.update.tag) - } + updateStatusManager.sessionIgnoredTag = currentStatus.update.tag } _uiState.value = _uiState.value.copy(showAppUpdateDialog = false, hasUpdateBadge = false) updateStatusManager.reset() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 0645040da..90e83f08f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1699,6 +1699,7 @@ fun SettingsScreen( com.arflix.tv.ui.components.AppUpdateModal( status = uiState.updateStatus, onDownload = { viewModel.downloadAppUpdate() }, + onCancelDownload = { viewModel.cancelDownloadAppUpdate() }, onInstall = { viewModel.installAppUpdateOrRequestPermission() }, onDismiss = { viewModel.dismissAppUpdateDialog() }, onIgnore = { viewModel.ignoreAppUpdate() } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 8a9eec7e3..dcd251383 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -2568,14 +2568,14 @@ class SettingsViewModel @Inject constructor( fun ignoreAppUpdate() { val currentStatus = updateStatusManager.status.value if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { - viewModelScope.launch { - updatePreferences.setIgnoredTag(currentStatus.update.tag) - } + updateStatusManager.sessionIgnoredTag = currentStatus.update.tag } _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) updateStatusManager.reset() } + private var downloadJob: kotlinx.coroutines.Job? = null + fun downloadAppUpdate() { val currentStatus = updateStatusManager.status.value val update = when (currentStatus) { @@ -2586,7 +2586,7 @@ class SettingsViewModel @Inject constructor( if (!appUpdateRepository.supportsSelfUpdate()) return - viewModelScope.launch { + downloadJob = viewModelScope.launch { updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(0f, update)) val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") @@ -2613,6 +2613,15 @@ class SettingsViewModel @Inject constructor( } } + fun cancelDownloadAppUpdate() { + downloadJob?.cancel() + downloadJob = null + val currentStatus = updateStatusManager.status.value + if (currentStatus is com.arflix.tv.updater.UpdateStatus.Downloading) { + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.UpdateAvailable(currentStatus.update)) + } + } + fun installAppUpdateOrRequestPermission() { val currentStatus = updateStatusManager.status.value if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return diff --git a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt index 1b2ab854b..c75c687a0 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt @@ -22,6 +22,8 @@ class UpdateStatusManager @Inject constructor() { private val _status = MutableStateFlow(UpdateStatus.Idle) val status: StateFlow = _status.asStateFlow() + var sessionIgnoredTag: String? = null + fun updateStatus(newStatus: UpdateStatus) { _status.value = newStatus } From 7f77c375e3c509063dec23e2512adcfb317b6997 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Thu, 14 May 2026 15:14:20 +0530 Subject: [PATCH 5/6] chore: bump version name to 1.9.92 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 344546db1..e40b13833 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ android { minSdk = 23 targetSdk = 35 versionCode = 271 - versionName = "1.9.91" + versionName = "1.9.92" buildConfigField("String", "GITHUB_OWNER", "\"ProdigyV21\"") buildConfigField("String", "GITHUB_REPO", "\"ARVIO\"") From 6c0365f06bbad39a83e94e2f612ea07a62bf2fa1 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Fri, 15 May 2026 13:04:01 +0530 Subject: [PATCH 6/6] feat: Enhance update status management with session tracking and last update context --- .../tv/ui/screens/home/HomeViewModel.kt | 19 +++++---- .../ui/screens/settings/SettingsViewModel.kt | 41 ++++++++++--------- .../arflix/tv/updater/UpdateStatusManager.kt | 15 ++++++- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index b46e0018c..b5c9fc5af 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1345,11 +1345,12 @@ class HomeViewModel @Inject constructor( var previousStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle updateStatusManager.status.collect { status -> val hasBadge = status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable || status is com.arflix.tv.updater.UpdateStatus.ReadyToInstall || status is com.arflix.tv.updater.UpdateStatus.Downloading - + var shouldAutoOpen = false var isIgnored = false if (status is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { - if (updateStatusManager.sessionIgnoredTag == status.update.tag) { + val persistedIgnoredTag = updatePreferences.ignoredTag.first() + if (persistedIgnoredTag == status.update.tag || updateStatusManager.sessionIgnoredTag == status.update.tag) { isIgnored = true } } @@ -1364,7 +1365,7 @@ class HomeViewModel @Inject constructor( showAppUpdateDialog = if (shouldAutoOpen) true else _uiState.value.showAppUpdateDialog, hasUpdateBadge = hasBadge && !isIgnored ) - + previousStatus = status } } @@ -3902,7 +3903,7 @@ class HomeViewModel @Inject constructor( if (!silent) { updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Checking) } - + val result = appUpdateRepository.getLatestUpdate() result.onSuccess { update -> val localVer = appUpdateRepository.getInstalledVersionName() @@ -3948,13 +3949,13 @@ class HomeViewModel @Inject constructor( val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") val dest = java.io.File(java.io.File(context.cacheDir, "updates"), safeName) - + val result = kotlinx.coroutines.withContext(Dispatchers.IO) { apkDownloader.download(update.assetUrl, dest) { downloaded, total -> val progress = if (total != null && total > 0L) { (downloaded.toFloat() / total.toFloat()).coerceIn(0f, 1f) } else null - + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(progress, update)) } } @@ -3983,7 +3984,7 @@ class HomeViewModel @Inject constructor( fun installAppUpdateOrRequestPermission() { val currentStatus = updateStatusManager.status.value if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return - + val apkPath = if (currentStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) currentStatus.apkPath else return val update = currentStatus.update val apkFile = java.io.File(apkPath) @@ -4022,10 +4023,12 @@ class HomeViewModel @Inject constructor( val currentStatus = updateStatusManager.status.value if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { updateStatusManager.sessionIgnoredTag = currentStatus.update.tag + viewModelScope.launch { + updatePreferences.setIgnoredTag(currentStatus.update.tag) + } } _uiState.value = _uiState.value.copy(showAppUpdateDialog = false, hasUpdateBadge = false) updateStatusManager.reset() } } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index dcd251383..826ec46d2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -539,7 +539,7 @@ class SettingsViewModel @Inject constructor( } } } - + private fun observeAddons() { viewModelScope.launch { streamRepository.installedAddons.collect { addons -> @@ -672,7 +672,7 @@ class SettingsViewModel @Inject constructor( } } } - + fun setDefaultSubtitle(language: String) { viewModelScope.launch { // Save locally @@ -1293,7 +1293,7 @@ class SettingsViewModel @Inject constructor( fun cycleQualityFilterPreset() { viewModelScope.launch { val currentPreset = detectQualityFilterPreset(_uiState.value.qualityFilters) - + // Prevent losing custom filters by cycling into a preset if (currentPreset == QualityFilterPreset.CUSTOM) { _uiState.value = _uiState.value.copy( @@ -1302,7 +1302,7 @@ class SettingsViewModel @Inject constructor( ) return@launch } - + val nextPreset = when (currentPreset) { QualityFilterPreset.OFF -> QualityFilterPreset.HD_1080_PLUS QualityFilterPreset.HD_1080_PLUS -> QualityFilterPreset.HD_1080_ONLY @@ -1358,7 +1358,7 @@ class SettingsViewModel @Inject constructor( } // ========== Addon Management ========== - + fun toggleAddon(addonId: String) { viewModelScope.launch { streamRepository.toggleAddon(addonId) @@ -1369,7 +1369,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) } } - + fun addCustomAddon(url: String) { viewModelScope.launch { val result = streamRepository.addCustomAddon(url) @@ -1836,7 +1836,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) } } - + fun removeAddon(addonId: String) { viewModelScope.launch { streamRepository.removeAddon(addonId) @@ -2444,7 +2444,7 @@ class SettingsViewModel @Inject constructor( var restoreResult = withTimeoutOrNull(30_000L) { restoreCloudStateToLocalInternal(silent = true) } ?: CloudRestoreResult.FAILED - + if (restoreResult == CloudRestoreResult.FAILED) { delay(1200) restoreResult = withTimeoutOrNull(30_000L) { @@ -2569,6 +2569,9 @@ class SettingsViewModel @Inject constructor( val currentStatus = updateStatusManager.status.value if (currentStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable) { updateStatusManager.sessionIgnoredTag = currentStatus.update.tag + viewModelScope.launch { + updatePreferences.setIgnoredTag(currentStatus.update.tag) + } } _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) updateStatusManager.reset() @@ -2591,13 +2594,13 @@ class SettingsViewModel @Inject constructor( val safeName = update.assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_") val dest = File(File(context.cacheDir, "updates"), safeName) - + val result = withContext(Dispatchers.IO) { apkDownloader.download(update.assetUrl, dest) { downloaded, total -> val progress = if (total != null && total > 0L) { (downloaded.toFloat() / total.toFloat()).coerceIn(0f, 1f) } else null - + updateStatusManager.updateStatus(com.arflix.tv.updater.UpdateStatus.Downloading(progress, update)) } } @@ -2625,7 +2628,7 @@ class SettingsViewModel @Inject constructor( fun installAppUpdateOrRequestPermission() { val currentStatus = updateStatusManager.status.value if (currentStatus !is com.arflix.tv.updater.UpdateStatus.ReadyToInstall && currentStatus !is com.arflix.tv.updater.UpdateStatus.Failure) return - + val apkPath = if (currentStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) currentStatus.apkPath else return val update = currentStatus.update val apkFile = File(apkPath) @@ -2659,9 +2662,9 @@ class SettingsViewModel @Inject constructor( context.startActivity(intent) } } - + // ========== Trakt Authentication ========== - + fun startTraktAuth() { val current = _uiState.value if (current.isTraktAuthStarting || current.isTraktPolling) return @@ -2723,10 +2726,10 @@ class SettingsViewModel @Inject constructor( traktPollingJob = viewModelScope.launch { val expiresAt = System.currentTimeMillis() + (deviceCode.expiresIn * 1000) var lastFailure: String? = null - + while (System.currentTimeMillis() < expiresAt) { delay(deviceCode.interval * 1000L) - + try { traktRepository.pollForToken(deviceCode.deviceCode) @@ -2767,7 +2770,7 @@ class SettingsViewModel @Inject constructor( // 400 = pending, continue polling } } - + // Expired or failed _uiState.value = _uiState.value.copy( traktCode = null, @@ -2778,7 +2781,7 @@ class SettingsViewModel @Inject constructor( ) } } - + fun cancelTraktAuth() { traktPollingJob?.cancel() _uiState.value = _uiState.value.copy( @@ -2787,7 +2790,7 @@ class SettingsViewModel @Inject constructor( isTraktPolling = false ) } - + fun disconnectTrakt() { viewModelScope.launch { cancelTraktAuth() @@ -2816,7 +2819,7 @@ class SettingsViewModel @Inject constructor( ) } } - + override fun onCleared() { super.onCleared() traktPollingJob?.cancel() diff --git a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt index c75c687a0..f1b1a957b 100644 --- a/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt @@ -23,12 +23,25 @@ class UpdateStatusManager @Inject constructor() { val status: StateFlow = _status.asStateFlow() var sessionIgnoredTag: String? = null + private var lastUpdate: AppUpdate? = null fun updateStatus(newStatus: UpdateStatus) { - _status.value = newStatus + val statusWithContext = when (newStatus) { + is UpdateStatus.UpdateAvailable -> newStatus.also { lastUpdate = it.update } + is UpdateStatus.Downloading -> newStatus.also { lastUpdate = it.update } + is UpdateStatus.ReadyToInstall -> newStatus.also { lastUpdate = it.update } + is UpdateStatus.Installing -> newStatus.also { if (it.update != null) lastUpdate = it.update } + is UpdateStatus.Failure -> { + val update = newStatus.update ?: lastUpdate + if (update != null) newStatus.copy(update = update) else newStatus + } + else -> newStatus + } + _status.value = statusWithContext } fun reset() { + lastUpdate = null _status.value = UpdateStatus.Idle } }