feat: LoTW upload + logbook (port from Iu-yang1's fork)

- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
  LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
  MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
  myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
  to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
  installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
  auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
  (CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
  one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
  LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
  grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
This commit is contained in:
atsunatsu committed 2026-09-25 22:37:20 +08:00
1 parent d33629f015
commit 8e549cd43c
36 files changed
+2696 -4

No files matched your search

@@ -0,0 +1,178 @@
/*
* 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.
*/
package com.rtbishop.look4sat.feature.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.presentation.R
@Composable
fun LoTWUploadCard(
hasCertificate: Boolean,
stationGrid: String,
showUploadConfigDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(R.string.prefs_lotw_upload_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (hasCertificate) {
stringResource(R.string.prefs_lotw_upload_configured, stationGrid)
} else {
stringResource(R.string.prefs_lotw_upload_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
@Composable
fun LoTWUploadConfigDialog(
certificate: LoTWCertificate?,
station: LoTWStation?,
busy: Boolean,
onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit,
onSaveStation: (LoTWStation) -> Unit
) {
var password by remember { mutableStateOf("") }
var grid by remember { mutableStateOf(station?.grid.orEmpty()) }
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null && password.isNotBlank()) {
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes != null && bytes.isNotEmpty()) {
onImport(bytes, password.toCharArray())
password = ""
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.prefs_lotw_upload_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.prefs_lotw_upload_busy), fontSize = 13.sp)
}
}
if (certificate == null) {
Text(stringResource(R.string.prefs_lotw_upload_cert_hint), fontSize = 13.sp)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.prefs_lotw_upload_password), fontSize = 13.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { filePicker.launch(arrayOf("*/*")) },
enabled = password.isNotBlank() && !busy,
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.prefs_lotw_upload_import), fontSize = 13.sp) }
} else {
Text(stringResource(R.string.prefs_lotw_upload_cert_info, certificate.callsign, certificate.dxcc, certificate.expires), fontSize = 13.sp)
OutlinedButton(onClick = onRemove, enabled = !busy) {
Text(stringResource(R.string.prefs_lotw_upload_remove), fontSize = 13.sp)
}
}
Text(stringResource(R.string.prefs_lotw_upload_station_title), style = MaterialTheme.typography.titleSmall)
// Grid field accepts a comma-separated multi-grid set (e.g. "OL62,OL72").
OutlinedTextField(
value = grid,
onValueChange = { grid = it.uppercase() },
label = { Text(stringResource(R.string.prefs_lotw_upload_grid), fontSize = 13.sp) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = cqZone,
onValueChange = { cqZone = it },
label = { Text("CQZ", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = ituZone,
onValueChange = { ituZone = it },
label = { Text("ITUZ", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = iota,
onValueChange = { iota = it.uppercase() },
label = { Text("IOTA", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
},
confirmButton = {
TextButton(
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, "", "", iota)) },
enabled = grid.isNotBlank() && !busy
) { Text(stringResource(R.string.prefs_lotw_upload_save)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.prefs_lotw_upload_close)) } }
)
}
@@ -0,0 +1,117 @@
/*
* 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.
*/
package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.logbook.displayMode
import com.rtbishop.look4sat.core.presentation.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@Composable
fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showLogbookDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(R.string.prefs_logbook_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_logbook_count, recordCount),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
@Composable
fun LogbookDialog(
records: List<QsoRecord>,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.prefs_logbook_title)) },
text = {
if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(records, key = { it.id }) { record ->
LogbookRow(record, onDelete = { onDelete(record.id) })
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.prefs_logbook_close)) } }
)
}
@Composable
private fun LogbookRow(record: QsoRecord, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(record.startUtcMillis))
}
val satShort = record.satelliteName.substringBefore('(').trim()
Row(
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$time ${record.theirCallsign} ${record.displayMode}",
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(text = satShort, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text(
text = if (record.lotwConfirmed) "✓" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -261,6 +261,24 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
)
}
if (dialogs.logbook) {
LogbookDialog(
records = uiState.logbookRecords,
onDismiss = { dialogs.logbook = false },
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) }
)
}
if (dialogs.lotwUpload) {
LoTWUploadConfigDialog(
certificate = uiState.lotwCertificate,
station = uiState.lotwStation,
busy = uiState.lotwUploadBusy,
onDismiss = { dialogs.lotwUpload = false },
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
)
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -365,6 +383,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
LogbookCard(
recordCount = uiState.logbookRecords.size,
showLogbookDialog = { dialogs.logbook = true }
)
}
item {
LoTWUploadCard(
hasCertificate = uiState.lotwCertificate != null,
stationGrid = uiState.lotwStation?.grid.orEmpty(),
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
)
}
item {
LoTWCard(
settings = uiState.lotwSettings,
@@ -895,6 +926,8 @@ private class DialogVisibility {
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
var compassCalibration by mutableStateOf(false)
var logbook by mutableStateOf(false)
var lotwUpload by mutableStateOf(false)
}
@Composable
@@ -902,7 +935,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
},
restore = {
DialogVisibility().apply {
@@ -910,6 +943,8 @@ private fun rememberDialogVisibility(): DialogVisibility {
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
compassCalibration = it.getOrElse(8) { false }
logbook = it.getOrElse(9) { false }
lotwUpload = it.getOrElse(10) { false }
}
}
)
@@ -68,6 +68,13 @@ data class SettingsState(
val lotwError: LoTWError? = null,
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
val lotwLastSyncEpochMs: Long = 0L,
/** Logbook (QSO records + LoTW confirmations), newest first. */
val logbookRecords: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord> = emptyList(),
/** Imported LoTW upload certificate (null when none). */
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
/** LoTW upload station location (null when unset). */
val lotwStation: com.rtbishop.look4sat.core.domain.repository.LoTWStation? = null,
val lotwUploadBusy: Boolean = false,
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -130,6 +137,16 @@ sealed interface SettingsAction {
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
data object CancelLoTWSync : SettingsAction
// Logbook (QSO records + LoTW confirmations)
data object RefreshLogbook : SettingsAction
data class DeleteLogbookRecord(val id: Long) : SettingsAction
// LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
data object RemoveLoTWCertificate : SettingsAction
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
data object DownloadUpdate : SettingsAction
@@ -37,9 +37,11 @@ import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
@@ -50,6 +52,8 @@ class SettingsViewModel(
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
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 sensorsRepo: ISensorsRepo,
private val apkFile: File,
private val showToast: IShowToast
@@ -154,6 +158,11 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwSettings = settings) }
}
}
viewModelScope.launch {
qsoRepository.records.collect { records ->
_uiState.update { it.copy(logbookRecords = records) }
}
}
}
@@ -200,6 +209,14 @@ class SettingsViewModel(
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
// Logbook
SettingsAction.RefreshLogbook -> refreshLogbook()
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
// LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -263,6 +280,13 @@ class SettingsViewModel(
// by call + QSO time); full: the fresh report replaces it all.
// Shared with the automatic sync on app start (MainApplication).
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
// Feed the logbook: imported confirmations show up in 日志本 and
// match local uploads (sameConfirmedContact) to mark them confirmed.
viewModelScope.launch {
val confirmed = result.qsos.values.flatten()
.map { it.toConfirmedRecord(callsign) }
qsoRepository.mergeLoTW(confirmed)
}
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
@@ -312,6 +336,55 @@ class SettingsViewModel(
// endregion
// region Logbook + LoTW upload configuration
private fun refreshLogbook() {
viewModelScope.launch {
_uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) }
}
}
private fun loadLoTWUploadStatus() {
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
_uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwUploadBusy = false) }
}
}
private fun importLoTWCertificate(bytes: ByteArray, password: CharArray) {
viewModelScope.launch {
_uiState.update { it.copy(lotwUploadBusy = true) }
try {
val cert = lotwUploadRepository.importCertificate(bytes, password)
_uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false) }
} catch (_: Exception) {
_uiState.update { it.copy(lotwUploadBusy = false) }
}
}
}
private fun removeLoTWCertificate() {
viewModelScope.launch {
lotwUploadRepository.removeCertificate()
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null) }
}
}
private fun saveLoTWStation(station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) {
viewModelScope.launch {
_uiState.update { it.copy(lotwUploadBusy = true) }
try {
val saved = lotwUploadRepository.saveStation(station)
_uiState.update { it.copy(lotwStation = saved, lotwUploadBusy = false) }
} catch (_: Exception) {
_uiState.update { it.copy(lotwUploadBusy = false) }
}
}
}
// endregion
// region Update checker
private fun checkForUpdate() {
@@ -447,6 +520,8 @@ class SettingsViewModel(
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository,
lotwUploadRepository = container.lotwUploadRepository,
sensorsRepo = container.provideSensorsRepo(),
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()