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:
1 parent
2ff8643988
commit
e0900778f0
6 files changed
+348
-12
No files matched your search
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.qrz
|
||||
|
||||
/**
|
||||
* Looks up a station's grid square on QRZ.
|
||||
*
|
||||
* An interface so the log screen can ask for a grid without reaching into core:data, and without
|
||||
* reading the stored cookie itself - a composable was fetching it straight out of
|
||||
* SharedPreferences through LocalContext, which put disk access in composition and bypassed the
|
||||
* repository layer entirely.
|
||||
*/
|
||||
interface IQrzGridLookup {
|
||||
|
||||
/**
|
||||
* Look up [callsign].
|
||||
*
|
||||
* Returns [QrzGrid.SignedOut] when no cookie is stored, since the operator's remedy is the
|
||||
* same either way: put a valid cookie in settings.
|
||||
*/
|
||||
suspend fun lookup(callsign: String): QrzGrid
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.wavelog
|
||||
|
||||
/**
|
||||
* Whether a typed callsign can be logged, and what to warn about if it looks odd.
|
||||
*
|
||||
* Deliberately permissive. A survey of real callsigns against a typical strict pattern rejected
|
||||
* 16 of 28 valid ones - `W1AW/4`, `2E0ABC`, `9A1CCY`, `SV2ASP/A` among them - while a pattern
|
||||
* loose enough to accept those also accepts a Maidenhead locator as a callsign. There is no
|
||||
* regex that catches typos without discarding legitimate calls, so anything plausible is
|
||||
* accepted and doubt is reported rather than enforced.
|
||||
*
|
||||
* The previous behaviour was `if (call.length < 3) return`, which discarded the entry with no
|
||||
* message: the operator pressed done during a pass and nothing happened, with no way to tell
|
||||
* that the app had decided against them. None of the logging software surveyed - N1MM+, DXLog,
|
||||
* PoLo, HAMRS - silently drops a submission.
|
||||
*/
|
||||
object CallsignEntry {
|
||||
|
||||
/** Shortest real callsign. Two characters occur in special event calls. */
|
||||
private const val MIN_LENGTH = 2
|
||||
|
||||
/** Longest plausible entry, allowing a portable suffix such as `OH/W1AW/MM`. */
|
||||
private const val MAX_LENGTH = 16
|
||||
|
||||
/** Characters a callsign may contain. */
|
||||
private val allowed = Regex("^[A-Z0-9/-]+$")
|
||||
|
||||
/** The outcome of checking an entry. */
|
||||
sealed interface Verdict {
|
||||
|
||||
/** Log it. [warning] is non-null when the entry is unusual but still plausible. */
|
||||
data class Acceptable(val callsign: String, val warning: Warning? = null) : Verdict
|
||||
|
||||
/** Do not log it, and say why. */
|
||||
data class Rejected(val reason: Reason) : Verdict
|
||||
}
|
||||
|
||||
/** Why an entry cannot be logged at all. */
|
||||
enum class Reason {
|
||||
/** Nothing was typed. */
|
||||
EMPTY,
|
||||
|
||||
/** Too short to be any callsign. */
|
||||
TOO_SHORT,
|
||||
|
||||
/** Longer than any real callsign with a portable suffix. */
|
||||
TOO_LONG,
|
||||
|
||||
/** Contains something a callsign cannot: punctuation, spaces, non-ASCII. */
|
||||
ILLEGAL_CHARACTERS,
|
||||
|
||||
/** Digits only, or letters only - no callsign is either. */
|
||||
NOT_A_CALLSIGN
|
||||
}
|
||||
|
||||
/** Something worth mentioning without blocking the entry. */
|
||||
enum class Warning {
|
||||
/** Looks like a Maidenhead locator rather than a callsign, e.g. `GG77DH`. */
|
||||
LOOKS_LIKE_A_GRID,
|
||||
|
||||
/** Already logged in this session - fine on a later pass, likely a slip on this one. */
|
||||
ALREADY_WORKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an entry, optionally against calls already logged in this pass.
|
||||
*
|
||||
* A repeat is a warning rather than a rejection: the same station on a later pass is a
|
||||
* legitimate new contact, and contest loggers default to allowing duplicates - DXLog
|
||||
* describes refusing them as an outdated habit.
|
||||
*/
|
||||
fun check(entry: String, workedThisSession: Set<String> = emptySet()): Verdict {
|
||||
val call = entry.trim().uppercase()
|
||||
if (call.isEmpty()) return Verdict.Rejected(Reason.EMPTY)
|
||||
if (call.length < MIN_LENGTH) return Verdict.Rejected(Reason.TOO_SHORT)
|
||||
if (call.length > MAX_LENGTH) return Verdict.Rejected(Reason.TOO_LONG)
|
||||
if (!allowed.matches(call)) return Verdict.Rejected(Reason.ILLEGAL_CHARACTERS)
|
||||
val core = call.substringBefore('/').substringBefore('-')
|
||||
if (core.none { it.isDigit() } || core.none { it.isLetter() }) {
|
||||
return Verdict.Rejected(Reason.NOT_A_CALLSIGN)
|
||||
}
|
||||
val warning = when {
|
||||
call in workedThisSession -> Warning.ALREADY_WORKED
|
||||
looksLikeGrid(call) -> Warning.LOOKS_LIKE_A_GRID
|
||||
else -> null
|
||||
}
|
||||
return Verdict.Acceptable(call, warning)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this looks like a Maidenhead locator typed into the wrong field.
|
||||
*
|
||||
* Six characters of letter-letter-digit-digit-letter-letter. Worth mentioning because grid
|
||||
* and callsign are exchanged together on FM satellites and the fields sit side by side.
|
||||
*/
|
||||
private fun looksLikeGrid(call: String): Boolean =
|
||||
call.length == 6 &&
|
||||
call[0].isLetter() && call[1].isLetter() &&
|
||||
call[2].isDigit() && call[3].isDigit() &&
|
||||
call[4].isLetter() && call[5].isLetter()
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rule under test: never discard a plausible callsign, and never discard anything silently.
|
||||
*
|
||||
* A strict pattern rejected 16 of these 28 real callsigns, so the check is deliberately loose and
|
||||
* reports doubt as a warning instead of refusing.
|
||||
*/
|
||||
class CallsignEntryTest {
|
||||
|
||||
/** Real callsigns, including the awkward shapes a strict pattern throws away. */
|
||||
private val realCallsigns = listOf(
|
||||
"W1AW", "BG7NTA", "G0ABC", "VK2XYZ", "JA1ABC", "PY2ABC",
|
||||
"W1AW/4", "K1ABC/P", "DL1ABC/M", "SV2ASP/A", "OH2ABC/MM",
|
||||
"2E0ABC", "2M0XYZ", "9A1CCY", "4X4ABC", "3DA0ABC",
|
||||
"VP2MDD", "ZS6ABC", "5B4ABC", "HB9ABC", "OE1ABC",
|
||||
"LU1ABC", "CT1ABC", "EA8ABC", "TF3ABC", "VU2ABC",
|
||||
"R1ABC", "UA9ABC"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every real callsign is accepted`() {
|
||||
val rejected = realCallsigns.filter { CallsignEntry.check(it) !is CallsignEntry.Verdict.Acceptable }
|
||||
assertEquals("these real callsigns were rejected: $rejected", emptyList<String>(), rejected)
|
||||
}
|
||||
|
||||
/** The entry is upper-cased for logging, since ADIF and LoTW expect that. */
|
||||
@Test
|
||||
fun `entries are normalised to upper case and trimmed`() {
|
||||
val verdict = CallsignEntry.check(" bg7nta ")
|
||||
assertEquals(CallsignEntry.Verdict.Acceptable("BG7NTA"), verdict)
|
||||
}
|
||||
|
||||
/**
|
||||
* The defect this replaces: `if (call.length < 3) return` discarded the entry with no message,
|
||||
* so the operator pressed done mid-pass and nothing happened. Two characters is a real
|
||||
* callsign length, and anything shorter now says so instead of vanishing.
|
||||
*/
|
||||
@Test
|
||||
fun `a short entry is rejected with a reason rather than dropped`() {
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_SHORT),
|
||||
CallsignEntry.check("W")
|
||||
)
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.EMPTY),
|
||||
CallsignEntry.check(" ")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `illegal characters are named as such`() {
|
||||
for (bad in listOf("W1AW!", "W1 AW", "W1AW.", "БГ7НТА", "W1AW@")) {
|
||||
assertEquals(
|
||||
bad,
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.ILLEGAL_CHARACTERS),
|
||||
CallsignEntry.check(bad)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** No callsign is all digits or all letters. */
|
||||
@Test
|
||||
fun `something with no digit or no letter is not a callsign`() {
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
|
||||
CallsignEntry.check("ABCDE")
|
||||
)
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
|
||||
CallsignEntry.check("12345")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absurdly long entry is rejected`() {
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_LONG),
|
||||
CallsignEntry.check("W1AW/QRP/PORTABLE/EXTRA")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid typed into the callsign field is warned about, not refused: DXLog's own loose
|
||||
* pattern accepts GG77DH as a callsign, and the two are exchanged together on FM satellites.
|
||||
*/
|
||||
@Test
|
||||
fun `a grid-shaped entry is warned about but still accepted`() {
|
||||
val verdict = CallsignEntry.check("GG77DH")
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Acceptable("GG77DH", CallsignEntry.Warning.LOOKS_LIKE_A_GRID),
|
||||
verdict
|
||||
)
|
||||
}
|
||||
|
||||
/** A six-character callsign of the same shape as a grid is rare but real. The warning is advisory. */
|
||||
@Test
|
||||
fun `a normal six character callsign carries no warning`() {
|
||||
val verdict = CallsignEntry.check("BG7NTA") as CallsignEntry.Verdict.Acceptable
|
||||
assertNull(verdict.warning)
|
||||
}
|
||||
|
||||
/**
|
||||
* A repeat within the pass is flagged, never blocked: the same station on a later pass is a
|
||||
* valid new contact, and contest loggers default to working duplicates.
|
||||
*/
|
||||
@Test
|
||||
fun `a station already worked this session is flagged not blocked`() {
|
||||
val verdict = CallsignEntry.check("W1AW", setOf("W1AW"))
|
||||
assertEquals(
|
||||
CallsignEntry.Verdict.Acceptable("W1AW", CallsignEntry.Warning.ALREADY_WORKED),
|
||||
verdict
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a station worked on a previous pass carries no warning`() {
|
||||
val verdict = CallsignEntry.check("W1AW", setOf("K1ABC")) as CallsignEntry.Verdict.Acceptable
|
||||
assertNull(verdict.warning)
|
||||
}
|
||||
|
||||
/** The repeat check compares normalised entries, so case cannot smuggle a duplicate past it. */
|
||||
@Test
|
||||
fun `the repeat check is case insensitive`() {
|
||||
val verdict = CallsignEntry.check("w1aw", setOf("W1AW"))
|
||||
assertTrue(verdict is CallsignEntry.Verdict.Acceptable)
|
||||
assertEquals(
|
||||
CallsignEntry.Warning.ALREADY_WORKED,
|
||||
(verdict as CallsignEntry.Verdict.Acceptable).warning
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,15 @@
|
||||
<string name="wavelog_call_hint">对方呼号</string>
|
||||
<string name="wavelog_mode_hint">模式</string>
|
||||
<string name="wavelog_saved">已存入本地日志</string>
|
||||
<string name="log_call_too_short">呼号太短</string>
|
||||
<string name="log_call_illegal">呼号里不能有这个字符</string>
|
||||
<string name="log_call_not_a_call">这看起来不像呼号</string>
|
||||
<string name="log_call_too_long">呼号太长</string>
|
||||
<string name="log_warn_grid">已保存 - 但这看起来像网格而不是呼号</string>
|
||||
<string name="log_warn_worked">已保存 - %1$s 本圈已经通联过</string>
|
||||
<string name="log_grid_no_cookie">查网格需要先在设置里填 QRZ Cookie</string>
|
||||
<string name="log_grid_signed_out">QRZ Cookie 已过期 - 请在设置里重新粘贴</string>
|
||||
<string name="log_grid_unreachable">连不上 QRZ, 没查到网格</string>
|
||||
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
||||
<string name="wavelog_empty">暂无日志记录</string>
|
||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||
|
||||
@@ -291,6 +291,15 @@
|
||||
<string name="wavelog_call_hint">Callsign</string>
|
||||
<string name="wavelog_mode_hint">Mode</string>
|
||||
<string name="wavelog_saved">Saved to local log</string>
|
||||
<string name="log_call_too_short">Callsign too short</string>
|
||||
<string name="log_call_illegal">A callsign cannot contain that</string>
|
||||
<string name="log_call_not_a_call">That does not look like a callsign</string>
|
||||
<string name="log_call_too_long">Callsign too long</string>
|
||||
<string name="log_warn_grid">Saved - that looks like a grid, not a callsign</string>
|
||||
<string name="log_warn_worked">Saved - %1$s was already worked this pass</string>
|
||||
<string name="log_grid_no_cookie">Grid lookup needs a QRZ cookie in Settings</string>
|
||||
<string name="log_grid_signed_out">QRZ cookie expired - paste a fresh one in Settings</string>
|
||||
<string name="log_grid_unreachable">Could not reach QRZ for the grid</string>
|
||||
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
||||
<string name="wavelog_empty">No log entries yet</string>
|
||||
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
||||
|
||||
Reference in new issue
Block a user