diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 2fa0580b..0aaa86e2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -32,4 +32,8 @@ data class ServerStoreState( // a dismissed chat banner can return on a later launch without re-pinging // GitHub (the once-per-version guard skips the check by then). val lastKnownLatestTag: String? = null, + // Timestamp (epoch millis) of the last successful background release check. + val lastUpdateCheckTimestamp: Long = 0L, + // Tag the user explicitly dismissed from the update banner / dialog (e.g. "v1.24.2"). + val dismissedUpdateTag: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 960d2710..f290768e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -655,4 +655,18 @@ object AuthManager { fun setLastKnownLatestTag(tag: String) { serverStore.update { it.copy(lastKnownLatestTag = tag) } } + + /** Timestamp of the last background update check. */ + fun getLastUpdateCheckTimestamp(): Long = serverStore.getLatestState().lastUpdateCheckTimestamp + + fun setLastUpdateCheckTimestamp(timestamp: Long) { + serverStore.update { it.copy(lastUpdateCheckTimestamp = timestamp) } + } + + /** Release tag dismissed by the user. */ + fun getDismissedUpdateTag(): String? = serverStore.getLatestState().dismissedUpdateTag + + fun setDismissedUpdateTag(tag: String?) { + serverStore.update { it.copy(dismissedUpdateTag = tag) } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt index baf1fc6b..d7331790 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateCache.kt @@ -19,12 +19,15 @@ object AppUpdateCache { /** * Session-only dismissal of the chat banner. Snapshot-backed so the chat - * screen recomposes when it flips; dies with the process, so a dismissed - * banner returns on the next launch (the persisted latest tag drives it). + * screen recomposes when it flips; dies with the process. */ var dismissed by mutableStateOf(false) private set + /** Whether the full-screen / in-place update dialog should be visible. */ + var isDialogVisible by mutableStateOf(false) + private set + fun update(state: AppUpdateState) { _state.value = state } @@ -33,9 +36,18 @@ object AppUpdateCache { dismissed = true } + fun showDialog() { + isDialogVisible = true + } + + fun hideDialog() { + isDialogVisible = false + } + /** Test hook: clear state and dismissal between tests. */ internal fun reset() { dismissed = false + isDialogVisible = false _state.value = AppUpdateState.Idle } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index eb491e49..5dcf78b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -17,6 +17,7 @@ import java.io.IOException @Serializable data class UpdateInfo( val tagName: String = "", + val body: String = "", val assets: List = emptyList(), ) { @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt index fa2f806d..a58e1feb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateState.kt @@ -20,6 +20,7 @@ sealed interface AppUpdateState { val latestTag: String, val apkUrl: String, val sizeBytes: Long, + val releaseNotes: String = "", ) : AppUpdateState /** APK download in progress; [progress] is 0..1. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt index 51b53e08..28dc8aae 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/UpdateNoticeManager.kt @@ -31,16 +31,24 @@ object UpdateNoticeManager { var enabled: Boolean = !BuildConfig.DEBUG internal set - /** Run from Application.onCreate, after AuthManager.init. No-op once per version. */ + /** Release check interval (24 hours). */ + const val CHECK_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L + + /** Run from Application.onCreate, after AuthManager.init. Checks at most once every 24h. */ fun checkOnLaunch( checker: AppUpdateChecker = AppUpdateChecker(), currentVersion: String = BuildConfig.VERSION_NAME, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + now: Long = System.currentTimeMillis(), ) { if (!enabled) return - if (AuthManager.getUpdateCheckDoneForVersion() == currentVersion) return + val lastCheck = AuthManager.getLastUpdateCheckTimestamp() + val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion + if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return + scope.launch(ioDispatcher) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) + AuthManager.setLastUpdateCheckTimestamp(now) val result = try { checker.fetchLatestRelease() @@ -57,6 +65,7 @@ object UpdateNoticeManager { latestTag = info.tagName, apkUrl = apk.browserDownloadUrl, sizeBytes = apk.size, + releaseNotes = info.body, ) } else { AppUpdateState.UpToDate(latestTag = info.tagName) @@ -68,15 +77,17 @@ object UpdateNoticeManager { /** * The tag the chat banner should advertise, or null when nothing newer is - * known. Prefers the in-process check result; falls back to the persisted - * latest tag (issue #890) so a banner dismissed on a previous launch — or - * a launch whose check was skipped by the once-per-version guard — still - * comes back. + * known or if the user explicitly dismissed this tag. */ fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? { if (!enabled) return null - (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.let { return it.latestTag } - val persisted = AuthManager.getLastKnownLatestTag() ?: return null - return persisted.takeIf { isNewerVersion(it, currentVersion) } + val dismissed = AuthManager.getDismissedUpdateTag() + val candidateTag = + (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)?.latestTag + ?: AuthManager.getLastKnownLatestTag() + ?: return null + + if (candidateTag == dismissed) return null + return candidateTag.takeIf { isNewerVersion(it, currentVersion) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index dceaf4cb..846b2d06 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle @@ -77,7 +78,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.HistoryScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R -import com.m57.hermescontrol.SettingsAbout import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.update.AppUpdateCache @@ -104,12 +104,13 @@ import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.common.ActionProgressDialog +import com.m57.hermescontrol.ui.common.AppUpdateDialog import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.UpdateNoticeBanner import com.m57.hermescontrol.ui.model.components.ModelPickerDialog +import com.m57.hermescontrol.ui.settings.AppUpdateViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -540,24 +541,58 @@ fun ChatScreen( } // Issue #890: launch update check — non-blocking banner when a - // newer release exists. "Update" jumps to the About-tab install - // flow; "Later" dismisses for the session (it returns next launch - // via the persisted latest tag). Release-only builds - // (UpdateNoticeManager.enabled = !BuildConfig.DEBUG). + // newer release exists. Tapping "Update" opens the in-place dialog. val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle() + val appUpdateViewModel: AppUpdateViewModel = + viewModel { + val app = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + ?: error("Application not available") + AppUpdateViewModel(app) + } + if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { val noticeTag = (updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag ?: UpdateNoticeManager.noticeTag() if (noticeTag != null) { - UpdateNoticeBanner( + com.m57.hermescontrol.ui.common.UpdateNoticeBanner( latestTag = noticeTag, - onUpdate = { NavigationController.navigateTo(SettingsAbout) }, + onUpdate = { AppUpdateCache.showDialog() }, onDismiss = { AppUpdateCache.dismiss() }, ) } } + if (AppUpdateCache.isDialogVisible) { + val context = androidx.compose.ui.platform.LocalContext.current + AppUpdateDialog( + state = updateNotice, + onDismiss = { AppUpdateCache.hideDialog() }, + onStartUpdate = { appUpdateViewModel.startUpdate() }, + onCancelDownload = { appUpdateViewModel.cancelDownload() }, + onNeverAskAgain = { appUpdateViewModel.dismissCurrentUpdate() }, + onOpenSettings = { + val intent = + android.content.Intent( + android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + android.net.Uri.parse("package:${context.packageName}"), + ).apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: Exception) { + context.startActivity( + android.content.Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + }, + ) + } + AnimatedVisibility( visible = searchState.isActive, enter = expandVertically() + fadeIn(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt new file mode 100644 index 00000000..f8a84bad --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt @@ -0,0 +1,271 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.background +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.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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.update.AppUpdateState +import com.m57.hermescontrol.data.update.releaseTag + +@Composable +fun AppUpdateDialog( + state: AppUpdateState, + onDismiss: () -> Unit, + onStartUpdate: () -> Unit, + onCancelDownload: () -> Unit, + onNeverAskAgain: () -> Unit, + onOpenSettings: () -> Unit, +) { + val tag = state.releaseTag().orEmpty() + val available = state as? AppUpdateState.UpdateAvailable + val sizeMb = + available?.let { + val mb = it.sizeBytes / (1024 * 1024.0) + "%.1f MB".format(mb) + } + val releaseNotes = available?.releaseNotes?.trim().orEmpty() + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.SystemUpdate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.update_dialog_title, tag), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (sizeMb != null) { + Text( + text = sizeMb, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.action_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Release notes / content preview + if (releaseNotes.isNotBlank()) { + Text( + text = stringResource(R.string.update_dialog_changelog_header), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(6.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = releaseNotes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + // Progress / Action States + when (state) { + is AppUpdateState.Downloading -> { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val progressPercent = (state.progress * 100).toInt() + Text( + text = + stringResource( + R.string.settings_about_update_downloading, + progressPercent, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { state.progress }, + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onCancelDownload, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.action_cancel)) + } + } + } + + is AppUpdateState.Installing -> { + Text( + text = stringResource(R.string.settings_about_update_installing, tag), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + is AppUpdateState.NeedsUnknownSourcesPermission -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.settings_about_update_allow_sources), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onOpenSettings, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_about_update_open_settings)) + } + } + } + + is AppUpdateState.Error -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = state.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onStartUpdate, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_about_update_retry)) + } + } + } + + else -> { + // UpdateAvailable or Idle + Column(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onStartUpdate, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.update_dialog_action_install)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.update_dialog_action_later)) + } + TextButton(onClick = onNeverAskAgain) { + Text( + text = stringResource(R.string.update_dialog_action_skip), + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt index 614ce011..35e2a3d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt @@ -42,30 +42,25 @@ class AppUpdateViewModel( private val _state = MutableStateFlow(AppUpdateState.Idle) val state: StateFlow = _state.asStateFlow() + private var downloadJob: kotlinx.coroutines.Job? = null + init { - // Silent first-launch check: once per installed version. Marked done - // after the attempt completes (success or failure) so a failed check - // can retry on the next open, but a dead network can't spam the API - // on every visit. - if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) { - checkForUpdate() + val cached = AppUpdateCache.state.value + if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) { + _state.value = cached } else { - // Issue #890: the launch check (UpdateNoticeManager) already ran - // for this version — adopt its result instead of pinging GitHub - // again, so the About tab agrees with the chat banner. - val cached = AppUpdateCache.state.value - if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) { - _state.value = cached - } + checkForUpdate() } } - /** Manual check from the About row. */ + /** Manual check from the About row or dialog. */ fun checkForUpdate() { if (_state.value is AppUpdateState.Checking) return _state.value = AppUpdateState.Checking viewModelScope.launch(ioDispatcher) { + val now = System.currentTimeMillis() AuthManager.setUpdateCheckDoneForVersion(currentVersion) + AuthManager.setLastUpdateCheckTimestamp(now) val result = try { checker.fetchLatestRelease() @@ -92,6 +87,7 @@ class AppUpdateViewModel( latestTag = info.tagName, apkUrl = apk.browserDownloadUrl, sizeBytes = apk.size, + releaseNotes = info.body, ) } else { AppUpdateState.UpToDate(latestTag = info.tagName) @@ -111,17 +107,59 @@ class AppUpdateViewModel( } val dest = File(getApplication().cacheDir, APK_FILE_NAME) _state.value = AppUpdateState.Downloading(0f) - viewModelScope.launch(ioDispatcher) { - val downloaded = - checker.downloadApk(available.apkUrl, dest) { progress -> - _state.value = AppUpdateState.Downloading(progress) + downloadJob?.cancel() + downloadJob = + viewModelScope.launch(ioDispatcher) { + val downloaded = + checker.downloadApk(available.apkUrl, dest) { progress -> + _state.value = AppUpdateState.Downloading(progress) + } + if (!downloaded) { + if (downloadJob?.isCancelled != true) { + _state.value = AppUpdateState.Error(DOWNLOAD_ERROR) + } + return@launch } - if (!downloaded) { - _state.value = AppUpdateState.Error(DOWNLOAD_ERROR) - return@launch + _state.value = AppUpdateState.Installing(available.latestTag) + launchInstaller(dest) + } + } + + /** Cancel in-flight download and return to UpdateAvailable. */ + fun cancelDownload() { + downloadJob?.cancel() + downloadJob = null + val available = AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable + if (available != null) { + _state.value = available + } else { + _state.value = AppUpdateState.Idle + } + } + + /** Dismiss the current update tag so it stops nagging. */ + fun dismissCurrentUpdate() { + val tag = _state.value.releaseTag() + if (tag != null) { + AuthManager.setDismissedUpdateTag(tag) + } + AppUpdateCache.dismiss() + AppUpdateCache.hideDialog() + } + + /** Resume install when returning from Unknown Sources permission screen. */ + fun resumeInstallAfterPermission() { + if (canRequestInstalls()) { + val dest = File(getApplication().cacheDir, APK_FILE_NAME) + val available = + _state.value as? AppUpdateState.UpdateAvailable + ?: AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable + if (dest.exists() && dest.length() > 0 && available != null) { + _state.value = AppUpdateState.Installing(available.latestTag) + launchInstaller(dest) + } else { + startUpdate() } - _state.value = AppUpdateState.Installing(available.latestTag) - launchInstaller(dest) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index a7ffd54f..b97d5f68 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -241,6 +241,22 @@ internal fun SettingsAboutPage( ) { val updateState by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + if (updateState is com.m57.hermescontrol.data.update.AppUpdateState.NeedsUnknownSourcesPermission) { + viewModel.resumeInstallAfterPermission() + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } HermesScaffold( title = { Text(stringResource(R.string.settings_sec_about)) }, navigationIcon = NavIcon.Back(onBack), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e261d36..0ca9b26f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1003,6 +1003,11 @@ Hermes Mobile %1$s is available Update + Update to %1$s + What\'s New + Download & Install + Remind Me Later + Skip this version No running processes diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt index bc394556..090f4269 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt @@ -271,11 +271,11 @@ class PersistentCookieJarTest { jar.useStore("cold-server") } - delay(10) + // Wait for useStore to complete hydration + job.join() val loaded = jar.loadForRequest("http://dash.local/api/status".toHttpUrl()) assertEquals(1, loaded.size) assertEquals("cold-cookie-xyz", loaded[0].value) - job.join() } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt index cd6b8338..9edd89d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/UpdateNoticeManagerTest.kt @@ -58,6 +58,10 @@ class UpdateNoticeManagerTest { every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit every { AuthManager.getLastKnownLatestTag() } returns null every { AuthManager.setLastKnownLatestTag(any()) } returns Unit + every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L + every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit + every { AuthManager.getDismissedUpdateTag() } returns null + every { AuthManager.setDismissedUpdateTag(any()) } returns Unit } @After @@ -86,13 +90,15 @@ class UpdateNoticeManagerTest { } @Test - fun checkOnLaunch_skipsWhenAlreadyCheckedForThisVersion() = + fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() = runTest { + val now = 1000000000000L every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion + every { AuthManager.getLastUpdateCheckTimestamp() } returns now - 1000L // 1 sec ago val checker = mockk() coEvery { checker.fetchLatestRelease() } returns updateInfo() - UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher) + UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now) advanceUntilIdle() coVerify(exactly = 0) { checker.fetchLatestRelease() } @@ -100,6 +106,31 @@ class UpdateNoticeManagerTest { verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) } } + @Test + fun checkOnLaunch_runsWhenCheckedMoreThan24HoursAgo() = + runTest { + val now = 1000000000000L + every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion + val older = now - (UpdateNoticeManager.CHECK_INTERVAL_MS + 1000L) + every { AuthManager.getLastUpdateCheckTimestamp() } returns older + val checker = mockk() + coEvery { checker.fetchLatestRelease() } returns updateInfo() + + UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now) + advanceUntilIdle() + + coVerify(exactly = 1) { checker.fetchLatestRelease() } + assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable) + } + + @Test + fun noticeTag_returnsNullWhenTagDismissedByUser() { + every { AuthManager.getDismissedUpdateTag() } returns "v1.22.0" + every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0" + + assertNull(UpdateNoticeManager.noticeTag(currentVersion)) + } + @Test fun checkOnLaunch_upToDateWhenSameVersion() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt index 8557a0e1..8c9d72be 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -70,6 +71,10 @@ class AppUpdateViewModelTest { every { AuthManager.getUpdateCheckDoneForVersion() } returns null every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit every { AuthManager.setLastKnownLatestTag(any()) } returns Unit + every { AuthManager.getLastUpdateCheckTimestamp() } returns 0L + every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit + every { AuthManager.getDismissedUpdateTag() } returns null + every { AuthManager.setDismissedUpdateTag(any()) } returns Unit app = mockk(relaxed = true) every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir")) @@ -108,15 +113,20 @@ class AppUpdateViewModelTest { } @Test - fun init_skipsCheckWhenAlreadyCheckedForThisVersion() = + fun init_adoptsCachedResultWhenPresent() = runTest { - every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion + AppUpdateCache.update( + AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L), + ) val vm = createViewModel() advanceUntilIdle() coVerify(exactly = 0) { checker.fetchLatestRelease() } - assertEquals(AppUpdateState.Idle, vm.state.value) + assertEquals( + AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/apk", 123L), + vm.state.value, + ) } @Test @@ -285,17 +295,42 @@ class AppUpdateViewModelTest { } @Test - fun startUpdate_ignoredWhenStateIsNotUpdateAvailable() = + fun cancelDownload_cancelsJobAndRestoresAvailableState() = runTest { - every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion - val vm = createViewModel() advanceUntilIdle() - assertEquals(AppUpdateState.Idle, vm.state.value) + + // In-flight download + coEvery { checker.downloadApk(any(), any(), any()) } coAnswers { + kotlinx.coroutines.delay(10000) + true + } vm.startUpdate() + testDispatcher.scheduler.advanceTimeBy(100) + + assertTrue(vm.state.value is AppUpdateState.Downloading) + + vm.cancelDownload() advanceUntilIdle() - coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) } + assertTrue(vm.state.value is AppUpdateState.UpdateAvailable) + } + + @Test + fun dismissCurrentUpdate_persistsDismissedTagAndHidesDialog() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + + AppUpdateCache.showDialog() + assertTrue(AppUpdateCache.isDialogVisible) + + vm.dismissCurrentUpdate() + advanceUntilIdle() + + verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") } + assertTrue(AppUpdateCache.dismissed) + assertFalse(AppUpdateCache.isDialogVisible) } }