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
@@ -13,6 +13,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -45,5 +46,15 @@
|
|||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="com.rtbishop.look4sat" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="updates" path="." />
|
||||||
|
</paths>
|
||||||
@@ -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.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
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.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
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 satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
|
|||||||
+67
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
+1
@@ -31,6 +31,7 @@ interface IMainContainer {
|
|||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
|
|||||||
+12
@@ -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
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" 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<List<Int>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,5 +251,17 @@
|
|||||||
<string name="mutual_open_radar">查看雷达</string>
|
<string name="mutual_open_radar">查看雷达</string>
|
||||||
<string name="mutual_station_a">站点A</string>
|
<string name="mutual_station_a">站点A</string>
|
||||||
<string name="mutual_station_b">站点B</string>
|
<string name="mutual_station_b">站点B</string>
|
||||||
|
<string name="update_check_title">检查更新</string>
|
||||||
|
<string name="update_check_button">检查更新</string>
|
||||||
|
<string name="update_check_current">当前版本:%s</string>
|
||||||
|
<string name="update_check_latest">最新版本:%s</string>
|
||||||
|
<string name="update_check_up_to_date">已是最新版本</string>
|
||||||
|
<string name="update_check_available">发现新版本</string>
|
||||||
|
<string name="update_check_release_notes">更新内容</string>
|
||||||
|
<string name="update_check_download">下载并安装</string>
|
||||||
|
<string name="update_check_downloading">正在下载…</string>
|
||||||
|
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
|
||||||
|
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||||
|
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -286,5 +286,17 @@
|
|||||||
<string name="mutual_open_radar">Open Radar</string>
|
<string name="mutual_open_radar">Open Radar</string>
|
||||||
<string name="mutual_station_a">Station A</string>
|
<string name="mutual_station_a">Station A</string>
|
||||||
<string name="mutual_station_b">Station B</string>
|
<string name="mutual_station_b">Station B</string>
|
||||||
|
<string name="update_check_title">Check for updates</string>
|
||||||
|
<string name="update_check_button">Check for updates</string>
|
||||||
|
<string name="update_check_current">Current version: %s</string>
|
||||||
|
<string name="update_check_latest">Latest version: %s</string>
|
||||||
|
<string name="update_check_up_to_date">You are up to date</string>
|
||||||
|
<string name="update_check_available">New version available</string>
|
||||||
|
<string name="update_check_release_notes">Release notes</string>
|
||||||
|
<string name="update_check_download">Download & install</string>
|
||||||
|
<string name="update_check_downloading">Downloading…</string>
|
||||||
|
<string name="update_check_error">Failed to check for updates. Check your connection.</string>
|
||||||
|
<string name="update_check_download_error">Download failed. Try again.</string>
|
||||||
|
<string name="update_check_no_apk">No APK attached to this release.</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
+30
-3
@@ -82,13 +82,25 @@ import java.util.Locale
|
|||||||
fun SettingsDestination() {
|
fun SettingsDestination() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
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()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
SettingsScreen(uiState, viewModel::onAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
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 dialogs = rememberDialogVisibility()
|
||||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
@@ -128,11 +140,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
DataSourcesDialog(
|
DataSourcesDialog(
|
||||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||||
|
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||||
|
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||||
|
statusCodes = uiState.dataSourcesStatus,
|
||||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||||
onDismiss = { dialogs.dataSources = false },
|
onDismiss = { dialogs.dataSources = false },
|
||||||
onSave = { satUrls, txUrls ->
|
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||||
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
|
val newSettings = DataSourcesSettings(
|
||||||
|
satelliteUrls = satUrls,
|
||||||
|
transceiversUrls = txUrls,
|
||||||
|
satelliteEnabled = satEnabled,
|
||||||
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||||
onAction(SettingsAction.UpdateFromWeb)
|
onAction(SettingsAction.UpdateFromWeb)
|
||||||
}
|
}
|
||||||
@@ -287,6 +307,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
item { CardCredits() }
|
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
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
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.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class PositionSettings(
|
data class PositionSettings(
|
||||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
||||||
@@ -34,6 +36,15 @@ data class DataSettings(
|
|||||||
val timestamp: Long
|
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(
|
data class SettingsState(
|
||||||
val appVersionName: String,
|
val appVersionName: String,
|
||||||
val positionSettings: PositionSettings,
|
val positionSettings: PositionSettings,
|
||||||
@@ -41,7 +52,9 @@ data class SettingsState(
|
|||||||
val otherSettings: OtherSettings,
|
val otherSettings: OtherSettings,
|
||||||
val rcSettings: RCSettings,
|
val rcSettings: RCSettings,
|
||||||
val radioControlSettings: RadioControlSettings,
|
val radioControlSettings: RadioControlSettings,
|
||||||
val dataSourcesSettings: DataSourcesSettings
|
val dataSourcesSettings: DataSourcesSettings,
|
||||||
|
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||||
|
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SettingsAction {
|
sealed interface SettingsAction {
|
||||||
@@ -72,6 +85,11 @@ sealed interface SettingsAction {
|
|||||||
// Data sources
|
// Data sources
|
||||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||||
|
|
||||||
|
// Update checker
|
||||||
|
data object CheckForUpdate : SettingsAction
|
||||||
|
data object DownloadUpdate : SettingsAction
|
||||||
|
data object ConsumeDownloadedApk : SettingsAction
|
||||||
|
|
||||||
// System
|
// System
|
||||||
data class ShowToast(val message: String) : SettingsAction
|
data class ShowToast(val message: String) : SettingsAction
|
||||||
}
|
}
|
||||||
+64
-2
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.settings
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
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.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
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.usecase.IShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
|
private val updateRepo: IUpdateRepository,
|
||||||
|
private val apkFile: File,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -47,7 +53,8 @@ class SettingsViewModel(
|
|||||||
otherSettings = settingsRepo.otherSettings.value,
|
otherSettings = settingsRepo.otherSettings.value,
|
||||||
rcSettings = settingsRepo.rcSettings.value,
|
rcSettings = settingsRepo.rcSettings.value,
|
||||||
radioControlSettings = settingsRepo.radioControlSettings.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) }
|
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.dataSourcesStatus.collect { status ->
|
||||||
|
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.radioControlSettings.collect { settings ->
|
settingsRepo.radioControlSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||||
@@ -125,11 +137,59 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||||
|
// Update checker
|
||||||
|
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||||
|
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||||
|
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
|
||||||
// System
|
// System
|
||||||
is SettingsAction.ShowToast -> showToast(action.message)
|
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
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
private fun setGpsPosition() {
|
||||||
@@ -201,11 +261,13 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
|
updateRepo = container.updateRepo,
|
||||||
|
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||||
showToast = container.provideShowToast()
|
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