feat: opt-in release candidate update channel (#1199)
This commit is contained in:
17 files changed
+1071
-60
No files matched your search
@@ -30,6 +30,9 @@ data class ServerStoreState(
|
||||
val showTokensPerSecond: Boolean = true,
|
||||
val showModelProvider: Boolean = false,
|
||||
val keepConnectedInBackground: Boolean = false,
|
||||
// Opt-in update channel: when true the in-app update check also considers
|
||||
// pre-release release-candidate GitHub releases, not just stable ones.
|
||||
val checkReleaseCandidateUpdates: Boolean = false,
|
||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||
val appLanguage: String = "system",
|
||||
|
||||
@@ -873,6 +873,16 @@ object AuthManager {
|
||||
|
||||
// ── In-app update check (issue #867) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the user opted into release-candidate updates. Stable-only by
|
||||
* default; when true the update check also considers pre-release RC tags.
|
||||
*/
|
||||
fun isCheckingReleaseCandidateUpdates(): Boolean = serverStore.getLatestState().checkReleaseCandidateUpdates
|
||||
|
||||
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
|
||||
serverStore.update { it.copy(checkReleaseCandidateUpdates = enabled) }
|
||||
}
|
||||
|
||||
/** App version the silent update check last completed for (null = never). */
|
||||
fun getUpdateCheckDoneForVersion(): String? = serverStore.getLatestState().updateCheckDoneForVersion
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ import java.io.IOException
|
||||
data class UpdateInfo(
|
||||
val tagName: String = "",
|
||||
val body: String = "",
|
||||
/** GitHub release flags: a draft is unpublished, a prerelease is pre-stable. */
|
||||
val draft: Boolean = false,
|
||||
val prerelease: Boolean = false,
|
||||
val assets: List<Asset> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
@@ -61,6 +64,32 @@ fun isNewerVersion(
|
||||
return a.prerelease.size > b.prerelease.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the newest installable release out of a GitHub `releases` list payload.
|
||||
*
|
||||
* Drafts and releases without an APK asset are always ignored. Pre-releases are
|
||||
* only eligible when [includeReleaseCandidates] is set *and* the tag is a
|
||||
* release candidate — alpha/beta/dev pre-releases stay out of the RC channel.
|
||||
*
|
||||
* Selection uses [isNewerVersion] rather than list order because the API orders
|
||||
* releases by publication date, which is not a version oracle.
|
||||
*/
|
||||
fun selectLatestUpdate(
|
||||
releases: List<UpdateInfo>,
|
||||
includeReleaseCandidates: Boolean = false,
|
||||
): UpdateInfo? =
|
||||
releases
|
||||
.filter { !it.draft && it.apkAsset != null }
|
||||
.filter {
|
||||
if (!it.prerelease) {
|
||||
true
|
||||
} else {
|
||||
includeReleaseCandidates && isReleaseCandidateVersion(it.tagName)
|
||||
}
|
||||
}.reduceOrNull { best, candidate ->
|
||||
if (isNewerVersion(candidate.tagName, best.tagName)) candidate else best
|
||||
}
|
||||
|
||||
private data class ParsedVersion(
|
||||
val core: List<Int>,
|
||||
val prerelease: List<String>,
|
||||
@@ -96,6 +125,19 @@ private fun comparePrereleaseIdentifier(
|
||||
return x.length.compareTo(y.length).takeIf { it != 0 } ?: x.compareTo(y)
|
||||
}
|
||||
|
||||
/** `rc`, `rc1`, `RC2` — but never alpha/beta/dev. */
|
||||
private val RC_IDENTIFIER = Regex("^rc[0-9]*$", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* True when [tag] is a release-candidate version such as "v1.25.0-rc.1",
|
||||
* "v1.25-rc1", or the legacy dot form "v1.25.rc.1".
|
||||
*
|
||||
* Only the first pre-release identifier is inspected, so alpha/beta builds are
|
||||
* never treated as release candidates.
|
||||
*/
|
||||
fun isReleaseCandidateVersion(tag: String): Boolean =
|
||||
parseVersion(tag)?.prerelease?.firstOrNull()?.let(RC_IDENTIFIER::matches) == true
|
||||
|
||||
/**
|
||||
* Talks to the GitHub releases API and downloads the release APK (issue
|
||||
* #867). Network methods are `open` so tests can fake them; the pure logic
|
||||
@@ -104,27 +146,44 @@ private fun comparePrereleaseIdentifier(
|
||||
*/
|
||||
open class AppUpdateChecker(
|
||||
private val client: OkHttpClient = OkHttpProvider.base,
|
||||
private val apiBaseUrl: String = "https://api.github.com/repos/Hy4ri/hermes-mobile",
|
||||
) {
|
||||
/**
|
||||
* Fetch the latest release metadata. Returns null when there is no
|
||||
* release yet (404) or the body can't be parsed. Throws [IOException]
|
||||
* on network failure so the caller can surface a friendly error.
|
||||
* Fetch the newest installable release metadata.
|
||||
*
|
||||
* Stable-only by default: the `releases/latest` endpoint deliberately
|
||||
* excludes pre-releases. With [includeReleaseCandidates] the releases list
|
||||
* is scanned instead so a newer release-candidate tag becomes eligible.
|
||||
*
|
||||
* Returns null when there is no usable release (404) or the body can't be
|
||||
* parsed. Throws [IOException] on network failure so the caller can
|
||||
* surface a friendly error.
|
||||
*/
|
||||
open suspend fun fetchLatestRelease(): UpdateInfo? =
|
||||
open suspend fun fetchLatestRelease(includeReleaseCandidates: Boolean = false): UpdateInfo? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("https://api.github.com/repos/Hy4ri/hermes-mobile/releases/latest")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
val body = response.body.string().orEmpty()
|
||||
if (includeReleaseCandidates) {
|
||||
val body = get("$apiBaseUrl/releases?per_page=$RELEASE_LIST_PAGE_SIZE") ?: return@withContext null
|
||||
selectLatestUpdate(parseReleaseList(body).orEmpty(), includeReleaseCandidates = true)
|
||||
} else {
|
||||
val body = get("$apiBaseUrl/releases/latest") ?: return@withContext null
|
||||
parseUpdateInfo(body)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET [url] as JSON text, or null on any non-2xx response. */
|
||||
private fun get(url: String): String? {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
return response.body.string().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a release APK asset to [dest], reporting progress 0..1 via
|
||||
* [onProgress]. True on success; false (with the partial file deleted)
|
||||
@@ -167,6 +226,9 @@ open class AppUpdateChecker(
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_BUFFER_SIZE = 8192
|
||||
|
||||
/** How many recent releases the opt-in RC scan inspects. */
|
||||
const val RELEASE_LIST_PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +237,9 @@ fun parseUpdateInfo(json: String): UpdateInfo? =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<UpdateInfo>(json)
|
||||
}.getOrNull()
|
||||
|
||||
/** Parse a GitHub `releases` list JSON body into [UpdateInfo]s, or null. */
|
||||
fun parseReleaseList(json: String): List<UpdateInfo>? =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<List<UpdateInfo>>(json)
|
||||
}.getOrNull()
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.update
|
||||
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -46,18 +47,28 @@ object UpdateNoticeManager {
|
||||
val versionChanged = AuthManager.getUpdateCheckDoneForVersion() != currentVersion
|
||||
if (!versionChanged && (now - lastCheck < CHECK_INTERVAL_MS)) return
|
||||
|
||||
// Captured once so the check and the notice path agree on the channel.
|
||||
val includeReleaseCandidates = AuthManager.isCheckingReleaseCandidateUpdates()
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||
val result =
|
||||
try {
|
||||
checker.fetchLatestRelease()
|
||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
val info = result ?: return@launch
|
||||
// The user may have switched back to stable while this request was in
|
||||
// flight — an RC result must never be published on the stable channel.
|
||||
if (isReleaseCandidateVersion(info.tagName) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return@launch
|
||||
}
|
||||
val apk = info.apkAsset ?: return@launch
|
||||
val state =
|
||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
@@ -77,7 +88,8 @@ object UpdateNoticeManager {
|
||||
|
||||
/**
|
||||
* The tag the chat banner should advertise, or null when nothing newer is
|
||||
* known or if the user explicitly dismissed this tag.
|
||||
* known, if the user explicitly dismissed this tag, or if it is a
|
||||
* release-candidate tag while the user is on the stable channel.
|
||||
*/
|
||||
fun noticeTag(currentVersion: String = BuildConfig.VERSION_NAME): String? {
|
||||
if (!enabled) return null
|
||||
@@ -88,6 +100,9 @@ object UpdateNoticeManager {
|
||||
?: return null
|
||||
|
||||
if (candidateTag == dismissed) return null
|
||||
if (isReleaseCandidateVersion(candidateTag) && !AuthManager.isCheckingReleaseCandidateUpdates()) {
|
||||
return null
|
||||
}
|
||||
return candidateTag.takeIf { isNewerVersion(it, currentVersion) }
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||
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.isReleaseCandidateVersion
|
||||
import com.m57.hermescontrol.data.update.releaseTag
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -42,15 +45,35 @@ class AppUpdateViewModel(
|
||||
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
||||
|
||||
private val _checkReleaseCandidateUpdates =
|
||||
MutableStateFlow(AuthManager.isCheckingReleaseCandidateUpdates())
|
||||
|
||||
/** Whether the RC update channel is enabled (issue: RC update toggle). */
|
||||
val checkReleaseCandidateUpdates: StateFlow<Boolean> = _checkReleaseCandidateUpdates.asStateFlow()
|
||||
|
||||
private var checkJob: kotlinx.coroutines.Job? = null
|
||||
private var downloadJob: kotlinx.coroutines.Job? = null
|
||||
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
|
||||
|
||||
/**
|
||||
* Bumped by every check. The blocking OkHttp call underneath cannot be
|
||||
* interrupted, so a cancelled check may still be running; results are only
|
||||
* published when this token is unchanged (and the channel still matches).
|
||||
*/
|
||||
private var checkGeneration = 0
|
||||
|
||||
init {
|
||||
val cached = AppUpdateCache.state.value
|
||||
if (cached is AppUpdateState.UpdateAvailable) {
|
||||
// An RC offer cached while the RC channel was on must not be adopted
|
||||
// after the user switched back to stable-only.
|
||||
val blocked =
|
||||
!_checkReleaseCandidateUpdates.value &&
|
||||
cached.releaseTag()?.let(::isReleaseCandidateVersion) == true
|
||||
if (blocked) AppUpdateCache.reset()
|
||||
if (cached is AppUpdateState.UpdateAvailable && !blocked) {
|
||||
lastAvailable = cached
|
||||
_state.value = cached
|
||||
} else if (cached is AppUpdateState.UpToDate) {
|
||||
} else if (cached is AppUpdateState.UpToDate && !blocked) {
|
||||
_state.value = cached
|
||||
} else {
|
||||
checkForUpdate()
|
||||
@@ -60,56 +83,132 @@ class AppUpdateViewModel(
|
||||
/** Manual check from the About row or dialog. */
|
||||
fun checkForUpdate() {
|
||||
if (_state.value is AppUpdateState.Checking) return
|
||||
runCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt in/out of release-candidate updates and immediately re-check on the
|
||||
* newly selected channel. Switching back to stable drops any cached RC
|
||||
* offer, and [updateAvailableForInstall] refuses an RC on the stable channel,
|
||||
* so a stale offer can never be installed.
|
||||
*/
|
||||
fun setCheckReleaseCandidateUpdates(enabled: Boolean) {
|
||||
if (_checkReleaseCandidateUpdates.value == enabled) return
|
||||
AuthManager.setCheckReleaseCandidateUpdates(enabled)
|
||||
_checkReleaseCandidateUpdates.value = enabled
|
||||
if (!enabled) {
|
||||
lastAvailable = null
|
||||
AppUpdateCache.reset()
|
||||
}
|
||||
// A check already in flight captured the previous channel — cancel it and
|
||||
// bump the generation so a late result can never be published.
|
||||
checkJob?.cancel()
|
||||
runCheck()
|
||||
}
|
||||
|
||||
private fun runCheck() {
|
||||
val generation = ++checkGeneration
|
||||
val includeReleaseCandidates = _checkReleaseCandidateUpdates.value
|
||||
_state.value = AppUpdateState.Checking
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val now = System.currentTimeMillis()
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||
val result =
|
||||
try {
|
||||
checker.fetchLatestRelease()
|
||||
} catch (e: IOException) {
|
||||
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
||||
return@launch
|
||||
}
|
||||
val info =
|
||||
result ?: run {
|
||||
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
|
||||
return@launch
|
||||
}
|
||||
val apk =
|
||||
info.apkAsset ?: run {
|
||||
_state.value = AppUpdateState.Error(NO_APK_ERROR)
|
||||
return@launch
|
||||
}
|
||||
_state.value =
|
||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
AppUpdateState
|
||||
.UpdateAvailable(
|
||||
checkJob =
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val now = System.currentTimeMillis()
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
AuthManager.setLastUpdateCheckTimestamp(now)
|
||||
val result =
|
||||
try {
|
||||
checker.fetchLatestRelease(includeReleaseCandidates)
|
||||
} catch (e: CancellationException) {
|
||||
// Cancellation is control flow — it must never be reported
|
||||
// as a failed check.
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
||||
_state.value = AppUpdateState.Error(NETWORK_ERROR)
|
||||
}
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentCheck(generation, includeReleaseCandidates)) {
|
||||
_state.value = AppUpdateState.Error(GENERIC_CHECK_ERROR)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The OkHttp call underneath blocks and cannot be interrupted, so a
|
||||
// newer check — or a channel switch — may have happened while it ran.
|
||||
ensureActive()
|
||||
if (!isCurrentCheck(generation, includeReleaseCandidates)) return@launch
|
||||
|
||||
val info =
|
||||
result ?: run {
|
||||
_state.value = AppUpdateState.Error(NO_RELEASE_ERROR)
|
||||
return@launch
|
||||
}
|
||||
val apk =
|
||||
info.apkAsset ?: run {
|
||||
_state.value = AppUpdateState.Error(NO_APK_ERROR)
|
||||
return@launch
|
||||
}
|
||||
val state =
|
||||
if (isNewerVersion(info.tagName, currentVersion)) {
|
||||
AppUpdateState.UpdateAvailable(
|
||||
latestTag = info.tagName,
|
||||
apkUrl = apk.browserDownloadUrl,
|
||||
sizeBytes = apk.size,
|
||||
releaseNotes = info.body,
|
||||
).also { lastAvailable = it }
|
||||
} else {
|
||||
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) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||
}
|
||||
// Fail closed: a release-candidate result is never published once
|
||||
// the RC channel is off, however it reached this point.
|
||||
if (!isAdmissibleOnCurrentChannel(state.releaseTag())) return@launch
|
||||
if (state is AppUpdateState.UpdateAvailable) lastAvailable = state
|
||||
_state.value = state
|
||||
// Keep the launch notice (issue #890) in sync with manual checks.
|
||||
AppUpdateCache.update(state)
|
||||
state.releaseTag()?.let { AuthManager.setLastKnownLatestTag(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Download the release APK and launch the system installer. */
|
||||
fun startUpdate() {
|
||||
/**
|
||||
* True when [generation] is still the newest check *and* the channel that
|
||||
* check was started for is still the selected one.
|
||||
*/
|
||||
private fun isCurrentCheck(
|
||||
generation: Int,
|
||||
includeReleaseCandidates: Boolean,
|
||||
): Boolean = generation == checkGeneration && includeReleaseCandidates == _checkReleaseCandidateUpdates.value
|
||||
|
||||
/** A release-candidate tag may only be used while the RC channel is on. */
|
||||
private fun isAdmissibleOnCurrentChannel(tag: String?): Boolean =
|
||||
tag == null || !isReleaseCandidateVersion(tag) || _checkReleaseCandidateUpdates.value
|
||||
|
||||
/**
|
||||
* The update this ViewModel may install right now, or null.
|
||||
*
|
||||
* Fails closed: a release-candidate offer is refused while the RC channel is
|
||||
* off, however it reached held state. A refusal drops the stale offer and
|
||||
* re-checks on the current channel instead of dead-ending.
|
||||
*/
|
||||
private fun updateAvailableForInstall(): AppUpdateState.UpdateAvailable? {
|
||||
val available =
|
||||
(_state.value as? AppUpdateState.UpdateAvailable)
|
||||
?: lastAvailable
|
||||
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
||||
?: return
|
||||
?: return null
|
||||
if (!isAdmissibleOnCurrentChannel(available.latestTag)) {
|
||||
lastAvailable = null
|
||||
AppUpdateCache.reset()
|
||||
checkForUpdate()
|
||||
return null
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
/** Download the release APK and launch the system installer. */
|
||||
fun startUpdate() {
|
||||
val available = updateAvailableForInstall() ?: return
|
||||
lastAvailable = available
|
||||
if (!canRequestInstalls()) {
|
||||
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
||||
@@ -166,10 +265,7 @@ class AppUpdateViewModel(
|
||||
fun resumeInstallAfterPermission() {
|
||||
if (canRequestInstalls()) {
|
||||
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
|
||||
val available =
|
||||
(_state.value as? AppUpdateState.UpdateAvailable)
|
||||
?: lastAvailable
|
||||
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
|
||||
val available = updateAvailableForInstall()
|
||||
if (dest.exists() && dest.length() > 0 && available != null) {
|
||||
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||
launchInstaller(dest)
|
||||
|
||||
@@ -262,6 +262,7 @@ internal fun SettingsAboutPage(
|
||||
},
|
||||
) {
|
||||
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val checkReleaseCandidateUpdates by viewModel.checkReleaseCandidateUpdates.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
|
||||
@@ -294,8 +295,10 @@ internal fun SettingsAboutPage(
|
||||
) {
|
||||
AboutSection(
|
||||
updateState = updateState,
|
||||
checkReleaseCandidateUpdates = checkReleaseCandidateUpdates,
|
||||
onCheckUpdate = viewModel::checkForUpdate,
|
||||
onStartUpdate = viewModel::startUpdate,
|
||||
onCheckReleaseCandidateUpdatesChange = viewModel::setCheckReleaseCandidateUpdates,
|
||||
onOpenInstallSettings = {
|
||||
val intent =
|
||||
Intent(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -12,6 +13,8 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -28,8 +31,10 @@ import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
@Composable
|
||||
internal fun AboutSection(
|
||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||
checkReleaseCandidateUpdates: Boolean = false,
|
||||
onCheckUpdate: () -> Unit = {},
|
||||
onStartUpdate: () -> Unit = {},
|
||||
onCheckReleaseCandidateUpdatesChange: (Boolean) -> Unit = {},
|
||||
onOpenInstallSettings: () -> Unit = {},
|
||||
) {
|
||||
SectionCard {
|
||||
@@ -57,6 +62,15 @@ internal fun AboutSection(
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
ReleaseCandidateRow(
|
||||
checked = checkReleaseCandidateUpdates,
|
||||
// Mid-download / mid-install the channel must not flip under the
|
||||
// in-flight APK.
|
||||
enabled =
|
||||
updateState !is AppUpdateState.Downloading &&
|
||||
updateState !is AppUpdateState.Installing,
|
||||
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
||||
)
|
||||
InfoRow(
|
||||
label = stringResource(R.string.settings_about_build),
|
||||
value =
|
||||
@@ -223,3 +237,44 @@ private fun ValueText(text: String) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in switch for pre-release (release candidate) updates. Stable-only is
|
||||
* the default; enabling this makes the update check consider RC tags too.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReleaseCandidateRow(
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_rc_updates_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_rc_updates_desc),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user