diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt
new file mode 100644
index 00000000..a16b6853
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt
@@ -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 .
+ */
+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'
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt
new file mode 100644
index 00000000..96f1530e
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt
@@ -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")
+ )
+ }
+}
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 8359625f..5d313408 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -278,6 +278,8 @@
对方呼号
网格 (可选)
对方报给你的网格。留空则从 QRZ 查询。
+ 不是有效网格
+ 仅方格 - 精度约 100 公里
模式
修改模式
时间
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index b0358b64..7272880f 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -309,6 +309,8 @@
Callsign
Grid (optional)
What they sent you. Left empty, it is looked up on QRZ.
+ Not a grid square
+ Square only - accurate to about 100 km
Mode
Edit the mode
Time
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
index 30e01720..1173aeb4 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
@@ -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() }),