feat(log): check a typed grid before it reaches the log

The grid field accepted anything six characters long, so "ZZ99ZZ", "123456" and a callsign
all reached WavelogQso.gridsquare and then the ADIF GRIDSQUARE field. Wavelog stores what
arrives, and a wrong square is worse than a missing one: it pollutes grid statistics and VUCC
tracking, where the error is invisible until an award check disagrees with the log.

GridEntry follows the rule the callsign field settled on - refuse only what is certainly
wrong. It rejects a length Maidenhead does not have, a field pair past R (S-X decodes beyond
the poles, which is how a plausible entry produces an impossible position), a square pair
that is not digits, and a subsquare past X. Everything else is accepted.

Four characters is accepted with a note that it is only accurate to about 100km, because
plenty of satellite operators exchange only the square and refusing that would reject good
data. The app's own isValidLocator could not be reused: it requires six characters and is
private.

Two things the field does better now. It takes eight characters rather than six, since the
extended form exists and truncating it would silently move the location. And case is
normalised on commit rather than while typing, so the cursor no longer jumps mid-entry - the
logged value is OL72ap, the conventional rendering, whatever was typed.

14 tests, including a cross-check that anything accepted at six characters or more also
decodes through the app's own qthToPosition. Without that the two would be free to disagree
about what a grid is.
This commit is contained in:
mckero committed 2026-08-26 11:32:49 +00:00
1 parent cb3ebe7870
commit 6c67aa2718
5 files changed
+309 -3

No files matched your search

@@ -0,0 +1,115 @@
/*
* 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
/**
* Checks a typed counterpart grid before it reaches the log.
*
* Separate from qthToPosition's validator, which requires six characters and is private. Plenty of
* satellite operators exchange only the four-character square, so requiring six would reject
* perfectly good entries - and this follows the same rule the callsign field settled on: refuse only
* what is certainly wrong, warn about the rest, never silently discard.
*
* The reason to check at all is that an unchecked value goes into the ADIF GRIDSQUARE field, and a
* malformed one is stored by Wavelog as-is. It then pollutes grid statistics and VUCC award
* tracking, where a wrong square is worse than a missing one.
*/
object GridEntry {
/** What a typed grid amounts to. */
sealed interface Verdict {
/** Usable. [normalised] is what should be logged - upper case for the pair, lower for the subsquare. */
data class Acceptable(val normalised: String, val warning: Warning? = null) : Verdict
/** Certainly not a grid. [reason] says which rule it broke. */
data class Unusable(val reason: Reason) : Verdict
/** Nothing typed. The QRZ lookup should run instead. */
data object Empty : Verdict
}
/** Worth mentioning but not worth refusing. */
enum class Warning {
/** Four characters, so the location is only accurate to about 100km. */
SQUARE_ONLY
}
/** Why an entry cannot be a grid. */
enum class Reason {
/** Not 4, 6 or 8 characters. Maidenhead has no other lengths. */
WRONG_LENGTH,
/** First pair outside A-R. S-X would decode past the poles. */
FIELD_OUT_OF_RANGE,
/** Second pair is not two digits. */
SQUARE_NOT_DIGITS,
/** Third pair outside A-X. */
SUBSQUARE_OUT_OF_RANGE
}
/**
* Judge a typed entry.
*
* Case is normalised on the way out rather than demanded on the way in - an operator typing
* one-handed outdoors should not have to care, and the conventional rendering is upper case for
* the field, digits, then lower case for the subsquare.
*/
fun check(entry: String): Verdict {
val text = entry.trim()
if (text.isEmpty()) return Verdict.Empty
if (text.length !in VALID_LENGTHS) return Verdict.Unusable(Reason.WRONG_LENGTH)
val upper = text.uppercase()
if (upper[0] !in FIELD_RANGE || upper[1] !in FIELD_RANGE) {
return Verdict.Unusable(Reason.FIELD_OUT_OF_RANGE)
}
if (!upper[2].isDigit() || !upper[3].isDigit()) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
if (text.length >= SUBSQUARE_LENGTH) {
if (upper[4] !in SUBSQUARE_RANGE || upper[5] !in SUBSQUARE_RANGE) {
return Verdict.Unusable(Reason.SUBSQUARE_OUT_OF_RANGE)
}
}
if (text.length == EXTENDED_LENGTH && (!upper[6].isDigit() || !upper[7].isDigit())) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
return Verdict.Acceptable(
normalised = normalise(upper),
warning = if (text.length == SQUARE_LENGTH) Warning.SQUARE_ONLY else null
)
}
/** `OL72ap` - upper case field, digits, lower case subsquare, as the convention renders it. */
private fun normalise(upper: String): String = buildString {
append(upper.take(SQUARE_LENGTH))
if (upper.length >= SUBSQUARE_LENGTH) append(upper.substring(4, 6).lowercase())
if (upper.length == EXTENDED_LENGTH) append(upper.substring(6, 8))
}
private const val SQUARE_LENGTH = 4
private const val SUBSQUARE_LENGTH = 6
private const val EXTENDED_LENGTH = 8
private val VALID_LENGTHS = setOf(SQUARE_LENGTH, SUBSQUARE_LENGTH, EXTENDED_LENGTH)
private val FIELD_RANGE = 'A'..'R'
private val SUBSQUARE_RANGE = 'A'..'X'
}
@@ -0,0 +1,167 @@
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A typed grid goes into the ADIF GRIDSQUARE field and Wavelog stores whatever arrives, where a
* wrong square is worse than a missing one - it pollutes grid statistics and VUCC tracking.
*
* The rule follows the callsign field: refuse only what is certainly wrong. Four characters is a
* legitimate exchange on satellites, so requiring six would reject good entries.
*/
class GridEntryTest {
/** Real grids, from stations and from this project's own test data. */
private val realGrids = listOf(
"OL72AP", // Shenzhen, the user's own
"FN31pr", // W1AW, mixed case as conventionally written
"JO31", // four-character exchange
"PM95uq",
"IO91vl39", // eight-character extended
"gf15vc", // all lower case
"RR73" // the highest legal field pair
)
@Test
fun `real grids are accepted`() {
for (grid in realGrids) {
val verdict = GridEntry.check(grid)
assertTrue("$grid must be usable, got $verdict", verdict is GridEntry.Verdict.Acceptable)
}
}
/**
* Cross-check against the app's own converter: anything this accepts at six characters or more
* must also decode to a real position, or the two disagree about what a grid is.
*/
@Test
fun `accepted grids of six or more decode to a position`() {
for (grid in realGrids.filter { it.length >= 6 }) {
val verdict = GridEntry.check(grid)
assertTrue(verdict is GridEntry.Verdict.Acceptable)
assertNotNull(
"$grid was accepted here but qthToPosition rejects it",
qthToPosition((verdict as GridEntry.Verdict.Acceptable).normalised)
)
}
}
/** Four characters is a real exchange, flagged rather than refused. */
@Test
fun `a four character square is acceptable with a warning`() {
val verdict = GridEntry.check("JO31")
assertEquals(
GridEntry.Verdict.Acceptable("JO31", GridEntry.Warning.SQUARE_ONLY),
verdict
)
}
@Test
fun `six characters carry no warning`() {
val verdict = GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable
assertNull(verdict.warning)
}
/** Upper field, lower subsquare - the conventional rendering, whatever was typed. */
@Test
fun `case is normalised rather than demanded`() {
assertEquals("OL72ap", (GridEntry.check("ol72ap") as GridEntry.Verdict.Acceptable).normalised)
assertEquals("OL72ap", (GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable).normalised)
assertEquals("OL72ap", (GridEntry.check("oL72Ap") as GridEntry.Verdict.Acceptable).normalised)
}
@Test
fun `surrounding whitespace does not matter`() {
assertEquals("OL72ap", (GridEntry.check(" OL72AP ") as GridEntry.Verdict.Acceptable).normalised)
}
/** Nothing typed means the QRZ lookup should run, which is a different outcome from bad input. */
@Test
fun `an empty entry is neither acceptable nor unusable`() {
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(""))
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(" "))
}
/** Maidenhead has no odd lengths and no two-character form in this context. */
@Test
fun `only 4 6 or 8 characters can be a grid`() {
for (bad in listOf("OL", "OL7", "OL72A", "OL72APX", "OL72AP123")) {
val verdict = GridEntry.check(bad)
assertEquals(
"$bad must be refused for length, got $verdict",
GridEntry.Verdict.Unusable(GridEntry.Reason.WRONG_LENGTH),
verdict
)
}
}
/**
* The first pair runs A-R only. S-X decodes past the poles, which is how a plausible-looking
* entry produces a position that cannot exist.
*/
@Test
fun `a field pair past R is refused`() {
for (bad in listOf("SS12AA", "ZZ99ZZ", "TT34bb")) {
assertEquals(
"$bad decodes outside the world",
GridEntry.Verdict.Unusable(GridEntry.Reason.FIELD_OUT_OF_RANGE),
GridEntry.check(bad)
)
}
}
@Test
fun `the square pair must be digits`() {
for (bad in listOf("OLAAAP", "OL7AAP", "ABCDEF")) {
assertEquals(
"$bad has no square digits",
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
GridEntry.check(bad)
)
}
}
/** The subsquare runs A-X. Y and Z do not exist. */
@Test
fun `a subsquare past X is refused`() {
assertEquals(
GridEntry.Verdict.Unusable(GridEntry.Reason.SUBSQUARE_OUT_OF_RANGE),
GridEntry.check("OL72YZ")
)
}
/** A callsign is the most likely wrong thing to be typed into this field. */
@Test
fun `a callsign is not a grid`() {
for (call in listOf("BG7NTA", "W1AW", "JA1ABC", "DL1ABC")) {
val verdict = GridEntry.check(call)
assertTrue(
"$call must not read as a grid, got $verdict",
verdict is GridEntry.Verdict.Unusable
)
}
}
/** Digits alone are a frequency or a report, not a grid. */
@Test
fun `numbers alone are not a grid`() {
for (bad in listOf("123456", "5959", "14550")) {
assertTrue(GridEntry.check(bad) is GridEntry.Verdict.Unusable)
}
}
/** The extended form ends in two digits. */
@Test
fun `an eight character grid needs digits at the end`() {
assertTrue(GridEntry.check("IO91vl39") is GridEntry.Verdict.Acceptable)
assertEquals(
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
GridEntry.check("IO91vlab")
)
}
}
@@ -278,6 +278,8 @@
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_grid_hint">网格 (可选)</string>
<string name="wavelog_grid_help">对方报给你的网格。留空则从 QRZ 查询。</string>
<string name="wavelog_grid_bad">不是有效网格</string>
<string name="wavelog_grid_square_only">仅方格 - 精度约 100 公里</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_mode_edit">修改模式</string>
<string name="wavelog_time_hint">时间</string>
@@ -309,6 +309,8 @@
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_grid_hint">Grid (optional)</string>
<string name="wavelog_grid_help">What they sent you. Left empty, it is looked up on QRZ.</string>
<string name="wavelog_grid_bad">Not a grid square</string>
<string name="wavelog_grid_square_only">Square only - accurate to about 100 km</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_mode_edit">Edit the mode</string>
<string name="wavelog_time_hint">Time</string>
@@ -81,6 +81,7 @@ 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.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.wavelog.GridEntry
import com.rtbishop.look4sat.core.domain.wavelog.PassClock
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
@@ -337,7 +338,9 @@ private fun ExpandedLogInput(
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val qsoId = UUID.randomUUID().toString()
val typedGrid = gridEntry.trim().uppercase()
// Only a usable grid is logged. A malformed one is dropped rather than stored, and the
// field has been saying so while it was typed.
val typedGrid = (GridEntry.check(gridEntry) as? GridEntry.Verdict.Acceptable)?.normalised ?: ""
queue.add(
WavelogQso(
id = qsoId,
@@ -455,16 +458,33 @@ private fun ExpandedLogInput(
// could previously only arrive by scraping QRZ - which needs a cookie, and returns nothing
// for a station with no locator on file. Typed here it also skips the lookup entirely,
// because what the operator heard beats what a web page says.
val gridVerdict = GridEntry.check(gridEntry)
OutlinedTextField(
value = gridEntry,
onValueChange = { gridEntry = it.take(6).uppercase() },
// 8 characters, not 6: the extended form exists and truncating it would silently
// change the location. Case is normalised on commit rather than while typing, so the
// cursor does not jump under the operator's thumb.
onValueChange = { gridEntry = it.take(8) },
label = { Text(stringResource(id = R.string.wavelog_grid_hint)) },
supportingText = {
Text(
text = stringResource(id = R.string.wavelog_grid_help),
// Says what is wrong while there is still time to fix it. A malformed grid is
// stored by Wavelog as-is and then pollutes grid statistics and VUCC tracking,
// where a wrong square is worse than a missing one.
text = when (gridVerdict) {
is GridEntry.Verdict.Unusable -> stringResource(id = R.string.wavelog_grid_bad)
is GridEntry.Verdict.Acceptable ->
if (gridVerdict.warning == GridEntry.Warning.SQUARE_ONLY) {
stringResource(id = R.string.wavelog_grid_square_only)
} else {
stringResource(id = R.string.wavelog_grid_help)
}
GridEntry.Verdict.Empty -> stringResource(id = R.string.wavelog_grid_help)
},
style = MaterialTheme.typography.bodySmall
)
},
isError = gridVerdict is GridEntry.Verdict.Unusable,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),