Compare commits

...
Author SHA1 Message Date
atsunatsu e0a7373ba4 build: release version 4.4.8-ba7opf.11.12 (587) 2026-10-12 17:34:16 +08:00
atsunatsu 9551de0d1e feat(logbook): 新建按钮 + 详情精简 + 新建草稿自动填标称频率
- 日志本左下按钮由「取消」改为「新建」,多选态显示「退出选择」;新建打开空白通联详情,
  保存即插入(id=0 → save 插入),数据通路未动。SharedDialog 新增 cancelText 参数
  (默认仍为「取消」,其它弹窗不受影响)
- 通联日志详情去掉发送/接收报告与备注三个字段:保存时不再覆盖这三个值(存量值保留、
  导出照旧,仅不可再编辑);对方网格保留但改为只读——未确认 QSO 不由 App 写对方网格
- 新建草稿选定卫星后自动填标称(不含多普勒)上下行:优先 FM voice repeater,否则取首个
  上下行齐全转发器的频段中值;卫星变化会重填,但手工改过的不覆盖(AO-7 换频段场景);
  仅作用于新建草稿(id==0),编辑已有记录不自动填
- domain 新增 NominalFrequencies 与 nominalTransceiver()/catNumForName()(名字按卫星身份
  匹配,SAUDISAT 1C ↔ SO-50);ISatelliteRepo/SatelliteRepo 增加 nominalFrequencies(name);
  SettingsViewModel 注入 satelliteRepo;新增单测 NominalTransceiverTest 6/6 通过
2026-10-12 17:34:09 +08:00
atsunatsu 6d1b5c2798 Merge pull request #8 from Iu-yang1/fix/lotw-empty-certificate-password
fix(lotw): import passwordless PKCS12 certificates on Android
2026-10-12 17:19:39 +08:00
Iu_yang1 9c75e0e89f fix(lotw): support empty PKCS12 passwords on Android 2026-10-12 16:34:03 +08:00
atsunatsu 375225f25b build: release version 4.4.8-ba7opf.11.10 (585) 2026-10-10 21:03:24 +08:00
atsunatsu 1a1ed0aaea Merge pull request #7 from Iu-yang1/feature/radar-window-toggle-upstream-pr
feat(radar): toggle radar size in small windows (+ missing layout.size import fix)
2026-10-10 20:52:41 +08:00
atsunatsu 0f34c40b11 fix(radar): add missing layout.size import for the radar size toggle
PR #7 calls Modifier.size(48.dp) in RadarScreen.kt but never imported androidx.compose.foundation.layout.size, so :feature:radar:compileDebugKotlin failed with 'Unresolved reference: size' (verified by running the build).
2026-10-10 20:51:38 +08:00
Iu_yang1 85c39790ae fix(radar): match current split-window sizing behavior 2026-10-10 20:30:29 +08:00
Iu_yang1 e443ffe85d feat(radar): toggle radar size in small windows 2026-10-10 20:26:10 +08:00
18 changed files with 541 additions and 51 deletions

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)
@@ -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()
}
}
@@ -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?)
@@ -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). */
@@ -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
}
@@ -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
)
}
@@ -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,
@@ -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
@@ -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)
@@ -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,
@@ -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()
+2 -2
View File
@@ -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