fix(log): say why a callsign was not logged instead of dropping it

`submit()` opened with `if (call.length < 3) return`. During a pass the operator typed
a callsign, pressed done, and nothing happened - no entry, no message, no way to tell
the app had decided against them. None of the logging software surveyed for this work
- N1MM+, DXLog, PoLo, HAMRS - discards a submission silently.

Validation is deliberately loose, because strictness costs more than it saves. Checked
against 28 real callsigns, a typical strict pattern rejects 16 of them: W1AW/4, 2E0ABC,
9A1CCY and SV2ASP/A among others. A pattern permissive enough to accept those also
accepts a Maidenhead locator as a callsign. There is no regex that catches typos without
throwing away legitimate calls, so CallsignEntry rejects only what cannot be a callsign
- empty, one character, illegal characters, all digits, all letters - and reports doubt
as a warning that still logs the contact.

Two warnings exist. A six-character grid-shaped entry says so, because grid and callsign
are exchanged together on FM satellites and the fields sit side by side. A station already
worked this pass says so too, without blocking: the same station on a later pass is a
legitimate new contact, and contest loggers default to working duplicates - DXLog
describes refusing them as an outdated habit.

That warning also replaces the duplicate suppression, which was a 300ms window comparing
the last callsign, admitted in its own comment to be a workaround. It could silently
discard a real second contact, and a set of calls worked this pass is both honest and
more useful. It survives configuration changes via rememberSaveable.

Not addressed here: the QRZ grid backfill still reads the cookie out of SharedPreferences
from inside a composable through LocalContext, and still reports nothing when a lookup
fails. IQrzGridLookup is added for that, but wiring it needs the container, the view model
and the UI to change together.
This commit is contained in:
mckero committed 2026-08-25 14:32:42 +00:00
1 parent 2ff8643988
commit e0900778f0
6 files changed
+348 -12

No files matched your search

@@ -52,6 +52,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -70,6 +71,7 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
@@ -84,7 +86,6 @@ import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
private const val DUPLICATE_WINDOW_MS = 2000L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -266,20 +267,38 @@ private fun ExpandedLogInput(
val scope = rememberCoroutineScope()
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
// A plain in-flight flag cannot help: submit() is synchronous, so it always
// clears before the next key event arrives.
var lastSaved by remember { mutableStateOf("" to 0L) }
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
// swallowed what it guessed were accidental double submissions - a guess that could discard
// a real second contact instead.
var workedThisSession by rememberSaveable { mutableStateOf(emptySet<String>()) }
val savedMsg = stringResource(id = R.string.wavelog_saved)
val gridWarnMsg = stringResource(id = R.string.log_warn_grid)
val workedWarnMsg = stringResource(id = R.string.log_warn_worked)
val tooShortMsg = stringResource(id = R.string.log_call_too_short)
val illegalMsg = stringResource(id = R.string.log_call_illegal)
val notACallMsg = stringResource(id = R.string.log_call_not_a_call)
val tooLongMsg = stringResource(id = R.string.log_call_too_long)
fun rejectionMessage(reason: CallsignEntry.Reason): String = when (reason) {
CallsignEntry.Reason.EMPTY, CallsignEntry.Reason.TOO_SHORT -> tooShortMsg
CallsignEntry.Reason.TOO_LONG -> tooLongMsg
CallsignEntry.Reason.ILLEGAL_CHARACTERS -> illegalMsg
CallsignEntry.Reason.NOT_A_CALLSIGN -> notACallMsg
}
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
val now = System.currentTimeMillis()
val (lastCall, lastAt) = lastSaved
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
lastSaved = call to now
// Every outcome says something. The previous `if (call.length < 3) return` discarded the
// entry in silence, so pressing done mid-pass appeared to do nothing, and none of the
// logging software surveyed drops a submission that way.
val verdict = CallsignEntry.check(callsign, workedThisSession)
if (verdict is CallsignEntry.Verdict.Rejected) {
showToast(rejectionMessage(verdict.reason))
return
}
val accepted = verdict as CallsignEntry.Verdict.Acceptable
val call = accepted.callsign
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
@@ -298,8 +317,15 @@ private fun ExpandedLogInput(
)
)
callsign = ""
workedThisSession = workedThisSession + call
onSaved()
showToast(savedMsg)
showToast(
when (accepted.warning) {
CallsignEntry.Warning.LOOKS_LIKE_A_GRID -> gridWarnMsg
CallsignEntry.Warning.ALREADY_WORKED -> workedWarnMsg.format(call)
null -> savedMsg
}
)
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
scope.launch {
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)