diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25895b98..58745973 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..f7882611 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 6030d53e..74eae8cf 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -35,6 +35,7 @@ import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo import com.rtbishop.look4sat.core.data.repository.SensorsRepo import com.rtbishop.look4sat.core.data.repository.SettingsRepo +import com.rtbishop.look4sat.core.data.repository.UpdateRepository import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.usecase.AddToCalendar @@ -79,6 +80,7 @@ class MainContainer(private val context: Context) : IMainContainer { override val satelliteRepo = provideSatelliteRepo() override val databaseRepo = provideDatabaseRepo() override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) } + override val updateRepo by lazy { UpdateRepository(remoteSource) } override val radioTrackingService: IRadioTrackingService by lazy { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt new file mode 100644 index 00000000..c03ed82f --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt @@ -0,0 +1,67 @@ +package com.rtbishop.look4sat.core.data.repository + +import com.rtbishop.look4sat.core.domain.model.LatestRelease +import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository +import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File + +class UpdateRepository( + private val remoteSource: IRemoteSource +) : IUpdateRepository { + + override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) { + val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL) + val stream = result.stream ?: return@withContext null + try { + val json = stream.bufferedReader().use { it.readText() } + parseRelease(json) + } catch (e: Exception) { + println("UpdateRepository parse failure: $e") + null + } + } + + override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) { + if (url.isBlank()) return@withContext false + val result = remoteSource.getNetworkStream(url) + val stream = result.stream ?: return@withContext false + try { + dest.outputStream().use { out -> stream.use { it.copyTo(out) } } + true + } catch (e: Exception) { + println("UpdateRepository download failure: $e") + false + } + } + + private fun parseRelease(json: String): LatestRelease? { + val obj = JSONObject(json) + val tag = obj.optString("tag_name") + if (tag.isBlank()) return null + val assets = obj.optJSONArray("assets") + var apkUrl: String? = null + if (assets != null) { + for (i in 0 until assets.length()) { + val asset = assets.optJSONObject(i) ?: continue + val name = asset.optString("name", "") + if (name.endsWith(".apk")) { + apkUrl = asset.optString("browser_download_url", "").ifBlank { null } + if (apkUrl != null) break + } + } + } + return LatestRelease( + versionTag = tag, + title = obj.optString("name", ""), + body = obj.optString("body", ""), + apkUrl = apkUrl + ) + } + + private companion object { + const val LATEST_RELEASE_URL = "https://api.github.com/repos/atsunatsu/Look4Sat/releases/latest" + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/LatestRelease.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/LatestRelease.kt new file mode 100644 index 00000000..57e85afb --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/LatestRelease.kt @@ -0,0 +1,9 @@ +package com.rtbishop.look4sat.core.domain.model + +/** Latest release info fetched from the fork's GitHub releases API. */ +data class LatestRelease( + val versionTag: String, // e.g. "v4.4.6-ba7opf.6" + val title: String, // release name, may be empty + val body: String, // release notes / update description + val apkUrl: String? // first .apk asset download URL, null if absent +) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 1ce313d0..346c7f3b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -31,6 +31,7 @@ interface IMainContainer { val satelliteRepo: ISatelliteRepo val databaseRepo: IDatabaseRepo val amSatRepo: IAmSatRepository + val updateRepo: IUpdateRepository val radioTrackingService: IRadioTrackingService val mutualPassData: StateFlow fun setMutualPassData(data: MutualPassData) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IUpdateRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IUpdateRepository.kt new file mode 100644 index 00000000..de2fddb2 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IUpdateRepository.kt @@ -0,0 +1,12 @@ +package com.rtbishop.look4sat.core.domain.repository + +import com.rtbishop.look4sat.core.domain.model.LatestRelease +import java.io.File + +interface IUpdateRepository { + /** Fetch the latest release of the fork repo from GitHub; null on failure. */ + suspend fun getLatestRelease(): LatestRelease? + + /** Download the release APK from [url] into [dest]; true on success. */ + suspend fun downloadApk(url: String, dest: File): Boolean +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VersionComparator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VersionComparator.kt new file mode 100644 index 00000000..c3f001f9 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VersionComparator.kt @@ -0,0 +1,44 @@ +package com.rtbishop.look4sat.core.domain.utility + +/** + * Compares two version strings of the form "..[-]" used by the + * BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.7"). + * + * The comparison is done on the numeric version segments first; when the base versions are + * equal, the trailing build number (if any) decides. This lets a "4.4.6-ba7opf.7" release be + * detected as newer than "4.4.6-ba7opf.6" while "4.5.0" trumps any "4.4.x" build. + */ +object VersionComparator { + + /** True when [candidate] is a newer version than [current]. */ + fun isNewer(candidate: String, current: String): Boolean { + val cand = parse(candidate) + val curr = parse(current) + val maxLen = maxOf(cand.first.size, curr.first.size) + for (i in 0 until maxLen) { + val c = cand.first.getOrElse(i) { 0 } + val k = curr.first.getOrElse(i) { 0 } + if (c != k) return c > k + } + return cand.second > curr.second + } + + /** + * Parses a version string into (numeric base segments, trailing build number). + * "v4.4.6-ba7opf.7" -> ([4,4,6], 7); "4.4.6" -> ([4,4,6], 0); "4.4.6-ba7opf" -> ([4,4,6], 0). + */ + fun parse(version: String): Pair, Int> { + val cleaned = version.trim().removePrefix("v") + val dashIndex = cleaned.indexOf('-') + val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned + val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else "" + val base = basePart.split('.').mapNotNull { it.toIntOrNull() } + // The build number is the trailing numeric segment of the suffix (e.g. "ba7opf.7" -> 7). + val build = suffixPart + .split('.') + .lastOrNull() + ?.toIntOrNull() + ?: 0 + return base to build + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VersionComparatorTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VersionComparatorTest.kt new file mode 100644 index 00000000..b3bb3996 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VersionComparatorTest.kt @@ -0,0 +1,48 @@ +package com.rtbishop.look4sat.core.domain + +import com.rtbishop.look4sat.core.domain.utility.VersionComparator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VersionComparatorTest { + + @Test + fun `newer build number wins when base version equal`() { + assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6")) + assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5")) + } + + @Test + fun `newer base version wins regardless of build number`() { + assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99")) + assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99")) + } + + @Test + fun `older versions are not newer`() { + assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6")) + assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1")) + assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6")) + } + + @Test + fun `v prefix is ignored`() { + assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6")) + assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1")) + } + + @Test + fun `non numeric suffix without number treated as zero build`() { + assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf")) + assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1")) + } + + @Test + fun `parse extracts base and build number`() { + assertEquals(listOf(4, 4, 6) to 7, VersionComparator.parse("v4.4.6-ba7opf.7")) + assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6")) + assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6-ba7opf")) + } +} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 348ad004..2ee04103 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -251,5 +251,17 @@ 查看雷达 站点A 站点B + 检查更新 + 检查更新 + 当前版本:%s + 最新版本:%s + 已是最新版本 + 发现新版本 + 更新内容 + 下载并安装 + 正在下载… + 检查更新失败,请检查网络连接。 + 下载失败,请重试。 + 该版本未附带 APK 安装包。 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 0272390b..540683df 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -286,5 +286,17 @@ Open Radar Station A Station B + Check for updates + Check for updates + Current version: %s + Latest version: %s + You are up to date + New version available + Release notes + Download & install + Downloading… + Failed to check for updates. Check your connection. + Download failed. Try again. + No APK attached to this release. diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 85934c71..1427d562 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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() + ) + } } } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 928cf60b..b391fdce 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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 = 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 } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index f43e91f6..bc4fc7ce 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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() ) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/UpdateCheckerScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/UpdateCheckerScreen.kt new file mode 100644 index 00000000..06f975a4 --- /dev/null +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/UpdateCheckerScreen.kt @@ -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 . + */ +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() + ) + } + } + } +}