mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat: check for updates page in settings
Add a full-screen update checker accessible from a button at the bottom of the settings page. It queries the fork's GitHub latest release and can download + install the APK in place. - UpdateRepository (core/data): fetch releases/latest via GitHub API, parse tag/description/APK asset, stream APK download to cacheDir - VersionComparator (core/domain): compare 4.4.6-ba7opf.N style tags (base version first, then build number; v-prefix tolerant) + tests - IMainContainer/MainContainer: inject updateRepo - UpdateCheckerScreen (feature/settings): shows current version, checks for updates, displays latest version + release notes, and a Download & install button; FileProvider hands the APK to the system installer - SettingsScreen: bottom entry button, full-screen page toggle - AndroidManifest: REQUEST_INSTALL_PACKAGES permission + FileProvider - i18n strings (en/zh)
This commit is contained in:
1 parent
1904a6b3e6
commit
604c2ec59d
15 files changed
+551
-6
No files matched your search
+30
-3
@@ -82,13 +82,25 @@ import java.util.Locale
|
||||
fun SettingsDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
if (showUpdateChecker) {
|
||||
UpdateCheckerScreen(
|
||||
currentVersion = uiState.appVersionName,
|
||||
state = uiState.updateChecker,
|
||||
onBack = { showUpdateChecker = false },
|
||||
onCheck = { onAction(SettingsAction.CheckForUpdate) },
|
||||
onDownload = { onAction(SettingsAction.DownloadUpdate) },
|
||||
onConsumeApk = { onAction(SettingsAction.ConsumeDownloadedApk) }
|
||||
)
|
||||
return
|
||||
}
|
||||
val dialogs = rememberDialogVisibility()
|
||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
@@ -128,11 +140,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
DataSourcesDialog(
|
||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||
statusCodes = uiState.dataSourcesStatus,
|
||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||
onDismiss = { dialogs.dataSources = false },
|
||||
onSave = { satUrls, txUrls ->
|
||||
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
|
||||
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||
val newSettings = DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
)
|
||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
}
|
||||
@@ -287,6 +307,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
onClick = { showUpdateChecker = true },
|
||||
text = stringResource(R.string.update_check_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -18,10 +18,12 @@
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import java.io.File
|
||||
|
||||
data class PositionSettings(
|
||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
||||
@@ -34,6 +36,15 @@ data class DataSettings(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
data class UpdateCheckerState(
|
||||
val isChecking: Boolean = false,
|
||||
val release: LatestRelease? = null,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isDownloading: Boolean = false,
|
||||
val apkFile: File? = null,
|
||||
val errorResId: Int? = null
|
||||
)
|
||||
|
||||
data class SettingsState(
|
||||
val appVersionName: String,
|
||||
val positionSettings: PositionSettings,
|
||||
@@ -41,7 +52,9 @@ data class SettingsState(
|
||||
val otherSettings: OtherSettings,
|
||||
val rcSettings: RCSettings,
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||
)
|
||||
|
||||
sealed interface SettingsAction {
|
||||
@@ -72,6 +85,11 @@ sealed interface SettingsAction {
|
||||
// Data sources
|
||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
data object DownloadUpdate : SettingsAction
|
||||
data object ConsumeDownloadedApk : SettingsAction
|
||||
|
||||
// System
|
||||
data class ShowToast(val message: String) : SettingsAction
|
||||
}
|
||||
+64
-2
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -24,16 +25,21 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -47,7 +53,8 @@ class SettingsViewModel(
|
||||
otherSettings = settingsRepo.otherSettings.value,
|
||||
rcSettings = settingsRepo.rcSettings.value,
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value
|
||||
)
|
||||
)
|
||||
|
||||
@@ -90,6 +97,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.dataSourcesStatus.collect { status ->
|
||||
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.radioControlSettings.collect { settings ->
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
@@ -125,11 +137,59 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
|
||||
// System
|
||||
is SettingsAction.ShowToast -> showToast(action.message)
|
||||
}
|
||||
}
|
||||
|
||||
// region Update checker
|
||||
|
||||
private fun checkForUpdate() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isChecking = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val release = updateRepo.getLatestRelease()
|
||||
val hasUpdate = release != null &&
|
||||
VersionComparator.isNewer(release.versionTag, settingsRepo.appVersionName)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isChecking = false,
|
||||
release = release,
|
||||
hasUpdate = hasUpdate,
|
||||
errorResId = if (release == null) R.string.update_check_error else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadUpdate() {
|
||||
val url = _uiState.value.updateChecker.release?.apkUrl ?: return
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isDownloading = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val success = updateRepo.downloadApk(url, apkFile)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isDownloading = false,
|
||||
apkFile = if (success) apkFile else null,
|
||||
errorResId = if (success) null else R.string.update_check_download_error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeDownloadedApk() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(apkFile = null)) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Position helpers — consolidated from 3 near-identical functions
|
||||
|
||||
private fun setGpsPosition() {
|
||||
@@ -201,11 +261,13 @@ class SettingsViewModel(
|
||||
|
||||
companion object {
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
|
||||
initializer {
|
||||
SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
}
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
|
||||
@Composable
|
||||
fun UpdateCheckerScreen(
|
||||
currentVersion: String,
|
||||
state: UpdateCheckerState,
|
||||
onBack: () -> Unit,
|
||||
onCheck: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onConsumeApk: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// When the APK finishes downloading, hand it to the system installer.
|
||||
LaunchedEffect(state.apkFile) {
|
||||
val file = state.apkFile ?: return@LaunchedEffect
|
||||
val uri: Uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
onConsumeApk()
|
||||
}
|
||||
|
||||
ScreenColumn(
|
||||
topBar = { isVerticalLayout ->
|
||||
TopBar {
|
||||
IconCard(
|
||||
action = onBack,
|
||||
resId = R.drawable.ic_arrow,
|
||||
modifier = Modifier.rotate(180f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_current, currentVersion),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CardButton(
|
||||
onClick = onCheck,
|
||||
text = stringResource(R.string.update_check_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.isChecking -> StatusRow(showSpinner = true)
|
||||
state.errorResId != null -> StatusRow(showSpinner = false, message = stringResource(state.errorResId))
|
||||
state.release == null -> Unit // not checked yet
|
||||
!state.hasUpdate -> StatusRow(showSpinner = false, message = stringResource(R.string.update_check_up_to_date))
|
||||
else -> ReleaseCard(
|
||||
state = state,
|
||||
onDownload = onDownload
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(showSpinner: Boolean, message: String? = null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (showSpinner) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseCard(state: UpdateCheckerState, onDownload: () -> Unit) {
|
||||
val release = state.release ?: return
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_available),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_latest, release.versionTag.removePrefix("v")),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
if (release.title.isNotBlank() && release.title != release.versionTag) {
|
||||
Text(text = release.title, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (release.body.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.update_check_release_notes),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
Text(
|
||||
text = release.body,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
when {
|
||||
release.apkUrl == null -> Text(
|
||||
text = stringResource(R.string.update_check_no_apk),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
state.isDownloading -> Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.update_check_downloading))
|
||||
}
|
||||
else -> CardButton(
|
||||
onClick = onDownload,
|
||||
text = stringResource(R.string.update_check_download),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user