fix(aprs): format packets with Locale.ROOT so they stay ASCII
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.
This commit is contained in:
1 parent
e1233dceaa
commit
f4e188261d
2 files changed
+85
-9
No files matched your search
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
|
|||||||
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||||
@@ -35,20 +36,20 @@ object AprsPacket {
|
|||||||
|
|
||||||
/** Optional distance filter: filter r/lat/lon/dist */
|
/** Optional distance filter: filter r/lat/lon/dist */
|
||||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
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) */
|
/** Altitude extension /A=00000 (feet) */
|
||||||
fun formatAltitude(altitudeMeters: Double?): String {
|
fun formatAltitude(altitudeMeters: Double?): String {
|
||||||
if (altitudeMeters == null) return ""
|
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) */
|
/** Speed/course extension (knots/degrees) */
|
||||||
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||||
if (speedMps == null || bearing == null) return ""
|
if (speedMps == null || bearing == null) return ""
|
||||||
val knots = (speedMps * 1.94384449).toInt()
|
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 hundredths = iRound % 100
|
||||||
val frac = when (positionAmbiguity) {
|
val frac = when (positionAmbiguity) {
|
||||||
1 -> " . "
|
1 -> " . "
|
||||||
2 -> String.format("%d . ", minutes / 10)
|
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||||
3 -> String.format("%02d. ", minutes)
|
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||||
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||||
else -> String.format("%02d.%02d", minutes, hundredths)
|
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||||
}
|
}
|
||||||
return if (isLat) {
|
return if (isLat) {
|
||||||
val ns = if (value >= 0) 'N' else 'S'
|
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 {
|
} else {
|
||||||
val ew = if (value >= 0) 'E' else 'W'
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+75
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user