feat(update): notify about app updates on launch (chat banner) (#891)
Fires one silent update check per installed version at app start (UpdateNoticeManager, release builds only) and shows a non-blocking banner on the chat screen when a newer release exists. The About tab adopts the launch result instead of re-pinging GitHub; dismissals return on the next launch via the persisted latest tag. Closes #890 Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ff8370bb8d
commit
daf33dcb4c
14 files changed
+535
-31
No files matched your search
@@ -9,6 +9,7 @@ import coil.decode.ImageDecoderDecoder
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||||
|
|
||||||
class HermesControlApp :
|
class HermesControlApp :
|
||||||
@@ -25,6 +26,9 @@ class HermesControlApp :
|
|||||||
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
// so the tab renders instantly when opened (the usage endpoint is slow on a
|
||||||
// cold backend). Fire-and-forget; never blocks UI startup.
|
// cold backend). Fire-and-forget; never blocks UI startup.
|
||||||
AnalyticsPreloader.preload(this)
|
AnalyticsPreloader.preload(this)
|
||||||
|
// Issue #890: silent once-per-version update check right after launch,
|
||||||
|
// so the chat screen can show an update banner without user interaction.
|
||||||
|
UpdateNoticeManager.checkOnLaunch()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader =
|
override fun newImageLoader(): ImageLoader =
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ data class ServerStoreState(
|
|||||||
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
|
// Null / mismatched with BuildConfig.VERSION_NAME → the About tab runs
|
||||||
// its one-time check again (re-check on app version bump).
|
// its one-time check again (re-check on app version bump).
|
||||||
val updateCheckDoneForVersion: String? = null,
|
val updateCheckDoneForVersion: String? = null,
|
||||||
|
// Latest release tag the silent check (issue #890) last saw. Persisted so
|
||||||
|
// 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,
|
||||||
)
|
)
|
||||||
@@ -648,4 +648,11 @@ object AuthManager {
|
|||||||
fun setUpdateCheckDoneForVersion(version: String) {
|
fun setUpdateCheckDoneForVersion(version: String) {
|
||||||
serverStore.update { it.copy(updateCheckDoneForVersion = version) }
|
serverStore.update { it.copy(updateCheckDoneForVersion = version) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Latest release tag the launch check (issue #890) persisted. */
|
||||||
|
fun getLastKnownLatestTag(): String? = serverStore.getLatestState().lastKnownLatestTag
|
||||||
|
|
||||||
|
fun setLastKnownLatestTag(tag: String) {
|
||||||
|
serverStore.update { it.copy(lastKnownLatestTag = tag) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide holder of the latest update-check result (issue #890). The
|
||||||
|
* launch check ([UpdateNoticeManager]) writes here so the chat banner can
|
||||||
|
* appear without a second network call, and the About tab adopts the same
|
||||||
|
* result so both surfaces agree on what the last check found.
|
||||||
|
*/
|
||||||
|
object AppUpdateCache {
|
||||||
|
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||||
|
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
var dismissed by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun update(state: AppUpdateState) {
|
||||||
|
_state.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
dismissed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: clear state and dismissal between tests. */
|
||||||
|
internal fun reset() {
|
||||||
|
dismissed = false
|
||||||
|
_state.value = AppUpdateState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the in-app updater (issue #867) — shared by the About tab's
|
||||||
|
* [com.m57.hermescontrol.ui.settings.AppUpdateViewModel] and the launch
|
||||||
|
* notice (issue #890) via [AppUpdateCache].
|
||||||
|
*/
|
||||||
|
sealed interface AppUpdateState {
|
||||||
|
/** Nothing shown yet (no check run in this session). */
|
||||||
|
data object Idle : AppUpdateState
|
||||||
|
|
||||||
|
/** A check is in flight. */
|
||||||
|
data object Checking : AppUpdateState
|
||||||
|
|
||||||
|
/** Latest release equals the installed version. */
|
||||||
|
data class UpToDate(val latestTag: String) : AppUpdateState
|
||||||
|
|
||||||
|
/** A newer release with an APK asset exists. */
|
||||||
|
data class UpdateAvailable(
|
||||||
|
val latestTag: String,
|
||||||
|
val apkUrl: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
) : AppUpdateState
|
||||||
|
|
||||||
|
/** APK download in progress; [progress] is 0..1. */
|
||||||
|
data class Downloading(val progress: Float) : AppUpdateState
|
||||||
|
|
||||||
|
/** Download finished and the system package installer was launched. */
|
||||||
|
data class Installing(val latestTag: String) : AppUpdateState
|
||||||
|
|
||||||
|
/** Install-from-unknown-sources not granted for this app yet. */
|
||||||
|
data object NeedsUnknownSourcesPermission : AppUpdateState
|
||||||
|
|
||||||
|
data class Error(val message: String) : AppUpdateState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The release tag a state carries, when it carries one. */
|
||||||
|
fun AppUpdateState.releaseTag(): String? =
|
||||||
|
when (this) {
|
||||||
|
is AppUpdateState.UpToDate -> latestTag
|
||||||
|
is AppUpdateState.UpdateAvailable -> latestTag
|
||||||
|
is AppUpdateState.Installing -> latestTag
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup update check (issue #890): one silent GitHub ping per installed
|
||||||
|
* version, fired right after launch, so the chat screen can show an update
|
||||||
|
* banner without the user ever opening the About tab.
|
||||||
|
*
|
||||||
|
* The result lands in [AppUpdateCache] (consumed by the chat banner and
|
||||||
|
* adopted by the About tab) and the latest tag is persisted via
|
||||||
|
* [AuthManager.setLastKnownLatestTag] so a dismissed banner can return on a
|
||||||
|
* later launch. Failures leave the cache Idle — the About tab still has its
|
||||||
|
* own manual retry path.
|
||||||
|
*/
|
||||||
|
object UpdateNoticeManager {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release-only feature (issue #890): the launch check and chat banner are
|
||||||
|
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
|
||||||
|
* hit the GitHub API or nag about updates. Tests flip this to true.
|
||||||
|
*/
|
||||||
|
var enabled: Boolean = !BuildConfig.DEBUG
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** Run from Application.onCreate, after AuthManager.init. No-op once per version. */
|
||||||
|
fun checkOnLaunch(
|
||||||
|
checker: AppUpdateChecker = AppUpdateChecker(),
|
||||||
|
currentVersion: String = BuildConfig.VERSION_NAME,
|
||||||
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) {
|
||||||
|
if (!enabled) return
|
||||||
|
if (AuthManager.getUpdateCheckDoneForVersion() == currentVersion) return
|
||||||
|
scope.launch(ioDispatcher) {
|
||||||
|
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
checker.fetchLatestRelease()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return@launch
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val info = result ?: return@launch
|
||||||
|
val apk = info.apkAsset ?: return@launch
|
||||||
|
val state =
|
||||||
|
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||||
|
AppUpdateState.UpdateAvailable(
|
||||||
|
latestTag = info.tagName,
|
||||||
|
apkUrl = apk.browserDownloadUrl,
|
||||||
|
sizeBytes = apk.size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
|
}
|
||||||
|
AppUpdateCache.update(state)
|
||||||
|
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,8 +77,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.m57.hermescontrol.HistoryScreen
|
import com.m57.hermescontrol.HistoryScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.SettingsAbout
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
|||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
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.model.components.ModelPickerDialog
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
@@ -518,6 +523,25 @@ 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).
|
||||||
|
val updateNotice by AppUpdateCache.state.collectAsStateWithLifecycle()
|
||||||
|
if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
|
||||||
|
val noticeTag =
|
||||||
|
(updateNotice as? AppUpdateState.UpdateAvailable)?.latestTag
|
||||||
|
?: UpdateNoticeManager.noticeTag()
|
||||||
|
if (noticeTag != null) {
|
||||||
|
UpdateNoticeBanner(
|
||||||
|
latestTag = noticeTag,
|
||||||
|
onUpdate = { NavigationController.navigateTo(SettingsAbout) },
|
||||||
|
onDismiss = { AppUpdateCache.dismiss() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = searchState.isActive,
|
visible = searchState.isActive,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Update
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-screen banner for the launch update check (issue #890): a newer
|
||||||
|
* Hermes Mobile release exists. "Update" jumps into the About-tab install
|
||||||
|
* flow; "Later" dismisses for the session (the banner returns next launch).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateNoticeBanner(
|
||||||
|
latestTag: String,
|
||||||
|
onUpdate: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Update,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_notice_banner_text, latestTag),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onUpdate) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_notice_banner_action),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_dismiss),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.isNewerVersion
|
import com.m57.hermescontrol.data.update.isNewerVersion
|
||||||
|
import com.m57.hermescontrol.data.update.releaseTag
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,36 +22,6 @@ import kotlinx.coroutines.launch
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/** UI state of the About-tab in-app updater (issue #867). */
|
|
||||||
sealed interface AppUpdateState {
|
|
||||||
/** Nothing shown yet (no check run in this session). */
|
|
||||||
data object Idle : AppUpdateState
|
|
||||||
|
|
||||||
/** A check is in flight. */
|
|
||||||
data object Checking : AppUpdateState
|
|
||||||
|
|
||||||
/** Latest release equals the installed version. */
|
|
||||||
data class UpToDate(val latestTag: String) : AppUpdateState
|
|
||||||
|
|
||||||
/** A newer release with an APK asset exists. */
|
|
||||||
data class UpdateAvailable(
|
|
||||||
val latestTag: String,
|
|
||||||
val apkUrl: String,
|
|
||||||
val sizeBytes: Long,
|
|
||||||
) : AppUpdateState
|
|
||||||
|
|
||||||
/** APK download in progress; [progress] is 0..1. */
|
|
||||||
data class Downloading(val progress: Float) : AppUpdateState
|
|
||||||
|
|
||||||
/** Download finished and the system package installer was launched. */
|
|
||||||
data class Installing(val latestTag: String) : AppUpdateState
|
|
||||||
|
|
||||||
/** Install-from-unknown-sources not granted for this app yet. */
|
|
||||||
data object NeedsUnknownSourcesPermission : AppUpdateState
|
|
||||||
|
|
||||||
data class Error(val message: String) : AppUpdateState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the in-app self-update flow (issue #867): check the GitHub
|
* Drives the in-app self-update flow (issue #867): check the GitHub
|
||||||
* releases API, download the release APK, and hand it to the system package
|
* releases API, download the release APK, and hand it to the system package
|
||||||
@@ -76,6 +49,14 @@ class AppUpdateViewModel(
|
|||||||
// on every visit.
|
// on every visit.
|
||||||
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
|
if (AuthManager.getUpdateCheckDoneForVersion() != currentVersion) {
|
||||||
checkForUpdate()
|
checkForUpdate()
|
||||||
|
} 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +96,9 @@ class AppUpdateViewModel(
|
|||||||
} else {
|
} else {
|
||||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||||
}
|
}
|
||||||
|
// Keep the launch notice (issue #890) in sync with manual checks.
|
||||||
|
AppUpdateCache.update(_state.value)
|
||||||
|
_state.value.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.ui.settings.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.ui.settings.InfoRow
|
import com.m57.hermescontrol.ui.settings.InfoRow
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
|
||||||
|
|||||||
@@ -983,6 +983,10 @@
|
|||||||
<string name="action_done">Done</string>
|
<string name="action_done">Done</string>
|
||||||
<string name="action_close">Close</string>
|
<string name="action_close">Close</string>
|
||||||
|
|
||||||
|
<!-- Update notice banner (issue #890) -->
|
||||||
|
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
|
||||||
|
<string name="update_notice_banner_action">Update</string>
|
||||||
|
|
||||||
<!-- Processes Screen (issue #532) -->
|
<!-- Processes Screen (issue #532) -->
|
||||||
<string name="processes_empty_title">No running processes</string>
|
<string name="processes_empty_title">No running processes</string>
|
||||||
<string name="processes_empty_desc">Background processes for this session will appear here.</string>
|
<string name="processes_empty_desc">Background processes for this session will appear here.</string>
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ class AuthManagerTest {
|
|||||||
verify { mockEditor.apply() }
|
verify { mockEditor.apply() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetAndSetLastKnownLatestTag() {
|
||||||
|
AuthManager.ensureDefaultProfile()
|
||||||
|
assertNull(AuthManager.getLastKnownLatestTag())
|
||||||
|
AuthManager.setLastKnownLatestTag("v1.22.0")
|
||||||
|
assertEquals("v1.22.0", AuthManager.getLastKnownLatestTag())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetHostAndPortFromBaseUrl() {
|
fun testGetHostAndPortFromBaseUrl() {
|
||||||
AuthManager.setBaseUrl("https://10.0.0.1:9090/")
|
AuthManager.setBaseUrl("https://10.0.0.1:9090/")
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package com.m57.hermescontrol.data.update
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
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.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch update check (issue #890): the once-per-version guard, the
|
||||||
|
* cache write, and the persisted latest tag.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class UpdateNoticeManagerTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val currentVersion = "1.21.0"
|
||||||
|
|
||||||
|
private fun updateInfo(tag: String = "v1.22.0"): UpdateInfo =
|
||||||
|
UpdateInfo(
|
||||||
|
tagName = tag,
|
||||||
|
assets =
|
||||||
|
listOf(
|
||||||
|
UpdateInfo.Asset(
|
||||||
|
name = "hermes-mobile-v1.22.0.apk",
|
||||||
|
size = 12345678L,
|
||||||
|
browserDownloadUrl = "https://example.com/hermes-mobile-v1.22.0.apk",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
AppUpdateCache.reset()
|
||||||
|
// Unit tests run against the debug variant where the feature is
|
||||||
|
// disabled by default — flip the knob on so the behavior is testable.
|
||||||
|
UpdateNoticeManager.enabled = true
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||||
|
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns null
|
||||||
|
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
UpdateNoticeManager.enabled = false
|
||||||
|
unmockkAll()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_runsOnceWhenNeverChecked() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||||
|
assertEquals(
|
||||||
|
"v1.22.0",
|
||||||
|
(AppUpdateCache.state.value as AppUpdateState.UpdateAvailable).latestTag,
|
||||||
|
)
|
||||||
|
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("v1.22.0") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_skipsWhenAlreadyCheckedForThisVersion() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_upToDateWhenSameVersion() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo(tag = "v1.21.0")
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(AppUpdateState.UpToDate("v1.21.0"), AppUpdateCache.state.value)
|
||||||
|
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("v1.21.0") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_networkFailure_leavesCacheIdle() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
|
// Still marked done so a dead network can't spam the API per launch.
|
||||||
|
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_noRelease_leavesCacheIdle() =
|
||||||
|
runTest {
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns null
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
|
assertFalse(AppUpdateCache.dismissed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── noticeTag (dismissed/restart banner return) ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_prefersFreshCacheResult() {
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable(
|
||||||
|
latestTag = "v1.22.0",
|
||||||
|
apkUrl = "https://example.com/apk",
|
||||||
|
sizeBytes = 1L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns "v0.9.0"
|
||||||
|
|
||||||
|
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
||||||
|
|
||||||
|
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_nullWhenPersistedTagNotNewer() {
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.21.0"
|
||||||
|
|
||||||
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_nullWhenNothingKnown() {
|
||||||
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── debug builds: feature disabled by default (BuildConfig.DEBUG) ───
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkOnLaunch_disabled_makesNoNetworkCallAndLeavesCacheIdle() =
|
||||||
|
runTest {
|
||||||
|
UpdateNoticeManager.enabled = false
|
||||||
|
val checker = mockk<AppUpdateChecker>()
|
||||||
|
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||||
|
|
||||||
|
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
|
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
|
||||||
|
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||||
|
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noticeTag_disabled_returnsNullEvenWhenNewerTagPersisted() {
|
||||||
|
UpdateNoticeManager.enabled = false
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable(
|
||||||
|
latestTag = "v1.22.0",
|
||||||
|
apkUrl = "https://example.com/apk",
|
||||||
|
sizeBytes = 1L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
||||||
|
|
||||||
|
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import android.content.pm.PackageManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||||
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
import com.m57.hermescontrol.data.update.UpdateInfo
|
import com.m57.hermescontrol.data.update.UpdateInfo
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
@@ -63,9 +65,11 @@ class AppUpdateViewModelTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
AppUpdateCache.reset()
|
||||||
mockkObject(AuthManager)
|
mockkObject(AuthManager)
|
||||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||||
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
every { AuthManager.setUpdateCheckDoneForVersion(any()) } returns Unit
|
||||||
|
every { AuthManager.setLastKnownLatestTag(any()) } returns Unit
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||||
@@ -126,6 +130,30 @@ class AppUpdateViewModelTest {
|
|||||||
coVerify(exactly = 1) { checker.fetchLatestRelease() }
|
coVerify(exactly = 1) { checker.fetchLatestRelease() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun init_adoptsLaunchCheckResultWithoutNetwork() =
|
||||||
|
runTest {
|
||||||
|
// Issue #890: the launch check already ran for this version and
|
||||||
|
// found an update — the About tab must adopt it, not ping GitHub.
|
||||||
|
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||||
|
AppUpdateCache.update(
|
||||||
|
AppUpdateState.UpdateAvailable(
|
||||||
|
latestTag = "v1.22.0",
|
||||||
|
apkUrl = "https://example.com/hermes-mobile-v1.22.0.apk",
|
||||||
|
sizeBytes = 12345678L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||||
|
assertEquals(
|
||||||
|
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/hermes-mobile-v1.22.0.apk", 12345678L),
|
||||||
|
vm.state.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manual check ────────────────────────────────────────────────────
|
// ── Manual check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user