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()
+ )
+ }
+ }
+ }
+}