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)) + } + } +}