mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-12 23:17:26 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a7373ba4 | ||
|
|
9551de0d1e | ||
|
|
6d1b5c2798 | ||
|
|
9c75e0e89f | ||
|
|
375225f25b | ||
|
|
1a1ed0aaea | ||
|
|
0f34c40b11 | ||
|
|
85c39790ae | ||
|
|
e443ffe85d | ||
|
|
48fa384afd | ||
|
|
bf65d8d8cf | ||
|
|
8d9208b063 | ||
|
|
a0ebe14d07 |
No files matched your search
+7
-1
@@ -74,7 +74,13 @@ internal fun Project.setupAndroidApp() {
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||
// "zh" alone is stripped down to the Hans config: without the Traditional
|
||||
// region entries below, values-b+zh+Hant is filtered out of the APK and a
|
||||
// zh-TW / zh-HK / zh-MO device falls back to English. aapt2 only accepts
|
||||
// the legacy "zh-rTW" spelling here ("zh-Hant" fails the build).
|
||||
localeFilters.addAll(
|
||||
listOf("en", "es", "ru", "si", "tr", "uk", "zh", "zh-rTW", "zh-rHK", "zh-rMO")
|
||||
)
|
||||
}
|
||||
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.security.PrivateKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
@@ -172,21 +173,19 @@ internal object Pkcs12Reader {
|
||||
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
|
||||
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
|
||||
|
||||
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
|
||||
val secretKey = try {
|
||||
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
|
||||
} catch (e: java.security.NoSuchAlgorithmException) {
|
||||
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
|
||||
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
|
||||
val keyBytes = derivePkcs12Key(prfName, password, salt, iterations, finalKeyBits)
|
||||
return try {
|
||||
val cipher = Cipher.getInstance(cipherName)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(keyBytes, "AES")
|
||||
else SecretKeySpec(keyBytes, "DESede"),
|
||||
IvParameterSpec(iv)
|
||||
)
|
||||
cipher.doFinal(encrypted)
|
||||
} finally {
|
||||
keyBytes.fill(0)
|
||||
}
|
||||
val cipher = Cipher.getInstance(cipherName)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
|
||||
else SecretKeySpec(secretKey.encoded, "DESede"),
|
||||
IvParameterSpec(iv)
|
||||
)
|
||||
return cipher.doFinal(encrypted)
|
||||
}
|
||||
|
||||
/** Human-readable name for a known algorithm OID. */
|
||||
@@ -299,3 +298,69 @@ internal object Pkcs12Reader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider selection is injectable so JVM tests cannot silently fall back to SunJCE. */
|
||||
internal fun derivePkcs12Key(
|
||||
prfName: String,
|
||||
password: CharArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
keyBits: Int,
|
||||
factory: (String) -> SecretKeyFactory = { SecretKeyFactory.getInstance(it) }
|
||||
): ByteArray {
|
||||
val spec = PBEKeySpec(password, salt, iterations, keyBits)
|
||||
return try {
|
||||
// Android's bundled BC factory rejects empty passwords, although PKCS#12 permits them.
|
||||
if (password.isEmpty()) {
|
||||
pbkdf2Hmac(byteArrayOf(), salt, iterations, keyBits / 8, prfName.removePrefix("PBKDF2With"))
|
||||
} else {
|
||||
try {
|
||||
factory(prfName).generateSecret(spec).encoded
|
||||
} catch (_: java.security.NoSuchAlgorithmException) {
|
||||
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
/** PBKDF2 using JCA HMAC, including zero-length passwords for all supported PBES2 PRFs. */
|
||||
internal fun pbkdf2Hmac(
|
||||
password: ByteArray,
|
||||
salt: ByteArray,
|
||||
iterations: Int,
|
||||
keyLengthBytes: Int,
|
||||
hmacAlgorithm: String
|
||||
): ByteArray {
|
||||
require(iterations > 0 && keyLengthBytes > 0)
|
||||
val mac = Mac.getInstance(hmacAlgorithm)
|
||||
// Some providers reject an empty SecretKeySpec. One zero byte has the same
|
||||
// zero-padded HMAC key block as an empty password.
|
||||
mac.init(SecretKeySpec(password.takeUnless(ByteArray::isEmpty) ?: byteArrayOf(0), hmacAlgorithm))
|
||||
val output = ByteArray(keyLengthBytes)
|
||||
val input = ByteArray(salt.size + 4)
|
||||
salt.copyInto(input)
|
||||
var outputOffset = 0
|
||||
var block = 1
|
||||
while (outputOffset < output.size) {
|
||||
input[input.size - 4] = (block ushr 24).toByte()
|
||||
input[input.size - 3] = (block ushr 16).toByte()
|
||||
input[input.size - 2] = (block ushr 8).toByte()
|
||||
input[input.size - 1] = block.toByte()
|
||||
var u = mac.doFinal(input)
|
||||
val result = u.copyOf()
|
||||
repeat(iterations - 1) {
|
||||
u = mac.doFinal(u)
|
||||
for (index in result.indices) result[index] = (result[index].toInt() xor u[index].toInt()).toByte()
|
||||
}
|
||||
val copied = minOf(result.size, output.size - outputOffset)
|
||||
result.copyInto(output, outputOffset, 0, copied)
|
||||
outputOffset += copied
|
||||
u.fill(0)
|
||||
result.fill(0)
|
||||
block++
|
||||
}
|
||||
input.fill(0)
|
||||
return output
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
@@ -25,8 +26,12 @@ 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 com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.utility.catNumForName
|
||||
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.nominalTransceiver
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -65,6 +70,12 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun nominalFrequencies(name: String): NominalFrequencies? {
|
||||
val catnum = localStorage.getEntriesNames().catNumForName(name) ?: return null
|
||||
val radio = localStorage.getRadiosWithId(catnum).nominalTransceiver() ?: return null
|
||||
return NominalFrequencies(radio.uplinkHz(), radio.downlinkHz())
|
||||
}
|
||||
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
||||
|
||||
@@ -408,6 +408,7 @@ class SettingsRepo(
|
||||
private val keyWavelogSyncCursors = "wavelogSyncCursors"
|
||||
private val keyWavelogSyncUrl = "wavelogSyncUrl"
|
||||
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
|
||||
private val keyLastWavelogFullSyncEpochMs = "wavelogLastFullSyncEpochMs"
|
||||
|
||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
|
||||
val json = preferences.getString(keyWavelogStations, null).orEmpty()
|
||||
@@ -473,6 +474,12 @@ class SettingsRepo(
|
||||
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) =
|
||||
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
|
||||
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long =
|
||||
preferences.getLong(keyLastWavelogFullSyncEpochMs, 0L)
|
||||
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) =
|
||||
preferences.edit { putLong(keyLastWavelogFullSyncEpochMs, value) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.Provider
|
||||
import java.security.spec.InvalidKeySpecException
|
||||
import java.security.spec.KeySpec
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.SecretKeyFactorySpi
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
/** Android's bundled BC factory rejects empty passwords even when PBKDF2 is available. */
|
||||
class EmptyPasswordProviderTest {
|
||||
private fun fixture(name: String) = javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val androidStyleProvider=object:Provider("LoTWEmptyPasswordTest",1.0,"Test-only empty-password rejection") {
|
||||
init { put("SecretKeyFactory.PBKDF2WithHmacSHA256",RejectEmptyPasswordFactory::class.java.name) }
|
||||
}
|
||||
|
||||
@Test fun providerReallyRejectsAnEmptyPassword() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256",androidStyleProvider).generateSecret(
|
||||
PBEKeySpec(charArrayOf(),byteArrayOf(1),1,256))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun derivesEmptyPasswordKeyWhenAndroidProviderRejectsIt() {
|
||||
val expected="62384466264daadc4144018c6bd864648272b34da8980d31521ffcce92ae003b"
|
||||
.chunked(2).map {it.toInt(16).toByte()}.toByteArray()
|
||||
assertArrayEquals(expected,derivePkcs12Key("PBKDF2WithHmacSHA256",charArrayOf(),"salt".toByteArray(),2,256) {
|
||||
SecretKeyFactory.getInstance(it,androidStyleProvider)
|
||||
})
|
||||
}
|
||||
|
||||
@Test fun emptyPasswordVectorsCoverEverySupportedPrfWithoutCallingTheFactory() {
|
||||
val vectors = mapOf(
|
||||
"SHA1" to "133a4ce837b4d2521ee2bf03e11c71ca794e079713b87cd23f360bdda2e87872",
|
||||
"SHA256" to "62384466264daadc4144018c6bd864648272b34da8980d31521ffcce92ae003b",
|
||||
"SHA384" to "a34ba6857eee44bd41b06d30e2521564ec2116a3010aad3080f35d0dde7b5c86",
|
||||
"SHA512" to "cc5eacbb057f2a5982fce67bf70f79fccf4adf285f5cbae1a5a1c5df012a630a"
|
||||
)
|
||||
for ((prf, hex) in vectors) {
|
||||
val expected = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
assertArrayEquals(expected, derivePkcs12Key("PBKDF2WithHmac$prf", charArrayOf(),
|
||||
"salt".toByteArray(), 2, 256) { error("Empty passwords must not reach a platform PBKDF2 factory") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun protectedPasswordsStillUseThePlatformFactory() {
|
||||
val actual = derivePkcs12Key("PBKDF2WithHmacSHA256", "password".toCharArray(), "salt".toByteArray(), 2, 256) {
|
||||
SecretKeyFactory.getInstance(it, androidStyleProvider)
|
||||
}
|
||||
val expected = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256", "SunJCE")
|
||||
.generateSecret(PBEKeySpec("password".toCharArray(), "salt".toByteArray(), 2, 256)).encoded
|
||||
assertArrayEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test fun importsUnprotectedCertificateWithPlatformFallback() {
|
||||
val material=LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"),charArrayOf(),System.currentTimeMillis()) {_,_->null}
|
||||
assertEquals("BA7OPF",material.info.callsign)
|
||||
assertEquals(318,material.info.dxcc)
|
||||
}
|
||||
|
||||
@Test fun readsBothPlainAndEncryptedCertificateBagsWithEmptyPassword() {
|
||||
for(name in listOf("test_pbes2_empty.p12","test_pbes2_empty_enc.p12")) {
|
||||
val (key,certificate)=Pkcs12Reader.read(fixture(name),charArrayOf())
|
||||
assertEquals("RSA",key.algorithm)
|
||||
assertEquals("RSA",certificate.publicKey.algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun blankInputOnProtectedCertificateStillReportsPasswordError() {
|
||||
val error=assertThrows(LoTWOperationException::class.java) {
|
||||
LoTWKeyMaterial.read(fixture("test_pbes2.p12"),charArrayOf(),System.currentTimeMillis()) {_,_->null}
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD,error.reason)
|
||||
}
|
||||
|
||||
class RejectEmptyPasswordFactory:SecretKeyFactorySpi() {
|
||||
override fun engineGenerateSecret(spec:KeySpec):SecretKey {
|
||||
require(spec is PBEKeySpec)
|
||||
if(spec.password.isEmpty())throw IllegalArgumentException("password empty")
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256","SunJCE").generateSecret(spec)
|
||||
}
|
||||
override fun engineGetKeySpec(key:SecretKey,spec:Class<*>):KeySpec=throw InvalidKeySpecException()
|
||||
override fun engineTranslateKey(key:SecretKey):SecretKey=throw InvalidKeyException()
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
|
||||
+2
@@ -434,4 +434,6 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit
|
||||
}
|
||||
@@ -117,6 +117,8 @@ object AdifCodec {
|
||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||
qslConfirmed = values["QSL_RCVD"].equals("Y", true),
|
||||
eqslConfirmed = values["EQSL_QSL_RCVD"].equals("Y", true),
|
||||
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||
country = values["COUNTRY"].orEmpty(),
|
||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||
|
||||
@@ -48,6 +48,12 @@ data class QsoRecord(
|
||||
val wavelogStation: String = "",
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
/** Paper QSL received (ADIF QSL_RCVD=Y) — one of the routes Wavelog counts as
|
||||
* "confirmed". Sync-only: the logbook stores no paper-QSL column, so this survives
|
||||
* only inside the record a Wavelog pull produced (the grid rule reads it there). */
|
||||
val qslConfirmed: Boolean = false,
|
||||
/** eQSL confirmation received (ADIF EQSL_QSL_RCVD=Y). Sync-only, see [qslConfirmed]. */
|
||||
val eqslConfirmed: Boolean = false,
|
||||
/** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS
|
||||
* (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler shift) uplink/downlink pair of the transceiver a contact on a satellite is
|
||||
* made through. The logbook's blank draft fills these in so a hand-made record carries a
|
||||
* frequency — LoTW derives BAND from it and rejects a record that has none.
|
||||
*/
|
||||
data class NominalFrequencies(val uplinkHz: Long?, val downlinkHz: Long?)
|
||||
+10
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
@@ -66,6 +67,15 @@ interface ISatelliteRepo {
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler) uplink/downlink of the transceiver a contact on [name] is made
|
||||
* through: the FM voice repeater when the bird has one, else the first live transceiver
|
||||
* publishing both bands. Names match through satellite identities, so the tracker spelling
|
||||
* ("SAUDISAT 1C") finds the same entry as the ARRL name ("SO-50"). Null when the name
|
||||
* resolves to no satellite, or the satellite publishes no usable pair.
|
||||
*/
|
||||
suspend fun nominalFrequencies(name: String): NominalFrequencies?
|
||||
|
||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||
* set to transponder satellites (FM voice / linear). */
|
||||
|
||||
+5
@@ -141,6 +141,11 @@ interface ISettingsRepo {
|
||||
/** Epoch ms of the last successful Wavelog sync (0 when never synced). */
|
||||
fun getLastWavelogSyncEpochMs(): Long
|
||||
fun setLastWavelogSyncEpochMs(value: Long)
|
||||
/** Epoch ms of the last FULL Wavelog re-scan (0 when never). An incremental request is
|
||||
* promoted to a full pull once this is stale — Wavelog's pull filters by row id, so a
|
||||
* confirmation arriving for an already-pulled row is invisible to the incremental cursor. */
|
||||
fun getLastWavelogFullSyncEpochMs(): Long
|
||||
fun setLastWavelogFullSyncEpochMs(value: Long)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
|
||||
+47
-11
@@ -51,10 +51,39 @@ internal fun isValidGrid4(grid: String): Boolean =
|
||||
grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' &&
|
||||
grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||
|
||||
/**
|
||||
* "Confirmed" in Wavelog's own sense. Its API's `qsl_filter` maps lotw / qsl / eqsl to
|
||||
* COL_LOTW_QSL_RCVD / COL_QSL_RCVD / COL_EQSL_QSL_RCVD = 'Y', and the ADIF export carries
|
||||
* those as LOTW_QSL_RCVD / QSL_RCVD / EQSL_QSL_RCVD.
|
||||
*/
|
||||
fun QsoRecord.isQslConfirmed(): Boolean = lotwConfirmed || qslConfirmed || eqslConfirmed
|
||||
|
||||
/**
|
||||
* How stale the last full pull may get before an incremental request is promoted to a full
|
||||
* one. Wavelog's pull API filters by row id only, so a confirmation that arrives for a row
|
||||
* pulled earlier (LoTW confirmations land on the same row, keeping its id) can never be seen
|
||||
* by the incremental cursor — the record was already fetched and is not fetched again. A full
|
||||
* pull re-reads every row with its current confirmation flags, which is the only way to pick
|
||||
* such confirmations up; it also rebuilds the stored grid set from scratch, so a square that
|
||||
* only ever existed because an unconfirmed QSO carried a looked-up grid drops out on its own.
|
||||
*/
|
||||
const val WAVELOG_FULL_RESCAN_INTERVAL_MS: Long = 3L * 24 * 60 * 60 * 1000
|
||||
|
||||
fun shouldPromoteWavelogFullSync(
|
||||
lastFullSyncEpochMs: Long,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Boolean = lastFullSyncEpochMs <= 0L || now - lastFullSyncEpochMs >= WAVELOG_FULL_RESCAN_INTERVAL_MS
|
||||
|
||||
/**
|
||||
* Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from
|
||||
* records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so
|
||||
* both sync sources feed the map's highlight and its 台址 selector the same data shape.
|
||||
*
|
||||
* Only CONFIRMED contacts turn a square green: the LoTW side derives its grids from ARRL's
|
||||
* confirmation report, so a Wavelog record that merely carries the opposite station's grid
|
||||
* (Wavelog fills those in from a callsign lookup) must not count before it is confirmed.
|
||||
* The roamed set is the operator's own operating location, not the opposite station's
|
||||
* confirmation, so it keeps taking every satellite record with a valid own grid.
|
||||
*/
|
||||
fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
|
||||
val grids = linkedSetOf<String>()
|
||||
@@ -73,6 +102,7 @@ fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
|
||||
.filter(::isValidGrid4)
|
||||
.toSet()
|
||||
roamed += myGrids
|
||||
if (!record.isQslConfirmed()) return@forEach
|
||||
val qso = GridQso(
|
||||
call = record.theirCallsign,
|
||||
epochMs = record.startUtcMillis,
|
||||
@@ -100,14 +130,16 @@ fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
|
||||
/**
|
||||
* Applies a successful Wavelog sync to the stored map data and sync bookkeeping.
|
||||
*
|
||||
* Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the
|
||||
* incremental and the full re-pull merge into the stored sets, matching how the LoTW
|
||||
* sync treats stale data — a full re-pull heals missing records but never deletes map
|
||||
* data the app already knows about. Cursors are replaced wholesale ([cursors] covers
|
||||
* every station just fetched), and the sync URL/epoch advance so the next run resumes
|
||||
* incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO
|
||||
* JSON blocks the caller, and for large accounts that froze the main thread after a
|
||||
* manual sync.
|
||||
* An incremental pull MERGES into the stored sets (union: a late confirmation only ever
|
||||
* adds a square). A full pull REPLACES them — same contract as the LoTW full sync — so the
|
||||
* squares left behind by unconfirmed records that used to be counted disappear, and so do
|
||||
* rows the log no longer has. A full pull that lost a station is partial: it degrades to
|
||||
* the merge instead of wiping the squares that station contributed.
|
||||
*
|
||||
* Cursors are replaced wholesale ([cursors] covers every station just fetched), and the
|
||||
* sync URL/epoch advance so the next run resumes incrementally. Persistence runs on the IO
|
||||
* dispatcher — building the per-grid QSO JSON blocks the caller, and for large accounts that
|
||||
* froze the main thread after a manual sync.
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
@@ -116,18 +148,22 @@ suspend fun applyWavelogSyncResult(
|
||||
records: List<QsoRecord>,
|
||||
cursors: Map<String, Long>,
|
||||
url: String,
|
||||
mode: WavelogSyncMode,
|
||||
failedStations: Set<String> = emptySet(),
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val data = buildWavelogGridData(records)
|
||||
val replace = mode == WavelogSyncMode.Full && failedStations.isEmpty()
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = existingGrids + data.grids
|
||||
val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos)
|
||||
val mergedRoamed = existingRoamed + data.roamedGrids
|
||||
val mergedGrids = if (replace) data.grids else existingGrids + data.grids
|
||||
val mergedQsos = if (replace) data.gridQsos else mergeGridQsos(existingQsos, data.gridQsos)
|
||||
val mergedRoamed = if (replace) data.roamedGrids else existingRoamed + data.roamedGrids
|
||||
settingsRepo.setWavelogSyncCursors(cursors)
|
||||
settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url))
|
||||
settingsRepo.setLastWavelogSyncEpochMs(now)
|
||||
if (replace) settingsRepo.setLastWavelogFullSyncEpochMs(now)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
|
||||
/**
|
||||
* The transceiver a contact on this satellite is logged through: the FM voice repeater when the
|
||||
* bird carries one (the radar Log tab logs through it too), else the first entry publishing both
|
||||
* bands. The second case is what gives a linear satellite — AO-7, RS-44 — a starting pair; the
|
||||
* operator edits the band when the bird happens to be worked the other way round.
|
||||
*
|
||||
* Dead entries are not skipped: a stale alive flag (AO-7 while its battery is flat) must not stop
|
||||
* the prefill, and the operator can always overwrite what gets filled in.
|
||||
*/
|
||||
fun List<SatRadio>.nominalTransceiver(): SatRadio? =
|
||||
voiceRepeater() ?: firstOrNull { it.uplinkHz() != null && it.downlinkHz() != null }
|
||||
|
||||
/**
|
||||
* Catalog number of the entry whose identity matches [name], looked up in a catnum -> name map
|
||||
* holding the tracker spellings ("SAUDISAT 1C" is where SO-50 lives).
|
||||
*/
|
||||
fun Map<Int, String>.catNumForName(name: String): Int? {
|
||||
val wanted = satelliteIdentity(name)
|
||||
if (wanted.isBlank()) return null
|
||||
return entries.firstOrNull { satelliteIdentity(it.value) == wanted }?.key
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.isQslConfirmed
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -124,6 +125,22 @@ class AdifCodecTest {
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeReadsEveryConfirmationRouteWavelogCounts() {
|
||||
// Wavelog's qsl_filter treats lotw / qsl / eqsl alike; the pull must see all three,
|
||||
// and an unconfirmed record must stay unconfirmed.
|
||||
val adi = "<EOH><CALL:6>BG5JVM<QSO_DATE:8>20260916<TIME_ON:4>0745<LOTW_QSL_RCVD:1>Y<EOR>" +
|
||||
"<CALL:6>BG5JVN<QSO_DATE:8>20260916<TIME_ON:4>0746<QSL_RCVD:1>Y<EOR>" +
|
||||
"<CALL:6>BG5JVO<QSO_DATE:8>20260916<TIME_ON:4>0747<EQSL_QSL_RCVD:1>Y<EOR>" +
|
||||
"<CALL:6>BG5JVP<QSO_DATE:8>20260916<TIME_ON:4>0748<EOR>"
|
||||
val records = AdifCodec.decode(adi)
|
||||
assertTrue(records[0].isQslConfirmed())
|
||||
assertTrue(records[1].isQslConfirmed())
|
||||
assertTrue(records[2].isQslConfirmed())
|
||||
assertTrue(!records[3].isQslConfirmed())
|
||||
assertTrue(records[3].lotwConfirmed.not() && records[3].qslConfirmed.not() && records[3].eqslConfirmed.not())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() {
|
||||
// The reported defect: an operator's stamped own grid set must never land in
|
||||
|
||||
+2
@@ -284,6 +284,8 @@ class LoTWGridSyncTest {
|
||||
override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value }
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
|
||||
+107
-4
@@ -35,6 +35,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -124,6 +125,35 @@ class WavelogSyncTest {
|
||||
assertEquals(listOf("EN52"), qso.theirGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataSkipsUnconfirmedContactsButKeepsTheRoamedGrid() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62", confirmed = false),
|
||||
record(start = 2_000L, theirGrid = "EN53", myGrid = "OL63", confirmed = false)
|
||||
)
|
||||
)
|
||||
// Wavelog fills the opposite station's grid in from a callsign lookup; an unconfirmed
|
||||
// contact must not turn a square green even though the grid is there.
|
||||
assertTrue(data.grids.isEmpty())
|
||||
assertTrue(data.gridQsos.isEmpty())
|
||||
// Where the operator was is not something the opposite station confirms.
|
||||
assertEquals(setOf("OL62", "OL63"), data.roamedGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gridDataAcceptsPaperAndEqslConfirmations() {
|
||||
val data = buildWavelogGridData(
|
||||
listOf(
|
||||
record(start = 1_000L, theirGrid = "EN52", confirmed = false, paper = true),
|
||||
record(start = 2_000L, theirGrid = "EN53", confirmed = false, eqsl = true)
|
||||
)
|
||||
)
|
||||
assertEquals(setOf("EN52", "EN53"), data.grids)
|
||||
assertEquals(1, data.gridQsos.getValue("EN52").size)
|
||||
assertEquals(1, data.gridQsos.getValue("EN53").size)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region # applyWavelogSyncResult
|
||||
@@ -138,6 +168,7 @@ class WavelogSyncTest {
|
||||
listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")),
|
||||
mapOf("1" to 500L),
|
||||
"http://a/wavelog/",
|
||||
WavelogSyncMode.Incremental,
|
||||
now = 42L
|
||||
)
|
||||
|
||||
@@ -147,6 +178,7 @@ class WavelogSyncTest {
|
||||
assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors())
|
||||
assertEquals("http://a/wavelog", settings.getWavelogSyncUrl())
|
||||
assertEquals(42L, settings.getLastWavelogSyncEpochMs())
|
||||
assertEquals(0L, settings.getLastWavelogFullSyncEpochMs())
|
||||
assertEquals(2, count)
|
||||
}
|
||||
|
||||
@@ -156,14 +188,76 @@ class WavelogSyncTest {
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
settings.setRoamedGrids(setOf("OM60"))
|
||||
|
||||
applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a")
|
||||
applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a")
|
||||
applyWavelogSyncResult(
|
||||
settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a",
|
||||
WavelogSyncMode.Incremental
|
||||
)
|
||||
applyWavelogSyncResult(
|
||||
settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a",
|
||||
WavelogSyncMode.Incremental
|
||||
)
|
||||
|
||||
assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids())
|
||||
assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncReplacesTheStoredSets() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
// A square kept from before the confirmation rule existed, plus one that is real now.
|
||||
settings.setWorkedGrids(setOf("PM01", "EN52"))
|
||||
settings.setWorkedGridQsos(mapOf("PM01" to emptyList()))
|
||||
settings.setRoamedGrids(setOf("OM60", "OL62"))
|
||||
|
||||
val count = applyWavelogSyncResult(
|
||||
settings,
|
||||
listOf(record(1_000L, "EN52", myGrid = "OL62")),
|
||||
mapOf("1" to 10L),
|
||||
"http://a",
|
||||
WavelogSyncMode.Full,
|
||||
now = 99L
|
||||
)
|
||||
|
||||
assertEquals(setOf("EN52"), settings.getWorkedGrids())
|
||||
assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys)
|
||||
assertEquals(setOf("OL62"), settings.getRoamedGrids())
|
||||
assertEquals(99L, settings.getLastWavelogFullSyncEpochMs())
|
||||
assertEquals(1, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullSyncWithAFailedStationDegradesToMerge() = runBlocking {
|
||||
val settings = FakeSettings()
|
||||
settings.setWorkedGrids(setOf("PM01"))
|
||||
|
||||
applyWavelogSyncResult(
|
||||
settings,
|
||||
listOf(record(1_000L, "EN52")),
|
||||
mapOf("1" to 10L),
|
||||
"http://a",
|
||||
WavelogSyncMode.Full,
|
||||
failedStations = setOf("2"),
|
||||
now = 99L
|
||||
)
|
||||
|
||||
// Partial pull: never wipe what the station that failed contributed.
|
||||
assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids())
|
||||
assertEquals(0L, settings.getLastWavelogFullSyncEpochMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incrementalRequestIsPromotedOnceTheLastFullScanIsStale() {
|
||||
val day = 24L * 60 * 60 * 1000
|
||||
assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 0L, now = day))
|
||||
assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 0L, now = 1000L * day))
|
||||
assertEquals(
|
||||
false,
|
||||
shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 10 * day, now = 12 * day)
|
||||
)
|
||||
assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 10 * day, now = 13 * day))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun record(
|
||||
@@ -171,7 +265,10 @@ class WavelogSyncTest {
|
||||
theirGrid: String = "",
|
||||
theirVucc: List<String> = emptyList(),
|
||||
myGrid: String = "OL62TI",
|
||||
sat: String = "SO-50"
|
||||
sat: String = "SO-50",
|
||||
confirmed: Boolean = true,
|
||||
paper: Boolean = false,
|
||||
eqsl: Boolean = false
|
||||
) = QsoRecord(
|
||||
startUtcMillis = start,
|
||||
theirCallsign = "BG5JVM",
|
||||
@@ -186,7 +283,10 @@ class WavelogSyncTest {
|
||||
mode = "FM",
|
||||
satelliteName = sat,
|
||||
propagationMode = if (sat.isNotBlank()) "SAT" else "",
|
||||
status = QsoStatus.COMPLETE
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = confirmed,
|
||||
qslConfirmed = paper,
|
||||
eqslConfirmed = eqsl
|
||||
)
|
||||
|
||||
private class FakeSettings : ISettingsRepo {
|
||||
@@ -196,6 +296,7 @@ class WavelogSyncTest {
|
||||
private var cursors: Map<String, Long> = emptyMap()
|
||||
private var syncUrl: String = ""
|
||||
private var lastSync: Long = 0L
|
||||
private var lastFullSync: Long = 0L
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||
@@ -209,6 +310,8 @@ class WavelogSyncTest {
|
||||
override fun setWavelogSyncUrl(url: String) { syncUrl = url }
|
||||
override fun getLastWavelogSyncEpochMs(): Long = lastSync
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value }
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long = lastFullSync
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) { lastFullSync = value }
|
||||
|
||||
override val appVersionName: String get() = TODO()
|
||||
override val selectedIds: StateFlow<List<Int>> get() = TODO()
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NominalTransceiverTest {
|
||||
|
||||
@Test
|
||||
fun `the voice repeater wins over a linear entry listed first`() {
|
||||
val radios = listOf(
|
||||
radio("lin", "Mode U/V Linear", uplink = 435_100_000L, downlink = 145_950_000L, mode = "USB"),
|
||||
radio("fm", "Mode V/U FM Voice CTCSS 67.0 Hz", uplink = 145_850_000L, downlink = 436_795_000L)
|
||||
)
|
||||
assertEquals("fm", radios.nominalTransceiver()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a linear bird falls back to the first entry with both bands`() {
|
||||
val radios = listOf(
|
||||
radio("beacon", "Beacon", uplink = null, downlink = 145_950_000L, mode = "CW"),
|
||||
radio("vu", "Mode V/U Linear", uplink = 145_950_000L, downlink = 435_600_000L, mode = "USB")
|
||||
)
|
||||
assertEquals("vu", radios.nominalTransceiver()?.uuid)
|
||||
assertEquals(145_950_000L, radios.nominalTransceiver()?.uplinkHz())
|
||||
assertEquals(435_600_000L, radios.nominalTransceiver()?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a published passband yields its middle`() {
|
||||
val radios = listOf(
|
||||
radio(
|
||||
"vu", "Mode V/U Linear", uplink = 435_000_000L, downlink = 145_900_000L,
|
||||
uplinkHigh = 435_300_000L, downlinkHigh = 146_000_000L, mode = "USB"
|
||||
)
|
||||
)
|
||||
assertEquals(435_150_000L, radios.nominalTransceiver()?.uplinkHz())
|
||||
assertEquals(145_950_000L, radios.nominalTransceiver()?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stale alive flag does not stop the prefill`() {
|
||||
val radios = listOf(
|
||||
radio("ao7", "Mode U/V Linear", uplink = 432_150_000L, downlink = 145_950_000L, isAlive = false, mode = "USB")
|
||||
)
|
||||
assertEquals("ao7", radios.nominalTransceiver()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no transceiver with both bands yields null`() {
|
||||
val radios = listOf(
|
||||
radio("beacon", "Beacon", uplink = null, downlink = 145_950_000L, mode = "CW"),
|
||||
radio("vu", "Mode V/U Linear", uplink = null, downlink = 435_600_000L, mode = "USB")
|
||||
)
|
||||
assertNull(radios.nominalTransceiver())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the catnum lookup matches through the alias table`() {
|
||||
val names = mapOf(
|
||||
27607 to "SAUDISAT 1C",
|
||||
7530 to "AO-7 (AMSAT-OSCAR 7)",
|
||||
25544 to "ISS (ZARYA)"
|
||||
)
|
||||
assertEquals(27607, names.catNumForName("SO-50"))
|
||||
assertEquals(7530, names.catNumForName("AO-7"))
|
||||
assertEquals(25544, names.catNumForName("ARISS"))
|
||||
assertNull(names.catNumForName("NOT A SATELLITE"))
|
||||
}
|
||||
|
||||
private fun radio(
|
||||
uuid: String,
|
||||
info: String,
|
||||
uplink: Long?,
|
||||
downlink: Long?,
|
||||
isAlive: Boolean = true,
|
||||
uplinkHigh: Long? = null,
|
||||
downlinkHigh: Long? = null,
|
||||
mode: String = "FM"
|
||||
) = SatRadio(
|
||||
uuid = uuid,
|
||||
info = info,
|
||||
isAlive = isAlive,
|
||||
downlinkLow = downlink,
|
||||
downlinkHigh = downlinkHigh,
|
||||
downlinkMode = mode,
|
||||
uplinkLow = uplink,
|
||||
uplinkHigh = uplinkHigh,
|
||||
uplinkMode = mode,
|
||||
isInverted = false,
|
||||
catnum = 1
|
||||
)
|
||||
}
|
||||
+6
-2
@@ -351,6 +351,7 @@ fun SharedDialog(
|
||||
onAccept: () -> Unit,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
cancelText: String? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(
|
||||
@@ -359,7 +360,8 @@ fun SharedDialog(
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
acceptText = acceptText,
|
||||
acceptEnabled = acceptEnabled
|
||||
acceptEnabled = acceptEnabled,
|
||||
cancelText = cancelText
|
||||
) { _ ->
|
||||
content()
|
||||
}
|
||||
@@ -373,6 +375,8 @@ fun SharedDialog(
|
||||
onAccept: (() -> Unit)? = null,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
/** Label of the leading button; [R.string.btn_cancel] when null. */
|
||||
cancelText: String? = null,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
@@ -385,7 +389,7 @@ fun SharedDialog(
|
||||
.padding(start = padding, top = padding, end = padding)
|
||||
) {
|
||||
if (onCancel != null) {
|
||||
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
|
||||
CardButton(onClick = onCancel, text = cancelText ?: stringResource(R.string.btn_cancel))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
|
||||
+50
-29
@@ -36,6 +36,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
@@ -61,10 +63,13 @@ private const val DATE_PATTERN = "yyyy-MM-dd"
|
||||
private const val TIME_PATTERN = "HH:mm:ss"
|
||||
|
||||
/**
|
||||
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
|
||||
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
|
||||
* re-derived from them, because that derivation is what LoTW validates: a record with
|
||||
* no uplink frequency has no BAND and cannot be signed.
|
||||
* Edit a recorded QSO — the record list rows in the radar Log tab and the logbook dialog
|
||||
* both open this, and the logbook's Create button opens it on a blank draft. The opposite
|
||||
* grid is read-only: an unconfirmed QSO has no opposite grid of its own, LoTW's confirmation
|
||||
* is what brings the real one in. The sent/received reports and the comment are not part of
|
||||
* the log any more, so they are neither shown nor rewritten here. Frequencies are entered in
|
||||
* MHz and the band is re-derived from them, because that derivation is what LoTW validates:
|
||||
* a record with no uplink frequency has no BAND and cannot be signed.
|
||||
*
|
||||
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
|
||||
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
|
||||
@@ -75,6 +80,8 @@ private const val TIME_PATTERN = "HH:mm:ss"
|
||||
fun QsoEditDialog(
|
||||
record: QsoRecord,
|
||||
satelliteCandidates: List<String> = emptyList(),
|
||||
/** Nominal (no Doppler) uplink/downlink of a named satellite, prefilled into a blank draft. */
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (QsoRecord) -> Unit
|
||||
) {
|
||||
@@ -90,10 +97,6 @@ fun QsoEditDialog(
|
||||
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
|
||||
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
|
||||
var pickerOpen by remember(record.id) { mutableStateOf(false) }
|
||||
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
|
||||
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
|
||||
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
|
||||
var commentText by remember(record.id) { mutableStateOf(record.comment) }
|
||||
var error by remember(record.id) { mutableStateOf("") }
|
||||
|
||||
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
|
||||
@@ -104,6 +107,28 @@ fun QsoEditDialog(
|
||||
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
|
||||
}
|
||||
|
||||
// A blank draft gets the satellite's nominal pair as soon as its name resolves — the same
|
||||
// frequencies the radar Log tab logs through, with no Doppler shift applied. Listed records
|
||||
// keep whatever they were logged with, and a pair the operator edited by hand survives a
|
||||
// satellite change: AO-7 is worked on either band, so a manual band switch must stick.
|
||||
var autoTx by remember(record.id) { mutableStateOf("") }
|
||||
var autoRx by remember(record.id) { mutableStateOf("") }
|
||||
val fillName = officialSatellite ?: satelliteText.trim()
|
||||
LaunchedEffect(record.id, fillName) {
|
||||
if (record.id != 0L || fillName.isBlank()) return@LaunchedEffect
|
||||
val nominal = nominalFrequencies(fillName) ?: return@LaunchedEffect
|
||||
val tx = formatMhz(nominal.uplinkHz)
|
||||
val rx = formatMhz(nominal.downlinkHz)
|
||||
if (tx.isNotBlank() && (txText.isBlank() || txText == autoTx)) {
|
||||
txText = tx
|
||||
autoTx = tx
|
||||
}
|
||||
if (rx.isNotBlank() && (rxText.isBlank() || rxText == autoRx)) {
|
||||
rxText = rx
|
||||
autoRx = rx
|
||||
}
|
||||
}
|
||||
|
||||
val txHz = parseMhz(txText)
|
||||
val rxHz = parseMhz(rxText)
|
||||
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
|
||||
@@ -207,22 +232,7 @@ fun QsoEditDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
sentText = it
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
receivedText = it
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
gridText = it.uppercase(Locale.US)
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
commentText = it
|
||||
}
|
||||
}
|
||||
ReadOnlyField(stringResource(R.string.qso_edit_grid), record.theirGrid)
|
||||
if (txHz == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_warn_no_freq),
|
||||
@@ -256,9 +266,6 @@ fun QsoEditDialog(
|
||||
theirCallsign = callsign.trim().uppercase(Locale.US),
|
||||
startUtcMillis = millis,
|
||||
endUtcMillis = record.endUtcMillis?.let { millis },
|
||||
theirGrid = gridText.trim().uppercase(Locale.US),
|
||||
sentReport = sentText.trim(),
|
||||
receivedReport = receivedText.trim(),
|
||||
txFrequencyHz = txHz,
|
||||
rxFrequencyHz = rxHz,
|
||||
band = frequencyBand(txHz),
|
||||
@@ -266,8 +273,7 @@ fun QsoEditDialog(
|
||||
mode = if (mode == "FT4") "MFSK" else mode,
|
||||
submode = if (mode == "FT4") "FT4" else "",
|
||||
satelliteName = satellite,
|
||||
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
|
||||
comment = commentText.trim()
|
||||
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -348,6 +354,21 @@ internal fun filteredCandidates(query: String, candidates: List<String>): List<S
|
||||
return matches.ifEmpty { candidates }
|
||||
}
|
||||
|
||||
/** Non-editable look-alike of [EditField], for values the operator must not rewrite. */
|
||||
@Composable
|
||||
private fun ReadOnlyField(label: String, value: String, modifier: Modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = value.ifBlank { "—" },
|
||||
onValueChange = {},
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
readOnly = true,
|
||||
enabled = false,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditField(
|
||||
label: String,
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Traditional-Chinese (zh-Hant) devices fall back to the Simplified Chinese
|
||||
strings in values-zh/ instead of the English default. Android 7+ resolves
|
||||
Chinese by script, so an unqualified values-zh is treated as zh-Hans and is
|
||||
never matched by a zh-TW / zh-HK / zh-MO (Hant) device.
|
||||
|
||||
This file is a verbatim copy of values-zh/strings.xml — do NOT translate it
|
||||
here. Edit values-zh/strings.xml and copy the change over; the unit test
|
||||
TraditionalChineseFallbackTest fails if the two files ever diverge.
|
||||
-->
|
||||
<!-- Commmon -->
|
||||
<string name="btn_accept">确定</string>
|
||||
<string name="btn_cancel">取消</string>
|
||||
<string name="empty_list_message">没有数据可显示</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_sat">卫星</string>
|
||||
<string name="nav_pass">过境</string>
|
||||
<string name="nav_radar">雷达</string>
|
||||
<string name="nav_mutual">匹配</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">类型:%s</string>
|
||||
<string name="sat_type_title">选择卫星类型</string>
|
||||
<string name="sat_search_hint">Id - 名称</string>
|
||||
<string name="sat_search_clear">清空</string>
|
||||
<string name="sat_clear_all">全部清空</string>
|
||||
<string name="sat_select_all">全选</string>
|
||||
<string name="sat_group_selected">已选择</string>
|
||||
<string name="sat_group_available">可用</string>
|
||||
<string name="sat_group_selected_count">已选择(%1$d)</string>
|
||||
<string name="sat_group_available_count">可用(%1$d)</string>
|
||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||
<string name="sat_warning_title">警告\!</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">筛选过境</string>
|
||||
<string name="pass_filter_elev">仰角值</string>
|
||||
<string name="pass_filter_hours">提前时间</string>
|
||||
<string name="pass_filter_history_hours">历史过境时间</string>
|
||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||
<string name="pass_modes_title">选择调制类型</string>
|
||||
<string name="pass_elevation">仰角:%.1f°</string>
|
||||
<string name="pass_altitude">高度:%d km</string>
|
||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
<string name="amsat_updated">更新于:</string>
|
||||
<string name="amsat_active">活跃</string>
|
||||
<string name="amsat_tlm">遥测/信标</string>
|
||||
<string name="amsat_not_heard">未听到</string>
|
||||
<string name="amsat_conflict">冲突</string>
|
||||
<string name="amsat_name">名称</string>
|
||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||
<string name="amsat_no_reports">此时间段无报告</string>
|
||||
<string name="amsat_close">关闭</string>
|
||||
<string name="amsat_upload">上传</string>
|
||||
<string name="amsat_upload_back">返回</string>
|
||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||
<string name="amsat_upload_status">状态</string>
|
||||
<string name="amsat_upload_callsign">呼号</string>
|
||||
<string name="amsat_upload_grid">网格</string>
|
||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm">确认上传</string>
|
||||
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||
<string name="amsat_upload_grid_none">未填写</string>
|
||||
<string name="amsat_upload_success">报告已上传</string>
|
||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||
<string name="amsat_upload_active">活跃</string>
|
||||
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||
<string name="amsat_upload_not_heard">未听到</string>
|
||||
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
<string name="radar_use_large">使用大雷达</string>
|
||||
<string name="radar_use_small">使用小雷达</string>
|
||||
<string name="radar_size_large_short">大</string>
|
||||
<string name="radar_size_small_short">小</string>
|
||||
<string name="radar_notify">通知</string>
|
||||
<string name="radar_az_text">方位角</string>
|
||||
<string name="radar_el_text">仰角</string>
|
||||
<string name="radar_alt_text">高度</string>
|
||||
<string name="radar_dist_text">距离</string>
|
||||
<string name="radar_eclipsed">地影中</string>
|
||||
<string name="radar_downlink">下行链路</string>
|
||||
<string name="radar_uplink">上行链路</string>
|
||||
<string name="radar_no_data">这颗卫星没有收发器</string>
|
||||
<string name="radar_add">添加</string>
|
||||
<string name="radar_data">卫星收发器:</string>
|
||||
<string name="radar_inverted">倒置:%s</string>
|
||||
<string name="radar_mode">调制:%s</string>
|
||||
<string name="radar_string_no">否</string>
|
||||
<string name="radar_string_yes">是</string>
|
||||
<string name="radar_visible">日照中</string>
|
||||
|
||||
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||
|
||||
<string name="radar_cw_decoder">CW 解码器</string>
|
||||
<string name="radar_cw_start">开始</string>
|
||||
<string name="radar_cw_stop">停止</string>
|
||||
<string name="radar_cw_reset">清空</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_first_call_mode">首通呼号</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
<string name="map_distance">距离:%.0f km</string>
|
||||
<string name="map_latitude">纬度:%.1f°</string>
|
||||
<string name="map_longitude">经度:%.1f°</string>
|
||||
<string name="map_phase">相位:%.1f°</string>
|
||||
<string name="map_eclipsed">地影中</string>
|
||||
<string name="map_period">周期:% .0f min</string>
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="region_qso_empty">该区域暂无已确认通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="grid_qso_match">匹配</string>
|
||||
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
|
||||
<string name="grid_mark_station">标记你想通联的电台</string>
|
||||
<string name="grid_mark_callsign">呼号</string>
|
||||
<string name="grid_mark_confirm">标记</string>
|
||||
<string name="grid_mark_label">标记</string>
|
||||
<string name="grid_mark_delete">删除标记</string>
|
||||
<string name="grid_mark_pin">置顶</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
|
||||
|
||||
<string name="prefs_loc_title">站点位置</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||
<string name="prefs_loc_input_title">输入位置</string>
|
||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||
<string name="prefs_loc_success">位置更新成功</string>
|
||||
<string name="prefs_station_title">站点位置设置</string>
|
||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||
<string name="prefs_locator_title">QTH 网格</string>
|
||||
<string name="prefs_wavelog_configure">配置</string>
|
||||
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
|
||||
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
|
||||
<string name="prefs_wavelog_sync_incremental">增量同步</string>
|
||||
<string name="prefs_wavelog_sync_full">全量同步</string>
|
||||
<string name="prefs_wavelog_clear">清除配置</string>
|
||||
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
|
||||
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
|
||||
<string name="prefs_wavelog_switch_title">配置提醒</string>
|
||||
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
|
||||
<string name="prefs_wavelog_switch_continue">继续</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">获取站点</string>
|
||||
<string name="prefs_wavelog_upload_probing">检测中…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
|
||||
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
|
||||
<string name="prefs_lotw_sync">同步</string>
|
||||
<string name="prefs_lotw_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
|
||||
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
|
||||
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
|
||||
<string name="lotw_sync_full">全量同步</string>
|
||||
<string name="lotw_sync_incremental">增量合并</string>
|
||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
|
||||
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
|
||||
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
|
||||
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
|
||||
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_new">新建</string>
|
||||
<string name="prefs_logbook_exit_selection">退出选择</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
|
||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||
<string name="prefs_logbook_callsign_missing">其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。</string>
|
||||
<string name="prefs_logbook_callsign_different">%1$d 条记录登记的不是证书 %2$s 的呼号。</string>
|
||||
<string name="prefs_logbook_callsign_rewrite">改写为 %1$s</string>
|
||||
<string name="prefs_logbook_callsign_switch">换一张证书</string>
|
||||
<string name="prefs_logbook_station_label">台址</string>
|
||||
<string name="prefs_logbook_station_all">全部</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
|
||||
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
|
||||
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
|
||||
<string name="prefs_lotw_upload_error_format">不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试</string>
|
||||
<string name="prefs_lotw_upload_error_format_alg">不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试</string>
|
||||
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
|
||||
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
|
||||
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_region_label">省份</string>
|
||||
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
<string name="prefs_data_radios">收发器:%s</string>
|
||||
<string name="prefs_data_update">在线更新</string>
|
||||
<string name="prefs_data_import">导入文件</string>
|
||||
<string name="prefs_data_clear">清空</string>
|
||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||
<string name="prefs_data_update_success">更新成功</string>
|
||||
|
||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</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>
|
||||
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||
|
||||
<string name="prefs_data_output_title">数据输出</string>
|
||||
<string name="prefs_net_output">网络</string>
|
||||
<string name="prefs_bt_output">蓝牙</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">网络数据输出</string>
|
||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||
|
||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
||||
<string name="prefs_bt_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">数据格式</string>
|
||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">电台控制</string>
|
||||
<string name="rc_settings_title">CAT 电台控制</string>
|
||||
<string name="rc_radio_model">电台型号</string>
|
||||
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_map_title">地图设置</string>
|
||||
<string name="prefs_map_configure">配置地图</string>
|
||||
<string name="prefs_map_source_title">地图来源</string>
|
||||
<string name="prefs_map_source_osm">离线 OSM 地图</string>
|
||||
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
|
||||
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
|
||||
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
|
||||
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
|
||||
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_compass_calibration_button">校准指南针</string>
|
||||
<string name="prefs_compass_calibration_title">指南针校准</string>
|
||||
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
|
||||
<string name="prefs_compass_accuracy">精度:%s</string>
|
||||
<string name="prefs_compass_accuracy_high">高——校准完成</string>
|
||||
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
|
||||
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
|
||||
<string name="prefs_compass_offset">手动航向偏置(°)</string>
|
||||
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
|
||||
<string name="prefs_compass_offset_reset">偏置归零</string>
|
||||
<string name="prefs_other_switch_light_theme">浅色主题</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
<string name="prefs_highlight_title">仰角高亮</string>
|
||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">致谢与声明</string>
|
||||
<string name="prefs_project_wiki">Project Wiki</string>
|
||||
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
|
||||
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
|
||||
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
|
||||
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
|
||||
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
|
||||
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
|
||||
<string name="project_wiki_section_mutual">双站过境匹配</string>
|
||||
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
|
||||
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
|
||||
<string name="project_wiki_section_grids">通联网格地图</string>
|
||||
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
|
||||
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat 所有用户及贡献者!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA BG5JSU(CW解码功能)
|
||||
\n• BH2VSQ BG5JSU(天地图渲染支持)
|
||||
\n• BG5JSU(LoTW上传支持)
|
||||
</string>
|
||||
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
|
||||
上游项目:https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
<string name="mutual_title">过境匹配</string>
|
||||
<string name="mutual_station_you">你的位置</string>
|
||||
<string name="mutual_station_opposite">友台位置</string>
|
||||
<string name="mutual_label_you">你</string>
|
||||
<string name="mutual_label_opposite">友台</string>
|
||||
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
|
||||
<string name="mutual_status_error">请修正提示后重新查询</string>
|
||||
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
|
||||
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
|
||||
<string name="mutual_status_idle">输入双方位置后点击查询</string>
|
||||
<string name="mutual_chip_calculating">计算中</string>
|
||||
<string name="mutual_chip_error">有错误</string>
|
||||
<string name="mutual_chip_matches">%d 个匹配</string>
|
||||
<string name="mutual_chip_none">无匹配</string>
|
||||
<string name="mutual_chip_idle">待查询</string>
|
||||
<string name="mutual_use_current">填入当前位置</string>
|
||||
<string name="mutual_lat">纬度</string>
|
||||
<string name="mutual_lon">经度</string>
|
||||
<string name="mutual_grid">网格</string>
|
||||
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||
<string name="mutual_time_range">时间范围</string>
|
||||
<string name="mutual_filter_transponder">转发器</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">线性</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
<string name="mutual_elevation_opposite">友台:%d°</string>
|
||||
<string name="mutual_both_tracks">双方轨迹</string>
|
||||
<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>
|
||||
|
||||
<!-- QSO edit dialog -->
|
||||
<string name="qso_edit_title">编辑通联记录</string>
|
||||
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
|
||||
<string name="qso_edit_callsign">呼号</string>
|
||||
<string name="qso_edit_date">日期 (UTC)</string>
|
||||
<string name="qso_edit_time">时间 (UTC)</string>
|
||||
<string name="qso_edit_tx">上行频率 (MHz)</string>
|
||||
<string name="qso_edit_rx">下行频率 (MHz)</string>
|
||||
<string name="qso_edit_band">频段:%1$s</string>
|
||||
<string name="qso_edit_mode">模式</string>
|
||||
<string name="qso_edit_satellite">卫星</string>
|
||||
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
|
||||
<string name="qso_edit_sent">发送报告</string>
|
||||
<string name="qso_edit_received">接收报告</string>
|
||||
<string name="qso_edit_grid">对方网格</string>
|
||||
<string name="qso_edit_comment">备注</string>
|
||||
<string name="qso_edit_save">保存</string>
|
||||
<string name="qso_edit_cancel">取消</string>
|
||||
<string name="log_out_window_title">不在过境窗口</string>
|
||||
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
|
||||
<string name="log_out_window_confirm">录入</string>
|
||||
<string name="qso_edit_err_callsign">呼号不能为空</string>
|
||||
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
|
||||
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
|
||||
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
|
||||
<string name="qso_edit_satellite_pick">选择</string>
|
||||
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
|
||||
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
|
||||
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
|
||||
|
||||
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
|
||||
<string name="gridfinder_title">网格寻线</string>
|
||||
<string name="gridfinder_status_corner">位于网格点上</string>
|
||||
<string name="gridfinder_status_line">位于网格线上</string>
|
||||
<string name="gridfinder_status_inside">在网格内部</string>
|
||||
<string name="gridfinder_status_unknown">位置不可用</string>
|
||||
<string name="gridfinder_status_waiting">正在获取定位…</string>
|
||||
<string name="gridfinder_status_blocked">需要定位权限</string>
|
||||
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
|
||||
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
|
||||
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
|
||||
<string name="gridfinder_grant_permission">授予定位权限</string>
|
||||
<string name="gridfinder_card_location">位置</string>
|
||||
<string name="gridfinder_card_corner">最近网格点引导</string>
|
||||
<string name="gridfinder_card_line">最近网格线引导</string>
|
||||
<string name="gridfinder_card_lines">两条边界线</string>
|
||||
<string name="gridfinder_card_map">近距离网格图</string>
|
||||
<string name="gridfinder_card_fix">定位质量</string>
|
||||
<string name="gridfinder_card_compass">罗盘</string>
|
||||
<string name="gridfinder_label_claimable">此处可计分的网格</string>
|
||||
<string name="gridfinder_label_grid4">网格 (4)</string>
|
||||
<string name="gridfinder_label_grid6">网格 (6)</string>
|
||||
<string name="gridfinder_label_grid8">网格 (8)</string>
|
||||
<string name="gridfinder_label_latitude">纬度</string>
|
||||
<string name="gridfinder_label_longitude">经度</string>
|
||||
<string name="gridfinder_label_altitude">海拔</string>
|
||||
<string name="gridfinder_label_accuracy">水平精度</string>
|
||||
<string name="gridfinder_label_fix_age">定位时刻</string>
|
||||
<string name="gridfinder_label_provider">定位来源</string>
|
||||
<string name="gridfinder_value_provider_on">已开启</string>
|
||||
<string name="gridfinder_value_provider_off">未开启</string>
|
||||
<string name="gridfinder_label_distance">距离</string>
|
||||
<string name="gridfinder_label_bearing">方位角</string>
|
||||
<string name="gridfinder_label_direction">方向</string>
|
||||
<string name="gridfinder_label_grids">网格</string>
|
||||
<string name="gridfinder_label_lat_line">纬线</string>
|
||||
<string name="gridfinder_label_lon_line">经线</string>
|
||||
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
|
||||
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
|
||||
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
|
||||
<string name="gridfinder_label_heading">朝向</string>
|
||||
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
|
||||
<string name="gridfinder_action_set_station">设为我的台址</string>
|
||||
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
|
||||
<string name="gridfinder_action_calibrate">校准罗盘</string>
|
||||
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
|
||||
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
|
||||
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">北</string>
|
||||
<string name="gridfinder_direction_south">南</string>
|
||||
<string name="gridfinder_direction_east">东</string>
|
||||
<string name="gridfinder_direction_west">西</string>
|
||||
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
|
||||
|
||||
</resources>
|
||||
@@ -82,6 +82,10 @@
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
<string name="radar_use_large">使用大雷达</string>
|
||||
<string name="radar_use_small">使用小雷达</string>
|
||||
<string name="radar_size_large_short">大</string>
|
||||
<string name="radar_size_small_short">小</string>
|
||||
<string name="radar_notify">通知</string>
|
||||
<string name="radar_az_text">方位角</string>
|
||||
<string name="radar_el_text">仰角</string>
|
||||
@@ -211,6 +215,8 @@
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_new">新建</string>
|
||||
<string name="prefs_logbook_exit_selection">退出选择</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
<string name="radar_use_large">Use large radar</string>
|
||||
<string name="radar_use_small">Use small radar</string>
|
||||
<string name="radar_size_large_short">L</string>
|
||||
<string name="radar_size_small_short">S</string>
|
||||
<string name="radar_notify">Notify</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||
@@ -240,6 +244,8 @@
|
||||
<string name="btn_delete">Delete</string>
|
||||
<string name="prefs_logbook_title">Logbook</string>
|
||||
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
||||
<string name="prefs_logbook_new">New</string>
|
||||
<string name="prefs_logbook_exit_selection">Exit selection</string>
|
||||
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
||||
<string name="prefs_logbook_close">Close</string>
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.presentation
|
||||
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
|
||||
/**
|
||||
* Android 7+ resolves Chinese by script, so an unqualified values-zh is treated as
|
||||
* zh-Hans and a Traditional device (zh-TW / zh-HK / zh-MO, i.e. zh-Hant) never matches
|
||||
* it — it used to fall back to the English default. values-b+zh+Hant carries the
|
||||
* Simplified text for those devices, and the locale filter in the convention plugin
|
||||
* keeps that config in the APK.
|
||||
*
|
||||
* Both halves fail silently at runtime (English strings, or a Hant config dropped from
|
||||
* the resource table), so they are pinned here: keep the Hant copy verbatim and keep
|
||||
* the Traditional locales in the locale filter.
|
||||
*/
|
||||
class TraditionalChineseFallbackTest {
|
||||
|
||||
private val simplified = stringEntries("src/main/res/values-zh/strings.xml")
|
||||
private val traditional = stringEntries("src/main/res/values-b+zh+Hant/strings.xml")
|
||||
private val conventionPlugin = generateSequence(File("").absoluteFile) { it.parentFile }
|
||||
.map { File(it, "build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt") }
|
||||
.first { it.isFile }
|
||||
|
||||
@Test
|
||||
fun `hant fallback is a verbatim copy of the simplified strings`() {
|
||||
assertTrue("values-zh/strings.xml looks empty or unparsable", simplified.size > 400)
|
||||
val missing = (simplified.keys - traditional.keys).sorted()
|
||||
val extra = (traditional.keys - simplified.keys).sorted()
|
||||
val changed = simplified.keys.intersect(traditional.keys)
|
||||
.filter { simplified[it] != traditional[it] }
|
||||
.sorted()
|
||||
assertEquals("keys missing from values-b+zh+Hant", emptyList<String>(), missing)
|
||||
assertEquals("keys only present in values-b+zh+Hant", emptyList<String>(), extra)
|
||||
assertEquals("values-b+zh+Hant diverged from values-zh (copy the change over)", emptyList<String>(), changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traditional locales survive the locale filter`() {
|
||||
assertTrue("convention plugin not found: ${conventionPlugin.absolutePath}", conventionPlugin.isFile)
|
||||
// Comments may mention either spelling, so only the code lines are inspected.
|
||||
val filters = conventionPlugin.readLines()
|
||||
.filterNot { it.trimStart().startsWith("//") }
|
||||
.joinToString("\n")
|
||||
val traditionalLocales = listOf("zh-rTW", "zh-rHK", "zh-rMO")
|
||||
val dropped = traditionalLocales.filterNot { filters.contains("\"$it\"") }
|
||||
assertEquals(
|
||||
"localeFilters must list the Traditional locales, otherwise aapt2 strips" +
|
||||
" values-b+zh+Hant from the APK and Traditional devices get English",
|
||||
emptyList<String>(),
|
||||
dropped
|
||||
)
|
||||
// aapt2 rejects the script spelling, so it must not creep back into the filter.
|
||||
assertTrue("localeFilters must use the aapt2 'zh-rTW' spelling, not 'zh-Hant'", !filters.contains("\"zh-Hant\""))
|
||||
}
|
||||
|
||||
private fun stringEntries(path: String): Map<String, String> {
|
||||
val file = File(path)
|
||||
assertTrue("resource file not found: ${file.absolutePath}", file.isFile)
|
||||
val document = DocumentBuilderFactory.newInstance()
|
||||
.apply { isNamespaceAware = false }
|
||||
.newDocumentBuilder()
|
||||
.parse(file)
|
||||
val nodes = document.getElementsByTagName("string")
|
||||
return (0 until nodes.length).associate {
|
||||
val element = nodes.item(it) as Element
|
||||
element.getAttribute("name") to element.textContent.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ 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.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -63,6 +64,7 @@ class FakeSatelliteRepo(
|
||||
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()
|
||||
override suspend fun nominalFrequencies(name: String): NominalFrequencies? = TODO()
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
@@ -159,4 +161,6 @@ class FakeSettingsRepo(
|
||||
override fun setWavelogSyncUrl(url: String) = Unit
|
||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
||||
override fun getLastWavelogFullSyncEpochMs(): Long = 0L
|
||||
override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -62,6 +63,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -284,6 +287,7 @@ private fun RadarScreen(
|
||||
// row, the radar keeps the largest square it can, the pager yields (reserved block when
|
||||
// the window is tall enough, overlaid tab strip + panel when it is not).
|
||||
val fillRadar = useFillRadarLayout(isVertical, maxWidth, maxHeight)
|
||||
var useLargeRadar by remember(fillRadar) { mutableStateOf(fillRadar) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
@@ -297,7 +301,15 @@ private fun RadarScreen(
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
TopBar {
|
||||
NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc)
|
||||
if (fillRadar) {
|
||||
RadarSizeToggle(
|
||||
showingLargeRadar = useLargeRadar,
|
||||
onToggle = { useLargeRadar = !useLargeRadar }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
@@ -307,7 +319,7 @@ private fun RadarScreen(
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
if (fillRadar) {
|
||||
if (fillRadar && useLargeRadar) {
|
||||
RadarFillArea(
|
||||
uiState = uiState,
|
||||
trackB = trackB,
|
||||
@@ -332,6 +344,32 @@ private fun RadarScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarSizeToggle(showingLargeRadar: Boolean, onToggle: () -> Unit) {
|
||||
val label = stringResource(
|
||||
if (showingLargeRadar) R.string.radar_use_small else R.string.radar_use_large
|
||||
)
|
||||
ElevatedCard(
|
||||
modifier = Modifier.size(48.dp),
|
||||
onClick = onToggle
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (showingLargeRadar) R.string.radar_size_small_short
|
||||
else R.string.radar_size_large_short
|
||||
),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radar area for split-screen / small windows. The radar takes the largest square the area
|
||||
* can hold so the circle spans the same share of the width it does full screen, and the
|
||||
|
||||
+17
-2
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
@@ -90,6 +91,10 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
|
||||
fun LogbookDialog(
|
||||
records: List<QsoRecord>,
|
||||
satelliteCandidates: List<String>,
|
||||
/** Blank draft the header's Create button opens (own callsign/grid come from the certificate). */
|
||||
newRecord: () -> QsoRecord,
|
||||
/** Nominal frequencies handed to the edit dialog, which prefills a blank draft with them. */
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
uploadBusy: Boolean,
|
||||
uploadMessage: String,
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||
@@ -125,12 +130,21 @@ fun LogbookDialog(
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
|
||||
// Record currently open in the edit dialog (null when closed).
|
||||
// Record currently open in the edit dialog — a listed row, or the blank draft the Create
|
||||
// button just made. A draft carries id = 0, so saving it inserts a new row.
|
||||
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
||||
// Header leading button: Create while idle, Leave-selection while rows are checked.
|
||||
// Cancel is gone; its two jobs are split over those two labels.
|
||||
val onHeader = if (selectionMode) onExitSelection else ({ editTarget = newRecord() })
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_logbook_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = if (selectionMode) onExitSelection else onDismiss,
|
||||
onCancel = onHeader,
|
||||
cancelText = if (selectionMode) {
|
||||
stringResource(R.string.prefs_logbook_exit_selection)
|
||||
} else {
|
||||
stringResource(R.string.prefs_logbook_new)
|
||||
},
|
||||
onAccept = if (selectionMode) onResubmitSelected else onUpload,
|
||||
acceptText = if (selectionMode) {
|
||||
stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
|
||||
@@ -272,6 +286,7 @@ fun LogbookDialog(
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
satelliteCandidates = satelliteCandidates,
|
||||
nominalFrequencies = nominalFrequencies,
|
||||
onDismiss = { editTarget = null },
|
||||
onSave = { updated ->
|
||||
onEdit(updated)
|
||||
|
||||
+21
@@ -67,7 +67,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
|
||||
@@ -102,6 +105,7 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
||||
SettingsScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
nominalFrequencies = viewModel::nominalFrequencies,
|
||||
onOpenGridFinder = onOpenGridFinder,
|
||||
pendingStationGrid = pendingStationGrid.orEmpty(),
|
||||
onFixStationGrid = { grids ->
|
||||
@@ -115,6 +119,7 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
||||
private fun SettingsScreen(
|
||||
uiState: SettingsState,
|
||||
onAction: (SettingsAction) -> Unit,
|
||||
nominalFrequencies: suspend (String) -> NominalFrequencies? = { null },
|
||||
onOpenGridFinder: () -> Unit,
|
||||
pendingStationGrid: String = "",
|
||||
onFixStationGrid: (List<String>) -> Unit = {},
|
||||
@@ -292,6 +297,22 @@ private fun SettingsScreen(
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
|
||||
satelliteCandidates = uiState.satelliteCatalog,
|
||||
newRecord = {
|
||||
// Blank draft: minute-truncated "now" (the radar Log tab's granularity), the
|
||||
// certificate's callsign and the station location's first grid, so the record
|
||||
// is uploadable as it stands.
|
||||
val now = System.currentTimeMillis() / 60_000L * 60_000L
|
||||
QsoRecord(
|
||||
startUtcMillis = now,
|
||||
endUtcMillis = now,
|
||||
theirCallsign = "",
|
||||
myCallsign = uiState.lotwCertificate?.callsign.orEmpty(),
|
||||
myGrid = uiState.lotwStation?.grid?.substringBefore(',')?.trim()
|
||||
?.uppercase(Locale.US).orEmpty(),
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
},
|
||||
nominalFrequencies = nominalFrequencies,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
preview = uiState.logbookPreview,
|
||||
|
||||
+34
-5
@@ -23,8 +23,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.model.NominalFrequencies
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
@@ -41,6 +43,7 @@ import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.shouldPromoteWavelogFullSync
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
@@ -71,6 +74,7 @@ class SettingsViewModel(
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
@@ -390,6 +394,11 @@ class SettingsViewModel(
|
||||
return
|
||||
}
|
||||
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
|
||||
// Late confirmations can only be picked up by a full pull (Wavelog's incremental
|
||||
// cursor is a row id), so promote a stale incremental request to a full re-scan.
|
||||
val promoted = mode == WavelogSyncMode.Incremental &&
|
||||
shouldPromoteWavelogFullSync(settingsRepo.getLastWavelogFullSyncEpochMs())
|
||||
val actualMode = if (promoted) WavelogSyncMode.Full else mode
|
||||
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -398,7 +407,7 @@ class SettingsViewModel(
|
||||
settings.apiKey,
|
||||
stations,
|
||||
settingsRepo.getWavelogSyncCursors(),
|
||||
full = mode == WavelogSyncMode.Full
|
||||
full = actualMode == WavelogSyncMode.Full
|
||||
)
|
||||
if (fetch == null) {
|
||||
_uiState.update {
|
||||
@@ -413,13 +422,20 @@ class SettingsViewModel(
|
||||
}
|
||||
// Stations that failed keep their previous cursor; fetched ones advance.
|
||||
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
|
||||
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
|
||||
applyWavelogSyncResult(
|
||||
settingsRepo,
|
||||
fetch.records,
|
||||
cursors,
|
||||
settings.url,
|
||||
actualMode,
|
||||
failedStations = fetch.failedStations.toSet()
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
wavelogSyncing = false,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
|
||||
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
|
||||
wavelogUploadMessage = wavelogSyncSummary(actualMode, fetch, merge, stations.size, promoted)
|
||||
)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
@@ -436,9 +452,14 @@ class SettingsViewModel(
|
||||
mode: WavelogSyncMode,
|
||||
fetch: WavelogSyncFetch,
|
||||
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
|
||||
totalStations: Int
|
||||
totalStations: Int,
|
||||
promoted: Boolean = false
|
||||
): String {
|
||||
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
|
||||
val head = when {
|
||||
promoted -> "Full Wavelog sync (auto re-scan)"
|
||||
mode == WavelogSyncMode.Full -> "Full Wavelog sync"
|
||||
else -> "Incremental Wavelog sync"
|
||||
}
|
||||
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
|
||||
return if (fetch.failedStations.isEmpty()) base
|
||||
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
|
||||
@@ -551,6 +572,13 @@ class SettingsViewModel(
|
||||
|
||||
// region Logbook + LoTW upload configuration
|
||||
|
||||
/**
|
||||
* Nominal (no Doppler) uplink/downlink of the satellite [name] is logged through — the
|
||||
* logbook's Create draft fills them into a blank record, so the operator does not have to
|
||||
* look the pair up and a record without a frequency (which LoTW rejects) cannot be saved.
|
||||
*/
|
||||
suspend fun nominalFrequencies(name: String): NominalFrequencies? = satelliteRepo.nominalFrequencies(name)
|
||||
|
||||
private fun refreshLogbook() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) }
|
||||
@@ -1012,6 +1040,7 @@ class SettingsViewModel(
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
satelliteRepo = container.satelliteRepo,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "582"
|
||||
appVersionCode = "587"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.11.7"
|
||||
appVersionName = "4.4.8-ba7opf.11.12"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user