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
index a16b6853..67c66bff 100644
--- 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
@@ -46,6 +46,12 @@ object GridEntry {
/** Worth mentioning but not worth refusing. */
enum class Warning {
+ /**
+ * Two characters. Legal per ADIF, but a field is 20 by 10 degrees - close to useless for a
+ * satellite contact, so it is worth saying rather than refusing.
+ */
+ FIELD_ONLY,
+
/** Four characters, so the location is only accurate to about 100km. */
SQUARE_ONLY
}
@@ -81,7 +87,14 @@ object GridEntry {
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()) {
+ // ASCII digits only. Char.isDigit() is Unicode-aware and covers the whole Nd category, so
+ // it accepted Arabic-Indic, Devanagari and fullwidth digits - which a localised keypad can
+ // produce without the operator seeing any difference. ADIF 3.1.7 defines Digit as "an ASCII
+ // character whose code lies in the range of 48 through 57", and Wavelog stores GRIDSQUARE
+ // verbatim, so such a value would never match a real grid in any statistics query.
+ // Guarded on length: a 2-character locator has no square pair, and reading index 2 of it
+ // would throw.
+ if (text.length >= SQUARE_LENGTH && (!upper[2].isAsciiDigit() || !upper[3].isAsciiDigit())) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
if (text.length >= SUBSQUARE_LENGTH) {
@@ -89,27 +102,42 @@ object GridEntry {
return Verdict.Unusable(Reason.SUBSQUARE_OUT_OF_RANGE)
}
}
- if (text.length == EXTENDED_LENGTH && (!upper[6].isDigit() || !upper[7].isDigit())) {
+ if (text.length == EXTENDED_LENGTH && (!upper[6].isAsciiDigit() || !upper[7].isAsciiDigit())) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
return Verdict.Acceptable(
normalised = normalise(upper),
- warning = if (text.length == SQUARE_LENGTH) Warning.SQUARE_ONLY else null
+ warning = when (text.length) {
+ FIELD_LENGTH -> Warning.FIELD_ONLY
+ 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))
+ append(upper.take(minOf(upper.length, SQUARE_LENGTH)))
if (upper.length >= SUBSQUARE_LENGTH) append(upper.substring(4, 6).lowercase())
if (upper.length == EXTENDED_LENGTH) append(upper.substring(6, 8))
}
+ /** ADIF 3.1.7 defines Digit as ASCII 48-57. Kotlin's isDigit() is far wider. */
+ private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
+
+ private const val FIELD_LENGTH = 2
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)
+
+ /**
+ * ADIF 3.1.7: GRIDSQUARE takes "2-character, 4-character, 6-character, or 8-character" locators.
+ * A 10 or 12 character locator stores its first 8 here and the rest in GRIDSQUARE_EXT, which
+ * neither WavelogQso nor Wavelog's own field list carries - so the extra pair has nowhere to go
+ * and the UI clips at 8, which produces the spec-correct GRIDSQUARE value.
+ */
+ private val VALID_LENGTHS = setOf(FIELD_LENGTH, 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
index 96f1530e..679dc583 100644
--- 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
@@ -80,6 +80,49 @@ class GridEntryTest {
assertEquals("OL72ap", (GridEntry.check(" OL72AP ") as GridEntry.Verdict.Acceptable).normalised)
}
+ /**
+ * Two characters is legal per ADIF, so it is accepted - but a field is 20 by 10 degrees, close
+ * to useless for a satellite contact, so it says so rather than refusing.
+ */
+ @Test
+ fun `a two character field is acceptable with a warning`() {
+ assertEquals(
+ GridEntry.Verdict.Acceptable("OL", GridEntry.Warning.FIELD_ONLY),
+ GridEntry.check("ol")
+ )
+ }
+
+ /** A 2-character entry must not read past its own end. */
+ @Test
+ fun `a two character field does not crash`() {
+ for (grid in listOf("AA", "RR", "ol", "FN")) {
+ assertTrue(GridEntry.check(grid) is GridEntry.Verdict.Acceptable)
+ }
+ }
+
+ /**
+ * Kotlin's Char.isDigit() is Unicode-aware and accepted Arabic-Indic, Devanagari, Persian and
+ * fullwidth digits, which a localised keypad produces without the operator seeing a difference.
+ * ADIF 3.1.7 defines Digit as ASCII 48-57, and Wavelog stores GRIDSQUARE verbatim, so such a
+ * value would never match a real grid in any statistics query.
+ */
+ @Test
+ fun `non-ascii digits are not digits`() {
+ val nonAscii = listOf(
+ "OL\u0661\u0662ap",
+ "OL\u0968\u0968ap",
+ "OL\uff11\uff12ap",
+ "OL\u06f1\u06f2ap",
+ "IO91vl\u0663\u0669"
+ )
+ for (grid in nonAscii) {
+ assertTrue(
+ "must be refused: " + grid,
+ GridEntry.check(grid) is GridEntry.Verdict.Unusable
+ )
+ }
+ }
+
/** 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`() {
@@ -87,10 +130,14 @@ class GridEntryTest {
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(" "))
}
- /** Maidenhead has no odd lengths and no two-character form in this context. */
+ /**
+ * ADIF 3.1.7 permits 2, 4, 6 and 8 characters and nothing between them. A 10 or 12 character
+ * locator stores its first 8 here with the rest in GRIDSQUARE_EXT, which nothing in this app
+ * carries - the field clips at 8, which produces the spec-correct value.
+ */
@Test
- fun `only 4 6 or 8 characters can be a grid`() {
- for (bad in listOf("OL", "OL7", "OL72A", "OL72APX", "OL72AP123")) {
+ fun `only 2 4 6 or 8 characters can be a grid`() {
+ for (bad in listOf("O", "OL7", "OL72A", "OL72APX", "OL72AP123")) {
val verdict = GridEntry.check(bad)
assertEquals(
"$bad must be refused for length, got $verdict",
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 5d313408..d508969f 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -280,6 +280,7 @@
对方报给你的网格。留空则从 QRZ 查询。
不是有效网格
仅方格 - 精度约 100 公里
+ 仅大方格 - 精度约 1000 公里
模式
修改模式
时间
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index 7272880f..85b48983 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -311,6 +311,7 @@
What they sent you. Left empty, it is looked up on QRZ.
Not a grid square
Square only - accurate to about 100 km
+ Field only - accurate to about 1000 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 1173aeb4..c8033f4b 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
@@ -473,12 +473,13 @@ private fun ExpandedLogInput(
// 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) {
+ is GridEntry.Verdict.Acceptable -> when (gridVerdict.warning) {
+ GridEntry.Warning.FIELD_ONLY ->
+ stringResource(id = R.string.wavelog_grid_field_only)
+ GridEntry.Warning.SQUARE_ONLY ->
stringResource(id = R.string.wavelog_grid_square_only)
- } else {
- stringResource(id = R.string.wavelog_grid_help)
- }
+ null -> stringResource(id = R.string.wavelog_grid_help)
+ }
GridEntry.Verdict.Empty -> stringResource(id = R.string.wavelog_grid_help)
},
style = MaterialTheme.typography.bodySmall