Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
a9c3cd0691
commit
f931565c0d
12 files changed
+894
-4
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package com.m57.hermescontrol.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||
import com.m57.hermescontrol.data.update.isNewerVersion
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
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
|
||||
* releases API, download the release APK, and hand it to the system package
|
||||
* installer. The installer launch uses the application context
|
||||
* (FLAG_ACTIVITY_NEW_TASK), so no Activity is required.
|
||||
*
|
||||
* A silent check runs once per installed version (guarded by the
|
||||
* `updateCheckDoneForVersion` app pref) — that's what puts the "Update
|
||||
* available" badge on the About row without the user tapping anything.
|
||||
*/
|
||||
class AppUpdateViewModel(
|
||||
application: Application,
|
||||
private val checker: AppUpdateChecker = AppUpdateChecker(),
|
||||
private val currentVersion: String = BuildConfig.VERSION_NAME,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val installIntentFactory: (Uri) -> Intent = ::buildInstallIntent,
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow<AppUpdateState>(AppUpdateState.Idle)
|
||||
val state: StateFlow<AppUpdateState> = _state.asStateFlow()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual check from the About row. */
|
||||
fun checkForUpdate() {
|
||||
if (_state.value is AppUpdateState.Checking) return
|
||||
_state.value = AppUpdateState.Checking
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
AuthManager.setUpdateCheckDoneForVersion(currentVersion)
|
||||
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(
|
||||
latestTag = info.tagName,
|
||||
apkUrl = apk.browserDownloadUrl,
|
||||
sizeBytes = apk.size,
|
||||
)
|
||||
} else {
|
||||
AppUpdateState.UpToDate(latestTag = info.tagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Download the release APK and launch the system installer. */
|
||||
fun startUpdate() {
|
||||
val available = _state.value as? AppUpdateState.UpdateAvailable ?: return
|
||||
if (!canRequestInstalls()) {
|
||||
_state.value = AppUpdateState.NeedsUnknownSourcesPermission
|
||||
return
|
||||
}
|
||||
val dest = File(getApplication<Application>().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)
|
||||
}
|
||||
if (!downloaded) {
|
||||
_state.value = AppUpdateState.Error(DOWNLOAD_ERROR)
|
||||
return@launch
|
||||
}
|
||||
_state.value = AppUpdateState.Installing(available.latestTag)
|
||||
launchInstaller(dest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canRequestInstalls(): Boolean =
|
||||
try {
|
||||
getApplication<Application>().packageManager.canRequestPackageInstalls()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun launchInstaller(apkFile: File) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile,
|
||||
)
|
||||
context.startActivity(installIntentFactory(uri))
|
||||
} catch (e: Exception) {
|
||||
_state.value = AppUpdateState.Error(INSTALLER_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APK_FILE_NAME = "hermes-update.apk"
|
||||
|
||||
val NETWORK_ERROR = "Network error — check your connection"
|
||||
val GENERIC_CHECK_ERROR = "Couldn't check for updates"
|
||||
val NO_RELEASE_ERROR = "No release found yet"
|
||||
val NO_APK_ERROR = "Release has no APK asset"
|
||||
val DOWNLOAD_ERROR = "Download failed — tap to retry"
|
||||
val INSTALLER_ERROR = "Couldn't open the installer"
|
||||
}
|
||||
}
|
||||
|
||||
/** MIME type of an installable APK (issue #867). */
|
||||
internal const val APK_MIME = "application/vnd.android.package-archive"
|
||||
|
||||
/**
|
||||
* The system-installer intent for a downloaded APK: ACTION_VIEW with the
|
||||
* package-archive mime, a read grant for the FileProvider URI, and
|
||||
* NEW_TASK (launched from the application context).
|
||||
*/
|
||||
internal fun buildInstallIntent(uri: Uri): Intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, APK_MIME)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.m57.hermescontrol.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -19,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -195,8 +199,10 @@ internal fun SettingsBehaviorPage(
|
||||
@Composable
|
||||
internal fun SettingsAboutPage(
|
||||
onBack: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
|
||||
viewModel: AppUpdateViewModel = viewModel(),
|
||||
) {
|
||||
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.settings_sec_about)) },
|
||||
navigationIcon = NavIcon.Back(onBack),
|
||||
@@ -210,7 +216,19 @@ internal fun SettingsAboutPage(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
AboutSection()
|
||||
AboutSection(
|
||||
updateState = updateState,
|
||||
onCheckUpdate = viewModel::checkForUpdate,
|
||||
onStartUpdate = viewModel::startUpdate,
|
||||
onOpenInstallSettings = {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.m57.hermescontrol.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
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
|
||||
@@ -14,11 +20,17 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.settings.AppUpdateState
|
||||
import com.m57.hermescontrol.ui.settings.InfoRow
|
||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
|
||||
@Composable
|
||||
internal fun AboutSection() {
|
||||
internal fun AboutSection(
|
||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||
onCheckUpdate: () -> Unit = {},
|
||||
onStartUpdate: () -> Unit = {},
|
||||
onOpenInstallSettings: () -> Unit = {},
|
||||
) {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -38,6 +50,12 @@ internal fun AboutSection() {
|
||||
label = stringResource(R.string.settings_about_version),
|
||||
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
)
|
||||
UpdateRow(
|
||||
state = updateState,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
InfoRow(
|
||||
label = stringResource(R.string.settings_about_build),
|
||||
value =
|
||||
@@ -64,3 +82,100 @@ internal fun AboutSection() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app update row (issue #867): tap to check GitHub releases; when a newer
|
||||
* release exists, download + install from here. Idle/up-to-date/error states
|
||||
* are tappable to re-check; actionable states show a labeled button.
|
||||
*/
|
||||
@Composable
|
||||
private fun UpdateRow(
|
||||
state: AppUpdateState,
|
||||
onCheckUpdate: () -> Unit,
|
||||
onStartUpdate: () -> Unit,
|
||||
onOpenInstallSettings: () -> Unit,
|
||||
) {
|
||||
val tappable =
|
||||
state is AppUpdateState.Idle ||
|
||||
state is AppUpdateState.UpToDate ||
|
||||
state is AppUpdateState.Error
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(enabled = tappable) { onCheckUpdate() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_update),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
when (state) {
|
||||
is AppUpdateState.Idle -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_check))
|
||||
}
|
||||
|
||||
is AppUpdateState.Checking -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_checking))
|
||||
}
|
||||
|
||||
is AppUpdateState.UpToDate -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_uptodate, state.latestTag))
|
||||
}
|
||||
|
||||
is AppUpdateState.UpdateAvailable -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_available, state.latestTag))
|
||||
TextButton(onClick = onStartUpdate) {
|
||||
Text(stringResource(R.string.settings_about_update_action))
|
||||
}
|
||||
}
|
||||
|
||||
is AppUpdateState.Downloading -> {
|
||||
ValueText(
|
||||
stringResource(
|
||||
R.string.settings_about_update_downloading,
|
||||
(state.progress * 100).toInt(),
|
||||
),
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progress },
|
||||
modifier = Modifier.width(80.dp).padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
is AppUpdateState.Installing -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_installing, state.latestTag))
|
||||
}
|
||||
|
||||
is AppUpdateState.NeedsUnknownSourcesPermission -> {
|
||||
ValueText(stringResource(R.string.settings_about_update_allow_sources))
|
||||
TextButton(onClick = onOpenInstallSettings) {
|
||||
Text(stringResource(R.string.settings_about_update_open_settings))
|
||||
}
|
||||
}
|
||||
|
||||
is AppUpdateState.Error -> {
|
||||
ValueText(state.message)
|
||||
TextButton(onClick = onCheckUpdate) {
|
||||
Text(stringResource(R.string.settings_about_update_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValueText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user