mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-13 07:27:26 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a7373ba4 | ||
|
|
9551de0d1e | ||
|
|
6d1b5c2798 | ||
|
|
9c75e0e89f | ||
|
|
375225f25b | ||
|
|
1a1ed0aaea | ||
|
|
0f34c40b11 | ||
|
|
85c39790ae | ||
|
|
e443ffe85d |
No files matched your search
@@ -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)
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+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). */
|
||||
|
||||
+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
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -92,6 +92,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>
|
||||
@@ -221,6 +225,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
@@ -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
|
||||
@@ -72,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
|
||||
@@ -569,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()) }
|
||||
@@ -1030,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 = "584"
|
||||
appVersionCode = "587"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.11.9"
|
||||
appVersionName = "4.4.8-ba7opf.11.12"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user