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() }),