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..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 @@ -96,6 +97,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 +171,8 @@ fun AppTopBar( // Settings gear icon (no text label) TopBarSettingsGear( isFocused = settingsFocused, - isSelected = settingsSelected + isSelected = settingsSelected, + hasBadge = hasUpdateBadge ) Text( @@ -264,7 +267,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 +311,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..6f9dbef3a --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppUpdateModal.kt @@ -0,0 +1,310 @@ +package com.arflix.tv.ui.components + +import androidx.compose.foundation.background +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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.foundation.focusable +import androidx.compose.ui.graphics.Color +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 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.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 + +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( + status: UpdateStatus, + onDownload: () -> Unit, + onCancelDownload: () -> Unit, + onInstall: () -> Unit, + onDismiss: () -> Unit, + onIgnore: () -> Unit +) { + 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), + ActionButtonConfig("Cancel", onCancelDownload) + ) + 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) { + focusRequester.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = 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(buttons.lastIndex) + true + } + Key.Enter, Key.DirectionCenter -> { + buttons.getOrNull(focusedIndex)?.action?.invoke() + 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.Downloading -> { + 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 -> { + androidx.compose.material3.Text("The latest ARVIO update has been downloaded and is ready to install.", style = ArflixTypography.body, color = TextPrimary) + } + is UpdateStatus.Installing -> { + 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.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 -> {} + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + buttons.forEachIndexed { index, btn -> + UpdateActionButton( + label = btn.label, + isFocused = focusedIndex == index, + onClick = btn.action, + highlighted = btn.highlighted, + enabled = btn.enabled + ) + } + } + } + } + } +} + +@Composable +private fun ModalScrim( + onDismiss: () -> Unit, + content: @Composable BoxScope.() -> Unit +) { + 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.clickable( + interactionSource = contentInteraction, + indication = null, + onClick = {} + ), + content = content + ) + } +} + +@Composable +private fun UpdateActionButton( + label: String, + isFocused: Boolean, + onClick: () -> Unit, + highlighted: Boolean = false, + enabled: Boolean = true +) { + 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) + } + val textColor = when { + !enabled -> TextSecondary.copy(alpha = 0.6f) + highlighted && isFocused -> Color.Black + highlighted -> Color.White + isFocused -> TextPrimary + else -> TextSecondary + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(background) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + 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/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..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 @@ -1089,6 +1089,7 @@ fun HomeScreen( profileCount = profileCount, clockFormat = uiState.clockFormat, syncStatus = uiState.syncStatus, + hasUpdateBadge = uiState.hasUpdateBadge, onItemFocusedPrefetch = {}, onNavigateToDetails = onNavigateToDetails, onNavigateToCollection = onNavigateToCollection, @@ -1214,6 +1215,18 @@ fun HomeScreen( onDismiss = { viewModel.dismissToast() } ) } + + // App Update Modal + if (uiState.showAppUpdateDialog) { + com.arflix.tv.ui.components.AppUpdateModal( + status = uiState.updateStatus, + onDownload = { viewModel.downloadAppUpdate() }, + onCancelDownload = { viewModel.cancelDownloadAppUpdate() }, + onInstall = { viewModel.installAppUpdateOrRequestPermission() }, + onDismiss = { viewModel.dismissAppUpdateDialog() }, + onIgnore = { viewModel.ignoreAppUpdate() } + ) + } } } @@ -2176,6 +2189,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, @@ -2505,7 +2519,8 @@ private fun HomeInputLayer( focusedIndex = focusState.sidebarFocusIndex, profile = currentProfile, profileCount = profileCount, - clockFormat = clockFormat + clockFormat = clockFormat, + 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 f3c2c6e35..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 @@ -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,42 @@ class HomeViewModel @Inject constructor( loadHomeData() } } + + viewModelScope.launch { + 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) { + val persistedIgnoredTag = updatePreferences.ignoredTag.first() + if (persistedIgnoredTag == status.update.tag || updateStatusManager.sessionIgnoredTag == status.update.tag) { + 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 = if (shouldAutoOpen) true else _uiState.value.showAppUpdateDialog, + hasUpdateBadge = hasBadge && !isIgnored + ) + + previousStatus = status + } + } + + // Check for updates shortly after startup + viewModelScope.launch { + delay(if (isLowRamDevice) 15_000L else 10_000L) + checkForAppUpdates(silent = true) + } + } /** @@ -3849,6 +3893,142 @@ 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() + } + } + } + + private var downloadJob: kotlinx.coroutines.Job? = null + + 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 + + downloadJob = 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 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 + + 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) { + 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/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 4ad971dad..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 @@ -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) { @@ -1699,19 +1696,13 @@ 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() } + onCancelDownload = { viewModel.cancelDownloadAppUpdate() }, + onInstall = { viewModel.installAppUpdateOrRequestPermission() }, + onDismiss = { viewModel.dismissAppUpdateDialog() }, + onIgnore = { viewModel.ignoreAppUpdate() } ) } @@ -3098,7 +3089,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) } @@ -3562,160 +3553,6 @@ private enum class Zone { SIDEBAR, SECTION, CONTENT } -@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 private fun UnknownSourcesModal( @@ -6583,10 +6420,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, @@ -6663,22 +6497,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 75209138b..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 @@ -149,15 +149,9 @@ data class SettingsUiState( 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 +213,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 +372,14 @@ class SettingsViewModel @Inject constructor( } } } + + viewModelScope.launch { + updateStatusManager.status.collect { status -> + _uiState.value = _uiState.value.copy( + updateStatus = status + ) + } + } } private fun loadSettings() { @@ -536,7 +539,7 @@ class SettingsViewModel @Inject constructor( } } } - + private fun observeAddons() { viewModelScope.launch { streamRepository.installedAddons.collect { addons -> @@ -669,7 +672,7 @@ class SettingsViewModel @Inject constructor( } } } - + fun setDefaultSubtitle(language: String) { viewModelScope.launch { // Save locally @@ -1290,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( @@ -1299,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 @@ -1355,7 +1358,7 @@ class SettingsViewModel @Inject constructor( } // ========== Addon Management ========== - + fun toggleAddon(addonId: String) { viewModelScope.launch { streamRepository.toggleAddon(addonId) @@ -1366,7 +1369,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) } } - + fun addCustomAddon(url: String) { viewModelScope.launch { val result = streamRepository.addCustomAddon(url) @@ -1833,7 +1836,7 @@ class SettingsViewModel @Inject constructor( syncLocalStateToCloud(silent = true) } } - + fun removeAddon(addonId: String) { viewModelScope.launch { streamRepository.removeAddon(addonId) @@ -2441,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) { @@ -2518,137 +2521,120 @@ 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) { + updateStatusManager.sessionIgnoredTag = currentStatus.update.tag + viewModelScope.launch { + updatePreferences.setIgnoredTag(currentStatus.update.tag) + } } + _uiState.value = _uiState.value.copy(showAppUpdateDialog = false) + updateStatusManager.reset() } + private var downloadJob: kotlinx.coroutines.Job? = null + 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 - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - isDownloadingAppUpdate = true, - appUpdateDownloadProgress = 0f, - appUpdateError = null, - showAppUpdateDialog = true - ) + if (!appUpdateRepository.supportsSelfUpdate()) return + + downloadJob = viewModelScope.launch { + 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 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 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 +2643,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() { @@ -2686,9 +2662,9 @@ class SettingsViewModel @Inject constructor( context.startActivity(intent) } } - + // ========== Trakt Authentication ========== - + fun startTraktAuth() { val current = _uiState.value if (current.isTraktAuthStarting || current.isTraktPolling) return @@ -2750,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) @@ -2794,7 +2770,7 @@ class SettingsViewModel @Inject constructor( // 400 = pending, continue polling } } - + // Expired or failed _uiState.value = _uiState.value.copy( traktCode = null, @@ -2805,7 +2781,7 @@ class SettingsViewModel @Inject constructor( ) } } - + fun cancelTraktAuth() { traktPollingJob?.cancel() _uiState.value = _uiState.value.copy( @@ -2814,7 +2790,7 @@ class SettingsViewModel @Inject constructor( isTraktPolling = false ) } - + fun disconnectTrakt() { viewModelScope.launch { cancelTraktAuth() @@ -2843,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/ApkInstallReceiver.kt b/app/src/main/kotlin/com/arflix/tv/updater/ApkInstallReceiver.kt index 81653754c..7062e609e 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(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 // 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..f1b1a957b --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/updater/UpdateStatusManager.kt @@ -0,0 +1,47 @@ +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() + + var sessionIgnoredTag: String? = null + private var lastUpdate: AppUpdate? = null + + fun updateStatus(newStatus: UpdateStatus) { + 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 + } +}