mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 |
No files matched your search
+15
-3
@@ -1,7 +1,19 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.convention.applicationPlugin)
|
||||
}
|
||||
|
||||
// Release signing credentials live in local.properties (gitignored) or
|
||||
// environment variables — never hardcode passwords in VCS.
|
||||
val releaseProps = Properties().apply {
|
||||
val propsFile = rootProject.file("local.properties")
|
||||
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
||||
}
|
||||
fun releaseCred(name: String): String =
|
||||
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
||||
|
||||
android {
|
||||
namespace = libs.versions.packageName.get()
|
||||
defaultConfig {
|
||||
@@ -11,9 +23,9 @@ android {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||
storePassword = "look4sat123"
|
||||
keyAlias = "look4sat"
|
||||
keyPassword = "look4sat123"
|
||||
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -45,5 +46,15 @@
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
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>
|
||||
</manifest>
|
||||
@@ -50,6 +50,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
private var startedActivityCount = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (startedActivityCount == 0) {
|
||||
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||
}
|
||||
startedActivityCount += 1
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -91,11 +94,16 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||
openMapRequest += 1
|
||||
}
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
@@ -114,13 +122,19 @@ fun NavRoot(deeplink: String? = null) {
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar,
|
||||
openMapRequest = openMapRequest,
|
||||
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +142,11 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {},
|
||||
openMapRequest: Int = 0,
|
||||
onOpenMapRequestHandled: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
@@ -136,6 +154,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Map)
|
||||
}
|
||||
LaunchedEffect(openMapRequest) {
|
||||
if (openMapRequest > 0) {
|
||||
navigateToMap()
|
||||
onOpenMapRequestHandled()
|
||||
}
|
||||
}
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||
|
||||
|
||||
@@ -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.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
|
||||
@@ -78,7 +79,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
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)
|
||||
|
||||
+71
-25
@@ -9,8 +9,13 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -30,13 +35,19 @@ private data class ApiReport(
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
) : IAmSatRepository {
|
||||
|
||||
private val statusCacheMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var statusCache: SatStatusPage? = null
|
||||
|
||||
@Volatile
|
||||
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheGeneration = 0
|
||||
|
||||
@@ -46,36 +57,63 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
statusCacheMutex.lock()
|
||||
try {
|
||||
if (!forceRefresh) statusCache?.let { return@withContext it }
|
||||
val generation = cacheGeneration
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||
if (!forceRefresh) statusCache?.let { return it }
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
var cachedPage: SatStatusPage? = null
|
||||
val inFlightFetch = statusCacheMutex.withLock {
|
||||
if (!forceRefresh) {
|
||||
val cached = statusCache
|
||||
if (cached != null) {
|
||||
cachedPage = cached
|
||||
return@withLock null
|
||||
}
|
||||
}
|
||||
|
||||
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||
fetchStatusFromRemote(cacheGeneration)
|
||||
}.also { statusFetchInFlight = it }
|
||||
}
|
||||
|
||||
cachedPage?.let { return it }
|
||||
val fetch = inFlightFetch ?: return null
|
||||
return try {
|
||||
fetch.await()
|
||||
} finally {
|
||||
statusCacheMutex.unlock()
|
||||
statusCacheMutex.withLock {
|
||||
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchStatus() {
|
||||
fetchStatus(forceRefresh = false)
|
||||
}
|
||||
|
||||
override fun clearStatusCache() {
|
||||
statusCache = null
|
||||
cacheGeneration += 1
|
||||
statusCache = null
|
||||
statusFetchInFlight = null
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
@@ -161,11 +199,10 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
formatDayLabel(utc)
|
||||
}
|
||||
return names.map { name ->
|
||||
val slots = (0 until 36).map { slotIdx ->
|
||||
@@ -190,6 +227,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDayLabel(calendar: Calendar): String {
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||
} else {
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
|
||||
+28
-12
@@ -67,28 +67,44 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
|
||||
// Keep the raw URL as the status key (matches what the settings UI
|
||||
// displays) while requesting with the normalized URL.
|
||||
val tleUrls = settings.satelliteUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val radioUrls = settings.transceiversUrls
|
||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||
.map { it to normalizeUrl(it) }
|
||||
.distinctBy { it.second }
|
||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||
.filterValues { it.isNotBlank() }
|
||||
.mapValues { normalizeUrl(it.value) }
|
||||
.entries
|
||||
.associate { (type, url) -> url to type }
|
||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// report the HTTP status code of every source (200, 404, ...)
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
||||
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[url] ?: customSourceType
|
||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}
|
||||
}.distinctBy { it.catnum }
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
|
||||
@@ -98,8 +98,13 @@ class SettingsRepo(
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
@@ -396,11 +401,21 @@ class SettingsRepo(
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||
// invariant intact even when a default empty list is used to construct the model.
|
||||
val normalized = settings.copy(
|
||||
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||
)
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = settings
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
@@ -409,15 +424,33 @@ class SettingsRepo(
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
),
|
||||
).migrateSatnogsTleSource(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTransceivers,
|
||||
legacyUrlKey = keyTransceiversUrl
|
||||
)
|
||||
),
|
||||
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||
)
|
||||
|
||||
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||
return preferences.getString(key, null)
|
||||
?.split(separatorComma)
|
||||
?.mapNotNull { it.trim() }
|
||||
?.filter { it == "true" || it == "false" }
|
||||
?.map { it == "true" }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||
return flags + List(urls.size - flags.size) { true }
|
||||
}
|
||||
|
||||
private fun getDataSourceUrls(
|
||||
key: String,
|
||||
defaultUrls: List<String>,
|
||||
@@ -436,6 +469,40 @@ class SettingsRepo(
|
||||
defaultUrls
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||
if (celestrakSatnogsIndex < 0) return this
|
||||
|
||||
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||
|
||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||
|
||||
private fun normalizeSourceUrl(url: String): String {
|
||||
val trimmed = url.trim()
|
||||
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
_dataSourcesStatus.value = status
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -44,20 +45,26 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val code = response.code
|
||||
response.close()
|
||||
return@withContext null
|
||||
return@withContext NetworkResult(code, null)
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it.
|
||||
// Closing the stream returns the connection to OkHttp's pool.
|
||||
response.body.byteStream().buffered()
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
response.close()
|
||||
return@withContext NetworkResult(response.code, null)
|
||||
}
|
||||
NetworkResult(response.code, body.byteStream().buffered())
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -3,6 +3,9 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -57,6 +60,21 @@ class AmSatRepositoryTest {
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val prefetch = async { repository.prefetchStatus() }
|
||||
val pageFetch = async { repository.fetchStatus() }
|
||||
prefetch.await()
|
||||
val page = pageFetch.await()
|
||||
|
||||
assertSame(page, repository.getCachedStatus())
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
@@ -65,20 +83,22 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource : IRemoteSource {
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
catalogRequests += 1
|
||||
return """{"data":[{"name":"AO-7"}]}"""
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
}
|
||||
|
||||
+37
-1
@@ -31,6 +31,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -103,6 +105,27 @@ class DatabaseRepoTest {
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
@@ -113,6 +136,12 @@ class DatabaseRepoTest {
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun jamxTleStream(): InputStream = """
|
||||
JAMX-0825b
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
@@ -121,7 +150,8 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult =
|
||||
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
@@ -225,6 +255,12 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -68,8 +68,13 @@ data class OtherSettings(
|
||||
|
||||
data class DataSourcesSettings(
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>
|
||||
)
|
||||
val transceiversUrls: List<String>,
|
||||
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||
val transceiversEnabled: List<Boolean> = emptyList()
|
||||
) {
|
||||
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||
}
|
||||
|
||||
data class RadioControlSettings(
|
||||
val enabled: Boolean,
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||
|
||||
/** Warm the foreground-session cache without forcing a network reload. */
|
||||
suspend fun prefetchStatus()
|
||||
|
||||
/** Clear the foreground-session status cache. */
|
||||
fun clearStatusCache()
|
||||
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ interface IMainContainer {
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
|
||||
+5
@@ -73,6 +73,11 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||
//endregion
|
||||
|
||||
//region # Data sources status
|
||||
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||
companion object {
|
||||
/** Code used when a request fails before any HTTP response is received. */
|
||||
const val CONNECTION_ERROR = -1
|
||||
}
|
||||
}
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): NetworkResult
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||
|
||||
@@ -19,7 +19,10 @@ package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
val satelliteDataUrls = mapOf(
|
||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||
@@ -35,7 +38,7 @@ object Sources {
|
||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||
@@ -45,7 +48,6 @@ object Sources {
|
||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
|
||||
+41
-12
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
/**
|
||||
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||
* null.
|
||||
*/
|
||||
fun qthToPosition(locator: String): GeoPos? {
|
||||
val trimmedQth = locator.take(6)
|
||||
if (!isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
|
||||
val trimmedQth = locator.trim().uppercase().take(8)
|
||||
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||
val latFirst = (trimmedQth[1].code - 65) * 10
|
||||
val lonSecond = trimmedQth[2].toString().toInt() * 2
|
||||
val latSecond = trimmedQth[3].toString().toInt()
|
||||
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
|
||||
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
|
||||
val longitude = (lonFirst + lonSecond + lonThird).round(4)
|
||||
val latitude = (latFirst + latSecond + latThird).round(4)
|
||||
return GeoPos(latitude, longitude)
|
||||
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||
var longitude = lonFirst + lonSecond + 1.0
|
||||
var latitude = latFirst + latSecond + 0.5
|
||||
if (trimmedQth.length >= 6) {
|
||||
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||
if (trimmedQth.length >= 8) {
|
||||
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
|
||||
longitude += lonExt + 15.0 / 3600.0
|
||||
latitude += latExt + 7.5 / 3600.0
|
||||
}
|
||||
}
|
||||
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||
* square and subsquare letters). Returns null for out-of-range positions.
|
||||
*/
|
||||
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
if (!isValidPosition(latitude, longitude)) return null
|
||||
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||
val lonSecond = ((newLongitude / 2) % 10).toInt()
|
||||
val latSecond = (newLatitude % 10).toInt()
|
||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
||||
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
|
||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
}
|
||||
|
||||
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
}
|
||||
|
||||
private fun isValidLocator(locator: String): Boolean {
|
||||
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
|
||||
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
|
||||
return when (locator.length) {
|
||||
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
|
||||
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
|
||||
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,3 @@
|
||||
/*
|
||||
* 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.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import org.junit.Test
|
||||
|
||||
class QthConverterTest {
|
||||
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
// 8-char locator -> centre of the extended square (higher precision)
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
|
||||
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||
// 4-char locator -> centre of the 2deg x 1deg square
|
||||
result = qthToPosition("JN58")
|
||||
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||
// lowercase input is accepted and normalised
|
||||
result = qthToPosition("ol63pd")
|
||||
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("JN5") == null) // odd length
|
||||
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td")
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
||||
</vector>
|
||||
@@ -154,6 +154,7 @@
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
@@ -250,5 +251,17 @@
|
||||
<string name="mutual_open_radar">查看雷达</string>
|
||||
<string name="mutual_station_a">站点A</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>
|
||||
@@ -188,6 +188,7 @@
|
||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||
|
||||
<string name="prefs_data_sources_title">Custom data sources</string>
|
||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
@@ -285,5 +286,17 @@
|
||||
<string name="mutual_open_radar">Open Radar</string>
|
||||
<string name="mutual_station_a">Station A</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>
|
||||
+31
-86
@@ -26,6 +26,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -34,7 +37,6 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class MutualUiState(
|
||||
@@ -56,7 +58,10 @@ data class MutualUiState(
|
||||
|
||||
class MutualViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
// Injected so unit tests can run the pass computation on a test dispatcher;
|
||||
// production callers keep the CPU-bound pool default.
|
||||
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
@@ -66,7 +71,7 @@ class MutualViewModel(
|
||||
// Pre-fill station A with the user's current station position (as grid),
|
||||
// and default min elevation to the same value used by the main radar passes
|
||||
val pos = settingsRepo.stationPosition.value
|
||||
val grid = latLonToGrid(pos.latitude, pos.longitude)
|
||||
val grid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||
_uiState.update { it.copy(
|
||||
stationAGrid = grid,
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -81,7 +86,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationALon.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationAGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,15 +97,18 @@ class MutualViewModel(
|
||||
val lat = _uiState.value.stationALat.toDoubleOrNull()
|
||||
val lon = value.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationAGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationAGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationAGrid(value: String) {
|
||||
val old = _uiState.value.stationAGrid
|
||||
_uiState.update { it.copy(stationAGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationAGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
@@ -112,7 +122,9 @@ class MutualViewModel(
|
||||
val lat = value.toDoubleOrNull()
|
||||
val lon = _uiState.value.stationBLon.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationBGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +133,18 @@ class MutualViewModel(
|
||||
val lat = _uiState.value.stationBLat.toDoubleOrNull()
|
||||
val lon = value.toDoubleOrNull()
|
||||
if (lat != null && lon != null) {
|
||||
_uiState.update { it.copy(stationBGrid = latLonToGrid(lat, lon)) }
|
||||
positionToQth(lat, lon)?.let { grid ->
|
||||
_uiState.update { it.copy(stationBGrid = grid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationBGrid(value: String) {
|
||||
val old = _uiState.value.stationBGrid
|
||||
_uiState.update { it.copy(stationBGrid = value) }
|
||||
if (value.trim().uppercase() == old.trim().uppercase()) return
|
||||
val pos = gridToLatLon(value.trim().uppercase())
|
||||
val newGrid = value.trim().uppercase()
|
||||
_uiState.update { it.copy(stationBGrid = newGrid) }
|
||||
if (newGrid == old.trim().uppercase()) return
|
||||
val pos = qthToPosition(newGrid)
|
||||
if (pos != null) {
|
||||
_uiState.update { it.copy(
|
||||
stationBLat = "%.4f".format(pos.latitude),
|
||||
@@ -144,7 +159,7 @@ class MutualViewModel(
|
||||
_uiState.update { it.copy(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
stationALon = "%.4f".format(pos.longitude),
|
||||
stationAGrid = latLonToGrid(pos.latitude, pos.longitude)
|
||||
stationAGrid = positionToQth(pos.latitude, pos.longitude) ?: ""
|
||||
) }
|
||||
}
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
@@ -184,7 +199,7 @@ class MutualViewModel(
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
|
||||
val results = withContext(Dispatchers.Default) {
|
||||
val results = withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||
}
|
||||
|
||||
@@ -213,83 +228,13 @@ class MutualViewModel(
|
||||
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
|
||||
val trimmed = gridText.trim().uppercase()
|
||||
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
|
||||
return gridToLatLon(trimmed)
|
||||
return qthToPosition(trimmed)
|
||||
}
|
||||
val lat = latText.toDoubleOrNull()
|
||||
val lon = lonText.toDoubleOrNull()
|
||||
return if (lat != null && lon != null) GeoPos(lat, lon) else null
|
||||
}
|
||||
|
||||
/** Convert lat/lon to Maidenhead grid (6-char). */
|
||||
private fun latLonToGrid(lat: Double, lon: Double): String {
|
||||
var adjLon = (lon + 180.0) % 360.0
|
||||
var adjLat = (lat + 90.0) % 180.0
|
||||
|
||||
val fieldLon = (adjLon / 20.0).toInt()
|
||||
val fieldLat = (adjLat / 10.0).toInt()
|
||||
adjLon -= fieldLon * 20.0
|
||||
adjLat -= fieldLat * 10.0
|
||||
|
||||
val squareLon = (adjLon / 2.0).toInt()
|
||||
val squareLat = (adjLat / 1.0).toInt()
|
||||
adjLon -= squareLon * 2.0
|
||||
adjLat -= squareLat * 1.0
|
||||
|
||||
val subLon = (adjLon * 60.0 / 5.0).toInt()
|
||||
val subLat = (adjLat * 60.0 / 2.5).toInt()
|
||||
|
||||
return buildString {
|
||||
append('A' + fieldLon)
|
||||
append('A' + fieldLat)
|
||||
append('0' + squareLon)
|
||||
append('0' + squareLat)
|
||||
append('A' + subLon)
|
||||
append('A' + subLat)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
|
||||
private fun gridToLatLon(grid: String): GeoPos? {
|
||||
val g = grid.uppercase()
|
||||
if (g.length < 4) return null
|
||||
val lonField = (g[0] - 'A').toDouble() * 20.0
|
||||
val latField = (g[1] - 'A').toDouble() * 10.0
|
||||
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
|
||||
|
||||
val lonSquare = (g[2] - '0').toDouble() * 2.0
|
||||
val latSquare = (g[3] - '0').toDouble() * 1.0
|
||||
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
|
||||
|
||||
var lon = lonField + lonSquare
|
||||
var lat = latField + latSquare
|
||||
|
||||
if (g.length >= 6) {
|
||||
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
|
||||
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
|
||||
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
|
||||
lon += lonSub
|
||||
lat += latSub
|
||||
|
||||
if (g.length >= 8) {
|
||||
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
|
||||
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
|
||||
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
|
||||
lon += lonExt + 15.0 / 3600.0
|
||||
lat += latExt + 7.5 / 3600.0
|
||||
} else {
|
||||
lon += 2.5 / 60.0
|
||||
lat += 1.25 / 60.0
|
||||
}
|
||||
} else {
|
||||
lon += 1.0
|
||||
lat += 0.5
|
||||
}
|
||||
|
||||
lon = lon - 180.0
|
||||
lat = lat - 90.0
|
||||
return GeoPos(lat, lon)
|
||||
}
|
||||
|
||||
private fun findMutualPasses(
|
||||
satellites: List<OrbitalObject>,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Test fake for ISatelliteRepo. Only `satellites` and `passes` are backed by
|
||||
* mutable state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSatelliteRepo(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList()
|
||||
) : ISatelliteRepo {
|
||||
|
||||
override val satellites = MutableStateFlow(satellites)
|
||||
override val passes = MutableStateFlow(passes)
|
||||
override val isCalculating = MutableStateFlow(false)
|
||||
override val selectedPass = MutableStateFlow(Pair(0, 0L))
|
||||
|
||||
override fun selectPass(catNum: Int, aosTime: Long) = TODO()
|
||||
override suspend fun initRepository() = TODO()
|
||||
override suspend fun calculatePasses(
|
||||
time: Long, hoursAhead: Int, minElevation: Double,
|
||||
aosStartMinute: Int, aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean, modes: List<String>
|
||||
) = TODO()
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos = TODO()
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
)
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
/**
|
||||
* Swaps the Main dispatcher for a [StandardTestDispatcher] so that
|
||||
* `viewModelScope.launch` work can be driven deterministically with
|
||||
* `runTest` / `advanceUntilIdle()` instead of racing real threads.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
val dispatcher: TestDispatcher = StandardTestDispatcher()
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||
|
||||
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MutualViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
/**
|
||||
* ViewModel wired to fakes and a test dispatcher that shares the runTest
|
||||
* scheduler, so [advanceUntilIdle] deterministically drains both the
|
||||
* viewModelScope (Main) queue and the compute dispatcher.
|
||||
*/
|
||||
private fun TestScope.createVm(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList(),
|
||||
position: GeoPos = TestOrbits.GUANGZHOU
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
)
|
||||
|
||||
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
|
||||
vm.queryMutualPasses()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- init
|
||||
|
||||
@Test
|
||||
fun `init prefills station A with the current position`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
val state = vm.uiState.value
|
||||
|
||||
assertEquals("23.1300", state.stationALat)
|
||||
assertEquals("113.2600", state.stationALon)
|
||||
assertEquals(6, state.stationAGrid.length)
|
||||
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||
assertTrue(state.stationAGrid.uppercase() == state.stationAGrid)
|
||||
assertEquals(0.0, state.stationAMinElev, 0.0)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- station input
|
||||
|
||||
@Test
|
||||
fun `entering latitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.SHENZHEN) // station A pre-filled with SZ
|
||||
vm.onStationALat("23.5")
|
||||
|
||||
// 23.5N 114.06E -> field OL, square 73 (Shenzhen), subsquare AM
|
||||
assertEquals("OL73AM", vm.uiState.value.stationAGrid)
|
||||
// raw input string is stored verbatim, not reformatted
|
||||
assertEquals("23.5", vm.uiState.value.stationALat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering longitude recomputes the maidenhead grid`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
vm.onStationALon("113.5")
|
||||
|
||||
// 23.13N 113.5E -> Guangzhou field OL63, subsquare SD
|
||||
assertEquals("OL63SD", vm.uiState.value.stationAGrid)
|
||||
// raw input string is stored verbatim, not reformatted
|
||||
assertEquals("113.5", vm.uiState.value.stationALon)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entering a lowercase grid normalises to uppercase and updates lat lon`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.NEW_YORK)
|
||||
vm.onStationAGrid("ol63")
|
||||
|
||||
val state = vm.uiState.value
|
||||
// The grid is normalised to upper case on input (Bug fix: raw input
|
||||
// used to be stored verbatim, e.g. "ol63" stayed lowercase).
|
||||
assertEquals("OL63", state.stationAGrid)
|
||||
// 4-char grid resolves to the square centre
|
||||
assertEquals(23.5, state.stationALat.toDouble(), 1e-9)
|
||||
assertEquals(113.0, state.stationALon.toDouble(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid grid leaves lat and lon untouched`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
val before = vm.uiState.value.stationALat
|
||||
vm.onStationAGrid("ZZ99") // valid shape, out-of-range letters
|
||||
|
||||
assertEquals("ZZ99", vm.uiState.value.stationAGrid)
|
||||
assertEquals(before, vm.uiState.value.stationALat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `use current position restores station A from settings`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(position = TestOrbits.GUANGZHOU)
|
||||
vm.onStationALat("10.0")
|
||||
vm.onStationALon("20.0")
|
||||
assertNotEquals("23.1300", vm.uiState.value.stationALat)
|
||||
|
||||
vm.onUseCurrentPosition()
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertEquals("23.1300", state.stationALat)
|
||||
assertEquals("113.2600", state.stationALon)
|
||||
assertTrue(state.stationAGrid.startsWith("OL63"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station B independent from station A`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm()
|
||||
vm.onStationBLat("22.54")
|
||||
vm.onStationBLon("114.06")
|
||||
|
||||
// 22.54N 114.06E is Shenzhen: OL72 field (lon square 7, lat square 2)
|
||||
assertEquals("OL72", vm.uiState.value.stationBGrid.take(4))
|
||||
assertTrue(vm.uiState.value.stationBGrid.length == 6)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ query: errors
|
||||
|
||||
@Test
|
||||
fun `query with invalid coordinates sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = TestOrbits.findPassWindows())
|
||||
vm.onStationAGrid("ZZ99")
|
||||
vm.onStationBGrid("ZZ99")
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertTrue(state.errorMessage != null)
|
||||
assertFalse(state.hasSearched)
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with no satellites sets an error`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = emptyList())
|
||||
vm.onStationBGrid("OL62")
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
assertEquals(
|
||||
"No satellite data. Select satellites from the list first.",
|
||||
vm.uiState.value.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query with everything empty reports empty pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onStationAMinElev(89.0)
|
||||
vm.onStationBMinElev(89.0)
|
||||
vm.onHoursAhead(2)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertEquals("Pass list is empty. Select satellites from the list first.", state.errorMessage)
|
||||
}
|
||||
|
||||
// --------------------------------------------------- query: happy path
|
||||
|
||||
@Test
|
||||
fun `query finds mutual passes reusing the main pass list`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 12)
|
||||
assertTrue("test fixture must produce ISS passes over Guangzhou", windows.isNotEmpty())
|
||||
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(12)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.hasSearched)
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue("expected at least one mutual pass", state.mutualPasses.isNotEmpty())
|
||||
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
||||
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
||||
// AOS/LOS within the searched horizon
|
||||
val now = System.currentTimeMillis()
|
||||
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
|
||||
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query results are sorted by start time even when input is shuffled`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
// A 24h horizon reliably yields several windows per satellite (ISS +
|
||||
// staggered variant), guaranteeing >= 2 distinct passes.
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce at least two windows", windows.size >= 2)
|
||||
|
||||
val shuffled = windows.shuffled(Random(42))
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = shuffled
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val starts = vm.uiState.value.mutualPasses.map { it.startTime }
|
||||
assertEquals(starts.sorted(), starts)
|
||||
// Refined AOS must not drift far from the coarse window
|
||||
val byAos = windows.associateBy { it.aosTime }
|
||||
vm.uiState.value.mutualPasses.forEach { mp ->
|
||||
assertTrue("refined AOS ${mp.startTime} near a coarse AOS", byAos.keys.any { kotlin.math.abs(it - mp.startTime) < 5 * 60_000L })
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------- query: fallback
|
||||
|
||||
@Test
|
||||
fun `query falls back to independent search when pass list is empty`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
// Two satellites (ISS + staggered variant) raise the odds of a window
|
||||
// within the fallback horizon while keeping the search realistic.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = emptyList()
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(12)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.hasSearched)
|
||||
// GZ + SZ are ~100km apart: a 12h ISS window must yield at least one mutual pass
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue("fallback should find mutual passes", state.mutualPasses.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query over antipodal stations finds no mutual passes`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = emptyList())
|
||||
vm.onStationBLat("40.71")
|
||||
vm.onStationBLon("-74.01")
|
||||
vm.onHoursAhead(2)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertFalse(state.isCalculating)
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
assertTrue(state.errorMessage != null)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- misc
|
||||
|
||||
@Test
|
||||
fun `clearError resets the error message`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val vm = createVm()
|
||||
vm.onStationAGrid("ZZ99")
|
||||
vm.onStationBGrid("ZZ99")
|
||||
queryAndSettle(vm)
|
||||
assertTrue(vm.uiState.value.errorMessage != null)
|
||||
|
||||
vm.clearError()
|
||||
|
||||
assertNull(vm.uiState.value.errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import kotlin.math.PI
|
||||
|
||||
/**
|
||||
* Deterministic orbital test fixtures: real ISS TLE elements (2024-03-09,
|
||||
* same source as DataParserTest) so SGP4 propagation produces genuine
|
||||
* visibility windows instead of fabricated numbers.
|
||||
*/
|
||||
object TestOrbits {
|
||||
|
||||
/** ISS (ZARYA) from the 2024-03-09 TLE used in DataParserTest. */
|
||||
val ISS: OrbitalObject = NearEarthObject(
|
||||
OrbitalData(
|
||||
name = "ISS (ZARYA)",
|
||||
epoch = 24069.23963816,
|
||||
meanmo = 15.49756209,
|
||||
eccn = 0.0005741,
|
||||
incl = 51.6418,
|
||||
raan = 90.7424,
|
||||
argper = 343.9724,
|
||||
meanan = 92.8274,
|
||||
catnum = 25544,
|
||||
bstar = 0.00025016
|
||||
)
|
||||
)
|
||||
|
||||
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
|
||||
val GUANGZHOU = GeoPos(23.13, 113.26)
|
||||
|
||||
/** Shenzhen, ~100 km from Guangzhou — nearly every LEO pass is mutual. */
|
||||
val SHENZHEN = GeoPos(22.54, 114.06)
|
||||
|
||||
/** New York — on the opposite side of the globe, never mutual with GZ. */
|
||||
val NEW_YORK = GeoPos(40.71, -74.01)
|
||||
|
||||
/**
|
||||
* Second LEO with the same ISS elements but a different argument of
|
||||
* perigee, so its visibility windows land at different times. Gives tests
|
||||
* two distinct passes to exercise ordering/aggregation logic.
|
||||
*/
|
||||
val ISS_VARIANT: OrbitalObject = NearEarthObject(
|
||||
OrbitalData(
|
||||
name = "ISS-V2",
|
||||
epoch = 24069.23963816,
|
||||
meanmo = 15.49756209,
|
||||
eccn = 0.0005741,
|
||||
incl = 51.6418,
|
||||
raan = 90.7424,
|
||||
argper = 100.0,
|
||||
meanan = 92.8274,
|
||||
catnum = 25545,
|
||||
bstar = 0.00025016
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Coarse pass-window search (30s steps) producing OrbitalPass entries with
|
||||
* honest AOS/LOS times. Mirrors the coarse phase of SatelliteRepo.getLeoPass;
|
||||
* the ViewModel under test re-refines the edges to ~1s itself.
|
||||
*/
|
||||
fun findPassWindows(
|
||||
sat: OrbitalObject = ISS,
|
||||
pos: GeoPos = GUANGZHOU,
|
||||
time: Long = System.currentTimeMillis(),
|
||||
hoursAhead: Int = 12
|
||||
): List<OrbitalPass> {
|
||||
if (!sat.willBeSeen(pos)) return emptyList()
|
||||
val passes = mutableListOf<OrbitalPass>()
|
||||
val step = 30_000L
|
||||
val end = time + hoursAhead * 3600_000L
|
||||
var t = time
|
||||
var inWindow = false
|
||||
var aos = 0L
|
||||
var maxElev = 0.0
|
||||
while (t < end) {
|
||||
val elev = sat.getElevation(pos, t) * 180.0 / PI
|
||||
if (elev > 0.0 && !inWindow) {
|
||||
inWindow = true
|
||||
aos = t
|
||||
maxElev = 0.0
|
||||
}
|
||||
if (inWindow && elev > maxElev) maxElev = elev
|
||||
if (elev <= 0.0 && inWindow) {
|
||||
inWindow = false
|
||||
passes.add(
|
||||
OrbitalPass(
|
||||
aosTime = aos,
|
||||
losTime = t,
|
||||
maxElevation = maxElev,
|
||||
orbitalObject = sat
|
||||
)
|
||||
)
|
||||
}
|
||||
t += step
|
||||
}
|
||||
return passes
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
}
|
||||
navigateUp()
|
||||
}
|
||||
val navigateToMapAndClearMutual = {
|
||||
if (container.mutualPassData.value.endTime > 0L) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
}
|
||||
navigateToMap()
|
||||
}
|
||||
LaunchedEffect(mutualData.endTime) {
|
||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||
while (true) {
|
||||
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
@@ -131,13 +137,11 @@ private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToMap: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||
}
|
||||
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
|
||||
// at the current moment, same display mode as the local station.
|
||||
val trackB = remember(mutualData.trackSamples) {
|
||||
@@ -173,7 +177,7 @@ private fun RadarScreen(
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
@@ -181,7 +185,7 @@ private fun RadarScreen(
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
|
||||
+356
-48
@@ -19,54 +19,83 @@ package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -138,10 +167,16 @@ private fun TransceiversDialogPreview() {
|
||||
transceiversUrls = listOf(
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
),
|
||||
satelliteEnabled = listOf(true, false),
|
||||
transceiversEnabled = listOf(true),
|
||||
statusCodes = mapOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
|
||||
"amsat.org/tle/current/nasabare.txt" to 404
|
||||
),
|
||||
onImportTle = {},
|
||||
onImportTransceivers = {},
|
||||
onDismiss = {},
|
||||
onSave = { _, _ -> }
|
||||
onSave = { _, _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,10 +185,13 @@ private fun TransceiversDialogPreview() {
|
||||
fun DataSourcesDialog(
|
||||
satelliteUrls: List<String>,
|
||||
transceiversUrls: List<String>,
|
||||
satelliteEnabled: List<Boolean>,
|
||||
transceiversEnabled: List<Boolean>,
|
||||
statusCodes: Map<String, Int>,
|
||||
onImportTle: () -> Unit,
|
||||
onImportTransceivers: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (List<String>, List<String>) -> Unit
|
||||
onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
|
||||
) {
|
||||
val padding = LocalSpacing.current.large
|
||||
// Stable Long IDs avoid key collisions when several entries are empty or duplicated.
|
||||
@@ -164,10 +202,39 @@ fun DataSourcesDialog(
|
||||
val txUrls = remember {
|
||||
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList()
|
||||
}
|
||||
val satEnabled = remember {
|
||||
mutableStateMapOf<Long, Boolean>().apply {
|
||||
satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } }
|
||||
}
|
||||
}
|
||||
val txEnabled = remember {
|
||||
mutableStateMapOf<Long, Boolean>().apply {
|
||||
txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } }
|
||||
}
|
||||
}
|
||||
val onRestoreDefaults = {
|
||||
val satDefaults = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
val txDefaults = Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||
nextId.longValue = (satDefaults.size + txDefaults.size).toLong()
|
||||
satUrls.clear()
|
||||
satUrls.addAll(satDefaults.mapIndexed { i, url -> i.toLong() to url })
|
||||
txUrls.clear()
|
||||
txUrls.addAll(txDefaults.mapIndexed { i, url -> (satDefaults.size + i).toLong() to url })
|
||||
satEnabled.clear()
|
||||
txEnabled.clear()
|
||||
Unit
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val satDraggedId = remember { mutableStateOf(-1L) }
|
||||
val txDraggedId = remember { mutableStateOf(-1L) }
|
||||
val onAccept = {
|
||||
val satFiltered = satUrls.filter { it.second.trim().isNotBlank() }
|
||||
val txFiltered = txUrls.filter { it.second.trim().isNotBlank() }
|
||||
onSave(
|
||||
satUrls.map { it.second.trim() }.filter { it.isNotBlank() },
|
||||
txUrls.map { it.second.trim() }.filter { it.isNotBlank() }
|
||||
satFiltered.map { it.second.trim() },
|
||||
txFiltered.map { it.second.trim() },
|
||||
satFiltered.map { satEnabled[it.first] ?: true },
|
||||
txFiltered.map { txEnabled[it.first] ?: true }
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -177,6 +244,7 @@ fun DataSourcesDialog(
|
||||
onAccept = onAccept,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.84f)
|
||||
.padding(horizontal = padding),
|
||||
@@ -200,12 +268,24 @@ fun DataSourcesDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
CardButton(
|
||||
onClick = onRestoreDefaults,
|
||||
text = stringResource(R.string.prefs_data_sources_restore),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
sourceSection(
|
||||
sectionKey = "sat",
|
||||
labelResId = R.string.prefs_data_sources_satellites_label,
|
||||
urls = satUrls,
|
||||
listState = listState,
|
||||
draggedId = satDraggedId,
|
||||
statusCodes = statusCodes,
|
||||
enabledMap = satEnabled,
|
||||
onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) },
|
||||
onAdd = { satUrls.add(nextId.longValue++ to "") },
|
||||
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) },
|
||||
onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) },
|
||||
onRemove = { i -> satUrls.removeAt(i) },
|
||||
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v }
|
||||
)
|
||||
@@ -213,8 +293,13 @@ fun DataSourcesDialog(
|
||||
sectionKey = "tx",
|
||||
labelResId = R.string.prefs_data_sources_transceivers_label,
|
||||
urls = txUrls,
|
||||
listState = listState,
|
||||
draggedId = txDraggedId,
|
||||
statusCodes = statusCodes,
|
||||
enabledMap = txEnabled,
|
||||
onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) },
|
||||
onAdd = { txUrls.add(nextId.longValue++ to "") },
|
||||
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) },
|
||||
onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) },
|
||||
onRemove = { i -> txUrls.removeAt(i) },
|
||||
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v }
|
||||
)
|
||||
@@ -226,8 +311,13 @@ private fun LazyListScope.sourceSection(
|
||||
sectionKey: String,
|
||||
labelResId: Int,
|
||||
urls: List<Pair<Long, String>>,
|
||||
listState: LazyListState,
|
||||
draggedId: MutableState<Long>,
|
||||
statusCodes: Map<String, Int>,
|
||||
enabledMap: Map<Long, Boolean>,
|
||||
onToggle: (Long) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onMoveUp: (Int) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
onUrlChange: (Int, String) -> Unit
|
||||
) {
|
||||
@@ -243,38 +333,256 @@ private fun LazyListScope.sourceSection(
|
||||
IconCard(action = onAdd, resId = R.drawable.ic_add)
|
||||
}
|
||||
}
|
||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) ->
|
||||
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
|
||||
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { onUrlChange(index, it) },
|
||||
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
|
||||
leadingIcon = {
|
||||
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow),
|
||||
contentDescription = null,
|
||||
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
|
||||
modifier = Modifier.rotate(270f)
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { onRemove(index) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
val enabled = enabledMap[id] ?: true
|
||||
val rowState = remember { DragRowState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDragging = draggedId.value == id
|
||||
val isLifted = isDragging || rowState.isSettling.value
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) return@LaunchedEffect
|
||||
autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring())
|
||||
)
|
||||
.draggedVisual(
|
||||
isLifted = isLifted,
|
||||
translationY = if (rowState.isSettling.value) {
|
||||
rowState.settleAnim.value
|
||||
} else {
|
||||
rowState.offsetY.floatValue + rowState.scrollComp.floatValue
|
||||
}
|
||||
)
|
||||
.animateItem(
|
||||
fadeInSpec = spring(),
|
||||
// The dragged row repositions instantly, while its neighbours spring
|
||||
// out of the way (the "squeeze" effect).
|
||||
placementSpec = if (isDragging) {
|
||||
tween<IntOffset>(durationMillis = 0)
|
||||
} else {
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
},
|
||||
fadeOutSpec = spring()
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_drag_handle),
|
||||
contentDescription = null,
|
||||
tint = enabledTint
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { onUrlChange(index, it) },
|
||||
label = { Text(stringResource(R.string.prefs_data_sources_url_title)) },
|
||||
supportingText = statusCodes[url]?.let { code ->
|
||||
{ Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) }
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { onRemove(index) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Checkbox(
|
||||
checked = enabled,
|
||||
onCheckedChange = { onToggle(id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row drag state, kept in one object to keep the drag-handle modifier signature small.
|
||||
*
|
||||
* [offsetY] is the compensated visual displacement during a drag (finger travel minus the
|
||||
* heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset]
|
||||
* tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly
|
||||
* flies the lifted row back into its slot once the finger is released.
|
||||
*/
|
||||
private class DragRowState {
|
||||
val offsetY = mutableFloatStateOf(0f)
|
||||
val fingerOffset = mutableFloatStateOf(0f)
|
||||
val scrollComp = mutableFloatStateOf(0f)
|
||||
val startCenterY = mutableFloatStateOf(0f)
|
||||
val settleAnim = Animatable(0f)
|
||||
val isSettling = mutableStateOf(false)
|
||||
}
|
||||
|
||||
/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */
|
||||
private val reorderSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
/**
|
||||
* Drag handle gesture that performs live reordering while dragging.
|
||||
*/
|
||||
@Composable
|
||||
private fun Modifier.dragHandle(
|
||||
listState: LazyListState,
|
||||
sectionKey: String,
|
||||
entryId: Long,
|
||||
urls: List<Pair<Long, String>>,
|
||||
draggedId: MutableState<Long>,
|
||||
rowState: DragRowState,
|
||||
scope: CoroutineScope,
|
||||
onMove: (from: Int, to: Int) -> Unit
|
||||
): Modifier = pointerInput(entryId, sectionKey) {
|
||||
fun reorderLive() {
|
||||
val myIndex = urls.indexOfFirst { it.first == entryId }
|
||||
if (myIndex !in urls.indices) return
|
||||
val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
// Dragging down: swap when the dragged centre passes the next row's midpoint.
|
||||
if (myIndex < urls.lastIndex) {
|
||||
val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" }
|
||||
if (next != null && myCenter > next.offset + next.size / 2f) {
|
||||
onMove(myIndex, myIndex + 1)
|
||||
rowState.offsetY.floatValue -= next.size.toFloat()
|
||||
return
|
||||
}
|
||||
}
|
||||
// Dragging up: swap when the dragged centre passes the previous row's midpoint.
|
||||
if (myIndex > 0) {
|
||||
val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" }
|
||||
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
|
||||
onMove(myIndex, myIndex - 1)
|
||||
rowState.offsetY.floatValue += prev.size.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the drag bookkeeping and fly the lifted row back into its slot.
|
||||
fun finishDrag() {
|
||||
val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue
|
||||
if (kotlin.math.abs(lastOffset) < 1f) {
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = -1L
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
rowState.settleAnim.snapTo(lastOffset)
|
||||
rowState.isSettling.value = true
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = -1L
|
||||
rowState.settleAnim.animateTo(0f, reorderSpring)
|
||||
rowState.isSettling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
rowState.isSettling.value = false
|
||||
val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" }
|
||||
rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
|
||||
rowState.fingerOffset.floatValue = 0f
|
||||
rowState.offsetY.floatValue = 0f
|
||||
rowState.scrollComp.floatValue = 0f
|
||||
draggedId.value = entryId
|
||||
},
|
||||
onDragEnd = ::finishDrag,
|
||||
onDragCancel = ::finishDrag
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress
|
||||
rowState.fingerOffset.floatValue += dragAmount.y
|
||||
rowState.offsetY.floatValue += dragAmount.y
|
||||
reorderLive()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
// Smoothly scale the row up/down as the lifted card appears and disappears.
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isLifted) 1.02f else 1f,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
label = "dragScale"
|
||||
)
|
||||
return this
|
||||
.graphicsLayer {
|
||||
if (isLifted) {
|
||||
this.translationY = translationY
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isLifted) {
|
||||
// Solid card on top so the lifted row fully covers the row beneath it
|
||||
// instead of showing a translucent overlap of both rows.
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.shadow(8.dp, shape, clip = false)
|
||||
.background(MaterialTheme.colorScheme.surface, shape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
|
||||
* The visual centre is tracked independently of the entry's layout slot (which can scroll out
|
||||
* of [LazyListState.layoutInfo.visibleItemsInfo] during a long drag); [startCenterY] is the
|
||||
* entry's viewport centre captured at drag start and [fingerOffset] is the raw finger delta.
|
||||
*/
|
||||
private suspend fun autoScroll(
|
||||
listState: LazyListState,
|
||||
startCenterY: MutableFloatState,
|
||||
fingerOffset: MutableFloatState,
|
||||
scrollComp: MutableFloatState
|
||||
) {
|
||||
val threshold = 48f
|
||||
val maxSpeed = 24f
|
||||
while (true) {
|
||||
val info = listState.layoutInfo
|
||||
val center = startCenterY.floatValue + fingerOffset.floatValue
|
||||
val top = info.viewportStartOffset + threshold
|
||||
val bottom = info.viewportEndOffset - threshold
|
||||
val delta = when {
|
||||
center < top -> -(top - center).coerceAtMost(maxSpeed)
|
||||
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
|
||||
else -> 0f
|
||||
}
|
||||
if (delta != 0f) scrollComp.floatValue += listState.scrollBy(delta)
|
||||
delay(16L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
|
||||
|
||||
@Composable
|
||||
private fun statusColor(code: Int): Color = when {
|
||||
code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
|
||||
code in 200..299 -> Color(0xFF66BB6A)
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewNetworkOutputDialog() {
|
||||
|
||||
+30
-3
@@ -82,13 +82,25 @@ import java.util.Locale
|
||||
fun SettingsDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
if (showUpdateChecker) {
|
||||
UpdateCheckerScreen(
|
||||
currentVersion = uiState.appVersionName,
|
||||
state = uiState.updateChecker,
|
||||
onBack = { showUpdateChecker = false },
|
||||
onCheck = { onAction(SettingsAction.CheckForUpdate) },
|
||||
onDownload = { onAction(SettingsAction.DownloadUpdate) },
|
||||
onConsumeApk = { onAction(SettingsAction.ConsumeDownloadedApk) }
|
||||
)
|
||||
return
|
||||
}
|
||||
val dialogs = rememberDialogVisibility()
|
||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
@@ -128,11 +140,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
DataSourcesDialog(
|
||||
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
|
||||
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
|
||||
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
|
||||
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
|
||||
statusCodes = uiState.dataSourcesStatus,
|
||||
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
|
||||
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
|
||||
onDismiss = { dialogs.dataSources = false },
|
||||
onSave = { satUrls, txUrls ->
|
||||
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls)
|
||||
onSave = { satUrls, txUrls, satEnabled, txEnabled ->
|
||||
val newSettings = DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
)
|
||||
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
|
||||
onAction(SettingsAction.UpdateFromWeb)
|
||||
}
|
||||
@@ -287,6 +307,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
onClick = { showUpdateChecker = true },
|
||||
text = stringResource(R.string.update_check_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -18,10 +18,12 @@
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import java.io.File
|
||||
|
||||
data class PositionSettings(
|
||||
val isUpdating: Boolean, val stationPos: GeoPos, val messageResId: Int
|
||||
@@ -34,6 +36,15 @@ data class DataSettings(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
data class UpdateCheckerState(
|
||||
val isChecking: Boolean = false,
|
||||
val release: LatestRelease? = null,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isDownloading: Boolean = false,
|
||||
val apkFile: File? = null,
|
||||
val errorResId: Int? = null
|
||||
)
|
||||
|
||||
data class SettingsState(
|
||||
val appVersionName: String,
|
||||
val positionSettings: PositionSettings,
|
||||
@@ -41,7 +52,9 @@ data class SettingsState(
|
||||
val otherSettings: OtherSettings,
|
||||
val rcSettings: RCSettings,
|
||||
val radioControlSettings: RadioControlSettings,
|
||||
val dataSourcesSettings: DataSourcesSettings
|
||||
val dataSourcesSettings: DataSourcesSettings,
|
||||
val dataSourcesStatus: Map<String, Int> = emptyMap(),
|
||||
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||
)
|
||||
|
||||
sealed interface SettingsAction {
|
||||
@@ -72,6 +85,11 @@ sealed interface SettingsAction {
|
||||
// Data sources
|
||||
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
data object DownloadUpdate : SettingsAction
|
||||
data object ConsumeDownloadedApk : SettingsAction
|
||||
|
||||
// System
|
||||
data class ShowToast(val message: String) : SettingsAction
|
||||
}
|
||||
+64
-2
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
@@ -24,16 +25,21 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class SettingsViewModel(
|
||||
private val databaseRepo: IDatabaseRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -47,7 +53,8 @@ class SettingsViewModel(
|
||||
otherSettings = settingsRepo.otherSettings.value,
|
||||
rcSettings = settingsRepo.rcSettings.value,
|
||||
radioControlSettings = settingsRepo.radioControlSettings.value,
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value
|
||||
)
|
||||
)
|
||||
|
||||
@@ -90,6 +97,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(dataSourcesSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.dataSourcesStatus.collect { status ->
|
||||
_uiState.update { it.copy(dataSourcesStatus = status) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.radioControlSettings.collect { settings ->
|
||||
_uiState.update { it.copy(radioControlSettings = settings) }
|
||||
@@ -125,11 +137,59 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
SettingsAction.ConsumeDownloadedApk -> consumeDownloadedApk()
|
||||
// System
|
||||
is SettingsAction.ShowToast -> showToast(action.message)
|
||||
}
|
||||
}
|
||||
|
||||
// region Update checker
|
||||
|
||||
private fun checkForUpdate() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isChecking = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val release = updateRepo.getLatestRelease()
|
||||
val hasUpdate = release != null &&
|
||||
VersionComparator.isNewer(release.versionTag, settingsRepo.appVersionName)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isChecking = false,
|
||||
release = release,
|
||||
hasUpdate = hasUpdate,
|
||||
errorResId = if (release == null) R.string.update_check_error else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadUpdate() {
|
||||
val url = _uiState.value.updateChecker.release?.apkUrl ?: return
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(isDownloading = true, errorResId = null)) }
|
||||
viewModelScope.launch {
|
||||
val success = updateRepo.downloadApk(url, apkFile)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
updateChecker = it.updateChecker.copy(
|
||||
isDownloading = false,
|
||||
apkFile = if (success) apkFile else null,
|
||||
errorResId = if (success) null else R.string.update_check_download_error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeDownloadedApk() {
|
||||
_uiState.update { it.copy(updateChecker = it.updateChecker.copy(apkFile = null)) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Position helpers — consolidated from 3 near-identical functions
|
||||
|
||||
private fun setGpsPosition() {
|
||||
@@ -201,11 +261,13 @@ class SettingsViewModel(
|
||||
|
||||
companion object {
|
||||
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
fun factory(container: IMainContainer, context: Context) = viewModelFactory {
|
||||
initializer {
|
||||
SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
updateRepo = container.updateRepo,
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
}
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.clip
|
||||
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,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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, modifier: Modifier = Modifier) {
|
||||
val release = state.release ?: return
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(top = 4.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = release.body,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -608,12 +608,16 @@ private fun formatFetchedAt(utcMs: Long): String {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = utcMs
|
||||
val day = cal.get(Calendar.DAY_OF_MONTH)
|
||||
val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
|
||||
val monthIndex = cal.get(Calendar.MONTH)
|
||||
val year = cal.get(Calendar.YEAR)
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||
return "$day$month $year - $hh:$mm:$ss"
|
||||
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
|
||||
} else {
|
||||
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "448"
|
||||
appVersionCode = "451"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.4"
|
||||
appVersionName = "4.4.6-ba7opf.7"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user