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,281 @@
/*
* 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.radar
import android.content.Intent
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.presentation.EmptyListCard
private const val LOG_WINDOW_MS = 24 * 3_600_000L
@Composable
fun LogPage(
uiState: RadarState,
logViewModel: LogViewModel,
modifier: Modifier = Modifier
) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
}
val isLinear = selectedRadio != null &&
DopplerFrequencyCalculator.isNamedLinearTransponder(selectedRadio)
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
val satName = uiState.currentPass?.name?.trim().orEmpty()
LaunchedEffect(catnum) {
if (catnum != 0) logViewModel.selectSatellite(catnum)
}
val txHz = remember(selectedRadio) {
selectedRadio?.uplinkLow?.let { low ->
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
}
}
val rxHz = remember(selectedRadio) {
selectedRadio?.downlinkLow?.let { low ->
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
}
}
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
val now = System.currentTimeMillis()
val recent = records
.filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
.sortedByDescending { it.startUtcMillis }
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
Column(modifier = modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = logUiState.callsignInput,
onValueChange = logViewModel::updateCallsign,
modifier = Modifier.weight(1f),
placeholder = { Text("Callsign", fontSize = 14.sp) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
})
)
Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") }
}
if (isLinear) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("CW", "SSB", "FT4").forEach { candidate ->
FilterChip(
selected = mode == candidate,
onClick = { logViewModel.selectMode(catnum, candidate) },
label = { Text(candidate, fontSize = 13.sp) }
)
}
}
} else {
Text("FM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (recent.isEmpty()) {
EmptyListCard(message = "No QSOs yet — type a callsign and tap Log")
} else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(recent, key = { it.id }) { record ->
LogRecordRow(record, onDelete = { logViewModel.delete(record.id) })
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { logViewModel.generatePost(satName, maxElev) },
enabled = recent.isNotEmpty(),
modifier = Modifier.weight(1f)
) { Text("生成通联记录", fontSize = 13.sp) }
OutlinedButton(
onClick = logViewModel::prepareUpload,
enabled = !logUiState.busy,
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
}
if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
Text("Working…", fontSize = 13.sp)
}
}
}
logUiState.postText?.let { text ->
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
PostDialog(
text = text,
onDismiss = logViewModel::dismissPost,
onCopy = {
clipboard.setText(AnnotatedString(text))
logViewModel.dismissPost()
},
onShare = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(send, "Share QSO log"))
logViewModel.dismissPost()
}
)
}
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
title = { Text("LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
}
)
}
}
@Composable
private fun LogRecordRow(record: QsoRecord, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis))
}
Row(
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$time ${record.theirCallsign} ${record.displayMode}",
fontSize = 14.sp,
maxLines = 1
)
Text(text = if (record.lotwConfirmed) "✓" else "", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary)
}
}
@Composable
private fun PostDialog(
text: String,
onDismiss: () -> Unit,
onCopy: () -> Unit,
onShare: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("通联记录") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(onClick = onCopy) { Text("Copy") }
TextButton(onClick = onShare) { Text("Share") }
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }
)
}
@Composable
private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传到 LoTW") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text("${preview.count} QSO(s) · ${
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.firstUtc.let {
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
} – ${
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.lastUtc.let {
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
}Z", fontSize = 13.sp)
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -0,0 +1,198 @@
/*
* 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.radar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import kotlinx.coroutines.flow.Flow
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.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class LogUiState(
val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
val selectedMode: String = "",
/** Station callsign from the imported LoTW certificate ("" when none). */
val certificateCallsign: String = "",
/** Primary grid of the LoTW upload station location ("" when none). */
val stationGrid: String = "",
val preview: LoTWUploadPreview? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
val postText: String? = null,
val busy: Boolean = false
)
class LogViewModel(
private val qsoRepository: IQsoRepository,
private val lotwUploadRepository: ILoTWUploadRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
val records: Flow<List<QsoRecord>> = qsoRepository.records
private val _uiState = MutableStateFlow(LogUiState())
val uiState: StateFlow<LogUiState> = _uiState
init {
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
_uiState.update {
it.copy(
certificateCallsign = cert?.callsign.orEmpty(),
stationGrid = station?.grid.orEmpty()
)
}
}
}
fun updateCallsign(value: String) =
_uiState.update { it.copy(callsignInput = value.uppercase(Locale.US)) }
/** Load the per-satellite mode preset when the selected satellite changes. */
fun selectSatellite(catnum: Int) {
val preset = settingsRepo.getSatelliteMode(catnum)
_uiState.update { it.copy(selectedMode = preset) }
}
fun selectMode(catnum: Int, mode: String) {
_uiState.update { it.copy(selectedMode = mode) }
settingsRepo.setSatelliteMode(catnum, mode)
}
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
val now = System.currentTimeMillis()
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
txFrequencyHz = txHz,
rxFrequencyHz = rxHz,
band = frequencyBand(txHz),
rxBand = frequencyBand(rxHz),
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
satelliteName = satName,
satelliteMode = normalizedMode,
status = QsoStatus.COMPLETE,
propagationMode = "SAT"
)
viewModelScope.launch { qsoRepository.save(record) }
_uiState.update { it.copy(callsignInput = "") }
}
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
/** English social post from the recorded QSOs of the current satellite (last 24h). */
fun generatePost(satName: String, maxElev: Double) {
viewModelScope.launch {
val now = System.currentTimeMillis()
val list = qsoRepository.records.first()
.filter { it.satelliteName.trim().equals(satName.trim(), true) }
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
.sortedBy { it.startUtcMillis }
if (list.isEmpty()) return@launch
val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val firstLine = "🛰️ $shortName | ${utc.format(Date(list.first().startUtcMillis))}"
val elLine = "🔺 EL ${String.format(Locale.US, "%.1f", maxElev)}° | ${list.size} QSOs"
val modeLines = list.groupBy { it.displayMode }
.map { (mode, qsos) -> "📡 $mode: ${qsos.joinToString(" ") { it.theirCallsign }}" }
.joinToString("\n")
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
_uiState.update { it.copy(postText = text) }
}
}
fun dismissPost() = _uiState.update { it.copy(postText = null) }
fun prepareUpload() {
if (_uiState.value.busy) return
viewModelScope.launch {
_uiState.update { it.copy(busy = true, message = "") }
try {
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
_uiState.update { it.copy(busy = false, preview = preview) }
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(busy = false, message = "Upload failed") }
}
}
}
fun confirmUpload() {
val preview = _uiState.value.preview ?: return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id)
val msg = when (result) {
is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
is LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun clearMessage() = _uiState.update { it.copy(message = "") }
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
}
}
}
}
@@ -83,6 +83,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Log("Log"),
Sstv("SSTV")
}
@@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
@@ -127,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -138,6 +140,7 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
@@ -194,11 +197,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
}
}
@@ -208,6 +211,7 @@ private fun RadarScreen(
private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -218,6 +222,7 @@ private fun PagerCard(
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
@@ -261,6 +266,10 @@ private fun PagerCard(
onAction = onAction,
requestMicPermission = requestMicPermission
)
RadarPage.Log -> LogPage(
uiState = uiState,
logViewModel = logViewModel
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -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()