diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt index 66800685..9d6d156b 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt @@ -24,6 +24,15 @@ object AprsStore { private const val KEY_STATUS = "status" private const val KEY_SYMBOL_TABLE = "symbol_table" private const val KEY_SYMBOL_CODE = "symbol_code" + + /** + * A house, not a car. + * + * Only fresh installs see this: saveConfig writes every key unconditionally and the enable + * switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and this + * fallback cannot reach them. + */ + private const val DEFAULT_SYMBOL_CODE = "-" private const val KEY_LAST_TIME = "last_report_time" private const val KEY_LAST_OK = "last_report_ok" private const val KEY_LAST_DETAIL = "last_report_detail" @@ -41,7 +50,7 @@ object AprsStore { intervalMin = p.getInt(KEY_INTERVAL, 5), statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS", symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/", - symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">" + symbolCode = p.getString(KEY_SYMBOL_CODE, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE ) } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt index 1ab58232..33ab181e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt @@ -143,5 +143,14 @@ object AprsBeacon { private const val CRLF_BYTES = 2 /** Fallback symbol. `>` is a car on the primary table - a reasonable stand-in for a phone. */ - private const val DEFAULT_SYMBOL = '>' + /** + * Substituted when the stored code is unusable. + * + * A house, not a car. The old default was '>' (CAR) with a comment conceding it was "a + * reasonable stand-in for a phone" - but a station beaconing from a handset showed up as a + * vehicle for every operator who was not driving, and aprs.fi names transmit-side symbol + * misconfiguration among the first things to check when a station looks wrong. A house is + * correct for most users and obviously wrong rather than misleading for the rest. + */ + private const val DEFAULT_SYMBOL = '-' } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt new file mode 100644 index 00000000..93aa5fac --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt @@ -0,0 +1,81 @@ +/* + * 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.aprs + +/** + * One APRS symbol an operator might plausibly want. + * + * [table] and [code] are the two characters that go into the packet. [descriptionKey] names a + * string resource rather than holding text, because core:domain has no access to resources and + * hardcoding English here would put wording outside the locale files. + */ +data class AprsSymbol(val table: Char, val code: Char, val descriptionKey: String) + +/** + * A short list of symbols worth offering, instead of two free-text fields. + * + * The fields accepted anything and used only the first character, so typing "satellite" into the + * table field persisted the whole word and beaconed as `/` - the field lied about what it did. + * aprs.fi's own troubleshooting guidance puts transmit-side symbol misconfiguration among the first + * things to check when a station does not appear as expected. + * + * The strongest single argument for a list: `\S` is Satellite/Pacsat but `/S` is SHUTTLE. One + * keystroke apart, and both look correct to someone typing from memory. + * + * Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, 25 Nov 2015). The list is deliberately + * short - it covers fixed, portable, vehicle and satellite postures, not all 400-odd symbols. + */ +object AprsSymbols { + + /** Fixed home station. Correct for most users, and wrong in an obvious way for the rest. */ + val HOUSE = AprsSymbol('/', '-', "aprs_symbol_house") + + val curated = listOf( + HOUSE, + AprsSymbol('\\', '-', "aprs_symbol_house_alt"), + AprsSymbol('/', '[', "aprs_symbol_person"), + AprsSymbol('/', 'y', "aprs_symbol_yagi"), + // Alternate table. /S is SHUTTLE, which is not what anyone means here. + AprsSymbol('\\', 'S', "aprs_symbol_satellite"), + AprsSymbol('/', ';', "aprs_symbol_portable"), + AprsSymbol('/', '$', "aprs_symbol_phone"), + AprsSymbol('/', 'I', "aprs_symbol_tcpip"), + AprsSymbol('\\', 'K', "aprs_symbol_ht"), + AprsSymbol('/', '>', "aprs_symbol_car"), + AprsSymbol('/', 'k', "aprs_symbol_truck"), + AprsSymbol('/', 'v', "aprs_symbol_van"), + AprsSymbol('/', 'R', "aprs_symbol_rv"), + AprsSymbol('/', 'b', "aprs_symbol_bike") + ) + + /** + * Find the curated entry matching a stored pair, or null when it is not on the list. + * + * Null matters: an operator may have set a symbol this list does not offer, and the picker must + * show it as-is rather than silently substituting the nearest entry. + */ + fun find(table: Char, code: Char): AprsSymbol? = + curated.firstOrNull { it.table == table && it.code == code } + + /** Same, from whatever strings the settings screen holds. Blank means the shipped default. */ + fun find(table: String, code: String): AprsSymbol? { + val t = table.firstOrNull() ?: HOUSE.table + val c = code.firstOrNull() ?: HOUSE.code + return find(t, c) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt index 08a7cf9e..df823d8a 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt @@ -145,8 +145,10 @@ class AprsBeaconTest { @Test fun `the symbol code falls back when unprintable`() { - assertEquals('>', AprsBeacon.codeOf("")) - assertEquals('>', AprsBeacon.codeOf(" ")) + // A house, not a car. The old fallback was '>' (CAR), which showed a phone-based beacon as + // a vehicle for every operator who was not driving. + assertEquals('-', AprsBeacon.codeOf("")) + assertEquals('-', AprsBeacon.codeOf(" ")) assertEquals('[', AprsBeacon.codeOf("[")) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt new file mode 100644 index 00000000..720380c8 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt @@ -0,0 +1,102 @@ +package com.rtbishop.look4sat.core.domain.aprs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The list exists because two free-text fields accepted anything and used only the first character, + * so an operator could type a word, watch it persist, and beacon as something else entirely. + */ +class AprsSymbolsTest { + + /** + * The pair that justifies the whole feature. `\S` is Satellite/Pacsat; `/S` is SHUTTLE. One + * keystroke apart, and both look right to someone typing from memory. + */ + @Test + fun `the satellite symbol uses the alternate table`() { + val satellite = AprsSymbols.curated.single { it.descriptionKey == "aprs_symbol_satellite" } + assertEquals('\\', satellite.table) + assertEquals('S', satellite.code) + } + + /** Every entry must survive the sanitiser that runs at packet-build time. */ + @Test + fun `every curated symbol passes the transmit sanitiser`() { + for (symbol in AprsSymbols.curated) { + assertEquals( + symbol.descriptionKey + " table must survive tableOf", + symbol.table, + AprsBeacon.tableOf(symbol.table.toString()) + ) + assertEquals( + symbol.descriptionKey + " code must survive codeOf", + symbol.code, + AprsBeacon.codeOf(symbol.code.toString()) + ) + } + } + + /** Two entries rendering the same pair would be a UI ambiguity. */ + @Test + fun `no two curated symbols are the same pair`() { + val pairs = AprsSymbols.curated.map { it.table to it.code } + assertEquals("pairs must be unique", pairs.size, pairs.toSet().size) + } + + /** Each needs its own description, or the list reads as duplicates. */ + @Test + fun `every curated symbol has a distinct description key`() { + val keys = AprsSymbols.curated.map { it.descriptionKey } + assertEquals("description keys must be unique", keys.size, keys.toSet().size) + assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") }) + } + + @Test + fun `a stored pair on the list is found`() { + assertEquals(AprsSymbols.HOUSE, AprsSymbols.find('/', '-')) + assertNotNull(AprsSymbols.find('\\', 'S')) + } + + /** + * A pair the list does not offer must report as absent rather than resolving to something near + * it. The picker relies on this to show an existing setting untouched. + */ + @Test + fun `a stored pair off the list is not silently substituted`() { + assertNull(AprsSymbols.find('/', 'S')) + assertNull(AprsSymbols.find('/', '!')) + assertNull(AprsSymbols.find('\\', 'y')) + } + + /** The settings screen holds strings, and only the first character counts. */ + @Test + fun `the string overload reads the first character`() { + assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/", "-")) + assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/junk", "-junk")) + } + + /** Blank fields mean the shipped default, which is what the save path substitutes. */ + @Test + fun `blank strings resolve to the default symbol`() { + assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("", "")) + } + + /** The default has to be on the list, or the picker opens showing nothing selected. */ + @Test + fun `the default symbol is on the curated list`() { + assertTrue(AprsSymbols.HOUSE in AprsSymbols.curated) + } + + /** Short enough to scan in a bottom sheet during setup. */ + @Test + fun `the list stays short`() { + assertTrue( + "a curated list of ${AprsSymbols.curated.size} defeats the point", + AprsSymbols.curated.size in 8..20 + ) + } +} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index b75408a7..39acf7e4 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -28,6 +28,23 @@ 周期(分钟) 状态文本 符号表 + 房屋 - 固定台站 + 房屋, 备用符号表 + 步行操作员 + 固定站八木天线 + 卫星台站 + 便携 / 野外运行 + 手机 + 仅互联网台站 + 手持电台 + 轿车 + 卡车 + 厢式车 + 房车 + 自行车 + 地图符号 + 自定义 (%1$s%2$s) + 你的台站在 APRS 地图上的显示方式 符号码 保存 取消 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index a44bc0f4..4c606b05 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -29,6 +29,23 @@ Interval (min) Status text Symbol table + House - fixed station + House, alternate table + Person on foot + Yagi at a fixed site + Satellite station + Portable / field operation + Phone + Internet-only station + Handheld radio + Car + Truck + Van + Motorhome + Bicycle + Map symbol + Custom (%1$s%2$s) + How your station appears on the APRS map Symbol code Save Cancel diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt index 841c666d..10536613 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt @@ -11,6 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import com.rtbishop.look4sat.core.domain.aprs.AprsSymbols import androidx.compose.material3.ElevatedCard import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -179,6 +185,7 @@ fun AprsCard() { } /** APRS settings dialog (plenty of room, full config) */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AprsSettingsDialog( config: AprsConfig, @@ -194,6 +201,7 @@ private fun AprsSettingsDialog( var status by remember { mutableStateOf(config.statusText) } var symbolTable by remember { mutableStateOf(config.symbolTable) } var symbolCode by remember { mutableStateOf(config.symbolCode) } + var symbolMenuExpanded by remember { mutableStateOf(false) } val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp) val context = LocalContext.current @@ -274,24 +282,62 @@ private fun AprsSettingsDialog( textStyle = textStyle ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // A list rather than two free-text fields. The fields accepted any string and used + // only the first character, so typing "satellite" persisted the word and beaconed + // as `/`. The pair that makes this worth doing: \S is Satellite but /S is SHUTTLE. + val selected = AprsSymbols.find(symbolTable, symbolCode) + val customLabel = stringResource( + id = R.string.prefs_aprs_symbol_custom, + symbolTable.take(1), + symbolCode.take(1) + ) + val selectedLabel = selected?.let { symbolLabel(it.descriptionKey) } ?: customLabel + ExposedDropdownMenuBox( + expanded = symbolMenuExpanded, + onExpandedChange = { symbolMenuExpanded = it } + ) { OutlinedTextField( - value = symbolTable, - onValueChange = { symbolTable = it }, - label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = textStyle - ) - OutlinedTextField( - value = symbolCode, - onValueChange = { symbolCode = it }, - label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) }, - singleLine = true, - modifier = Modifier.weight(1f), - textStyle = textStyle + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(id = R.string.prefs_aprs_symbol)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = symbolMenuExpanded) + }, + textStyle = textStyle, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() ) + ExposedDropdownMenu( + expanded = symbolMenuExpanded, + onDismissRequest = { symbolMenuExpanded = false } + ) { + // An existing setting that is not on the list appears first and stays + // selectable, so opening the menu cannot silently change it. + if (selected == null) { + DropdownMenuItem( + text = { Text(text = customLabel, maxLines = 1) }, + onClick = { symbolMenuExpanded = false } + ) + } + AprsSymbols.curated.forEach { symbol -> + DropdownMenuItem( + text = { Text(text = symbolLabel(symbol.descriptionKey), maxLines = 1) }, + onClick = { + symbolTable = symbol.table.toString() + symbolCode = symbol.code.toString() + symbolMenuExpanded = false + } + ) + } + } } + Text( + text = stringResource(id = R.string.prefs_aprs_symbol_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Text( text = stringResource(id = R.string.prefs_aprs_passcode_hint), fontSize = 12.sp, @@ -316,7 +362,7 @@ private fun AprsSettingsDialog( intervalMin = interval.toIntOrNull() ?: 5, statusText = status, symbolTable = symbolTable.ifBlank { "/" }, - symbolCode = symbolCode.ifBlank { ">" } + symbolCode = symbolCode.ifBlank { "-" } ) ) }) { Text(stringResource(id = R.string.prefs_aprs_save)) } @@ -339,3 +385,30 @@ private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (( Switch(checked = checked, onCheckedChange = onCheckedChange) } } + +/** + * Resolve a symbol's description key to its localised text. + * + * AprsSymbols names a resource rather than holding text, because core:domain cannot reach resources + * and hardcoding English there would put wording outside the locale files. The mapping has to live + * on this side, and an unknown key falls back to the key itself rather than crashing - a missing + * translation should not take the settings screen down. + */ +@Composable +private fun symbolLabel(descriptionKey: String): String = when (descriptionKey) { + "aprs_symbol_house" -> stringResource(id = R.string.aprs_symbol_house) + "aprs_symbol_house_alt" -> stringResource(id = R.string.aprs_symbol_house_alt) + "aprs_symbol_person" -> stringResource(id = R.string.aprs_symbol_person) + "aprs_symbol_yagi" -> stringResource(id = R.string.aprs_symbol_yagi) + "aprs_symbol_satellite" -> stringResource(id = R.string.aprs_symbol_satellite) + "aprs_symbol_portable" -> stringResource(id = R.string.aprs_symbol_portable) + "aprs_symbol_phone" -> stringResource(id = R.string.aprs_symbol_phone) + "aprs_symbol_tcpip" -> stringResource(id = R.string.aprs_symbol_tcpip) + "aprs_symbol_ht" -> stringResource(id = R.string.aprs_symbol_ht) + "aprs_symbol_car" -> stringResource(id = R.string.aprs_symbol_car) + "aprs_symbol_truck" -> stringResource(id = R.string.aprs_symbol_truck) + "aprs_symbol_van" -> stringResource(id = R.string.aprs_symbol_van) + "aprs_symbol_rv" -> stringResource(id = R.string.aprs_symbol_rv) + "aprs_symbol_bike" -> stringResource(id = R.string.aprs_symbol_bike) + else -> descriptionKey +}