From f4e188261d9842deab2d7be6465daa71a33380ea Mon Sep 17 00:00:00 2001 From: QIU Date: Fri, 14 Aug 2026 15:09:18 +0000 Subject: [PATCH] fix(aprs): format packets with Locale.ROOT so they stay ASCII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine String.format calls in AprsPacket used the JVM default locale. On a device set to Arabic, Persian or Bengali the digit shapes come out as Eastern Arabic / Bengali numerals, so every position report was malformed: ar_EG lat=٣٩٥٤.٢٥N lon=١١٦٢٤.٤٤E alt=/A=٠٠٠٣٢٨ fa_IR lat=۳۹۵۴.۲۵N lon=۱۱۶۲۴.۴۴E alt=/A=۰۰۰۳۲۸ bn_BD lat=৩৯৫৪.২৫N lon=১১৬২৪.৪৪E alt=/A=০০০৩২৮ APRS-IS is an ASCII line protocol, so aprsc rejects these packets outright: APRS reporting simply never worked for those users, with no clear error. A locale using ',' as the decimal separator would corrupt the range filter the same way. Affected: getDMS position encoding (all five ambiguity branches), the DDMM.MM/DDDMM.MM assembly, formatAltitude, formatCourseSpeed and formatRangeFilter. TDD proof: without Locale.ROOT: 4 of 4 AprsPacketLocaleTest cases FAILED with Locale.ROOT: BUILD SUCCESSFUL, full :core:domain:test green Ruled out by the same probe (no change made): getDMS degree/minute split matches an independent DDMM.MM reference implementation over 1,800,000 sampled latitudes with zero divergence; the passcode loop dropping the trailing NUL on even-length callsigns is the standard algorithm's behaviour. --- .../look4sat/core/domain/aprs/AprsPacket.kt | 19 ++--- .../core/domain/aprs/AprsPacketLocaleTest.kt | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt index d0b5a367..37e84237 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt @@ -2,6 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs import kotlin.math.abs import kotlin.math.round +import java.util.Locale /** * APRS-IS protocol core (pure Kotlin, no Android dependencies). @@ -35,20 +36,20 @@ object AprsPacket { /** Optional distance filter: filter r/lat/lon/dist */ fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String { - return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm) + return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm) } /** Altitude extension /A=00000 (feet) */ fun formatAltitude(altitudeMeters: Double?): String { if (altitudeMeters == null) return "" - return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt()) + return String.format(Locale.ROOT, "/A=%06d", (altitudeMeters * 3.2808399).toInt()) } /** Speed/course extension (knots/degrees) */ fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String { if (speedMps == null || bearing == null) return "" val knots = (speedMps * 1.94384449).toInt() - return String.format("/%03d/%03d", bearing.toInt(), knots) + return String.format(Locale.ROOT, "/%03d/%03d", bearing.toInt(), knots) } } @@ -100,17 +101,17 @@ class AprsPosition( val hundredths = iRound % 100 val frac = when (positionAmbiguity) { 1 -> " . " - 2 -> String.format("%d . ", minutes / 10) - 3 -> String.format("%02d. ", minutes) - 4 -> String.format("%02d.%d ", minutes, hundredths / 10) - else -> String.format("%02d.%02d", minutes, hundredths) + 2 -> String.format(Locale.ROOT, "%d . ", minutes / 10) + 3 -> String.format(Locale.ROOT, "%02d. ", minutes) + 4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10) + else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths) } return if (isLat) { val ns = if (value >= 0) 'N' else 'S' - String.format("%02d%s%c", degrees, frac, ns) + String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns) } else { val ew = if (value >= 0) 'E' else 'W' - String.format("%03d%s%c", degrees, frac, ew) + String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew) } } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt new file mode 100644 index 00000000..6ed412f4 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt @@ -0,0 +1,75 @@ +package com.rtbishop.look4sat.core.domain.aprs + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** + * APRS-IS is an ASCII line protocol. Formatting the position, altitude and + * course/speed extensions with the JVM default locale produced Eastern Arabic + * or Bengali digits on devices set to ar/fa/bn, and the server rejects those + * packets. + * + * Regression guard: every formatted field must stay ASCII regardless of the + * default locale. + */ +class AprsPacketLocaleTest { + + private val original: Locale = Locale.getDefault() + + @After + fun restoreLocale() { + Locale.setDefault(original) + } + + private val asciiPacket = Regex("^[\\x20-\\x7E]*$") + + @Test + fun position_staysAsciiUnderArabicLocale() { + Locale.setDefault(Locale.forLanguageTag("ar-EG")) + + val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString() + + assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded)) + assertEquals("3954.25N/11624.44E>", encoded) + } + + @Test + fun position_staysAsciiUnderBengaliLocale() { + Locale.setDefault(Locale.forLanguageTag("bn-BD")) + + val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString() + + assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded)) + assertEquals("3352.13S/15112.56E>", encoded) + } + + @Test + fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() { + Locale.setDefault(Locale.forLanguageTag("fa-IR")) + + val altitude = AprsPacket.formatAltitude(100.0) + val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f) + val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100) + + assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude)) + assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed)) + assertTrue("not ASCII: $filter", asciiPacket.matches(filter)) + assertEquals("/A=000328", altitude) + assertEquals("/090/019", courseSpeed) + assertEquals("r/39.904/116.407/100", filter) + } + + @Test + fun ambiguousPosition_staysAsciiUnderArabicLocale() { + Locale.setDefault(Locale.forLanguageTag("ar-EG")) + + for (ambiguity in 1..4) { + val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity) + .toUncompressedString() + assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded)) + } + } +}