diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3b572d8..07159276 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,7 +15,20 @@
-
+
+
+
+
+ android:foregroundServiceType="location" />
diff --git a/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt b/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt
index 4e11d22b..c9e67028 100644
--- a/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt
+++ b/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt
@@ -1,5 +1,6 @@
package com.rtbishop.look4sat.app
+import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -37,6 +38,11 @@ class AprsForegroundService : Service() {
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
+
+ /** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
+ const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
+
+ private const val ALARM_REQUEST = 4101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -63,6 +69,12 @@ class AprsForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
+ ACTION_ALARM_TICK -> {
+ // Woken by the exact alarm. Reporting once and then booking the next tick, rather
+ // than using a repeating alarm, means a changed interval takes effect at once.
+ if (reporter == null) startReporting() else reporter?.reportNow()
+ scheduleNextTick()
+ }
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
@@ -119,9 +131,12 @@ class AprsForegroundService : Service() {
)
reporter = rep
rep.start()
+ // The reporter beacons once on start; the alarm carries every one after that.
+ scheduleNextTick()
}
private fun stopReporting() {
+ cancelTicks()
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -212,4 +227,43 @@ class AprsForegroundService : Service() {
reporter = null
super.onDestroy()
}
+
+ /**
+ * Book the next beacon with an exact alarm.
+ *
+ * A coroutine delay was used before, which Doze defeats: the timer fires but network access is
+ * suspended and wake locks are ignored, even inside a foreground service. Only
+ * setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
+ * the system's throttle on how often such an alarm may repeat.
+ */
+ private fun scheduleNextTick() {
+ val cfg = AprsStore.loadConfig(this)
+ if (!cfg.enabled) return
+ val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
+ val at = System.currentTimeMillis() + minutes * 60_000L
+ runCatching {
+ val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
+ if (exact) {
+ alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
+ } else {
+ // The operator revoked exact alarms. An inexact one still beacons, just whenever
+ // the system decides, which beats not beaconing at all.
+ alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
+ }
+ }
+ }
+
+ private fun cancelTicks() {
+ val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ runCatching { alarms.cancel(tickIntent()) }
+ }
+
+ private fun tickIntent(): PendingIntent = PendingIntent.getService(
+ this,
+ ALARM_REQUEST,
+ Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt
index 50666e2d..a8dfdb45 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt
@@ -1,13 +1,11 @@
package com.rtbishop.look4sat.core.data.aprs
-import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
+import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
-import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -70,12 +68,11 @@ class AprsReporter(
onState(AprsState.Error)
return
}
- job = scope.launch {
- while (isActive) {
- reportOnce()
- delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
- }
- }
+ // One report now; the service's exact alarm drives every one after this. The loop that
+ // used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
+ // schedule and then finds network access suspended, so the beacon stopped whenever the
+ // screen locked while the notification still claimed it was running.
+ job = scope.launch { reportOnce() }
}
fun stop() {
@@ -111,7 +108,25 @@ class AprsReporter(
onState(AprsState.Connected)
val pos = positionProvider()
- val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
+ val beacon = AprsBeacon.build(
+ callsign = cfg.callsign,
+ ssid = cfg.ssid,
+ latitude = pos?.first,
+ longitude = pos?.second,
+ symbolTable = cfg.symbolTable,
+ symbolCode = cfg.symbolCode,
+ comment = cfg.statusText
+ )
+ // Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
+ // of Guinea on the global network, under the operator's own callsign.
+ if (beacon is AprsBeacon.Result.Blocked) {
+ onState(AprsState.Error)
+ onReport(
+ AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
+ )
+ return
+ }
+ val packetLine = (beacon as AprsBeacon.Result.Line).text
val result = c.sendPacket(packetLine)
val sent = result?.first == true
val detail = result?.second ?: "no connection"
@@ -132,15 +147,21 @@ class AprsReporter(
}
}
- /** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME'
- )
- return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
+
+ /** A short reason for a refusal, for the operator's last-report line. */
+ private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
+ AprsBeacon.Refusal.NoPosition -> "no position yet"
+ AprsBeacon.Refusal.NoCallsign -> "no callsign set"
+ is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
}
+
+
+
+
+ companion object {
+
+ /** Floor for the reporting interval, in minutes. */
+ const val MIN_INTERVAL_MIN = 5
+ }
+
}
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
new file mode 100644
index 00000000..1ab58232
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt
@@ -0,0 +1,147 @@
+/*
+ * 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
+
+/**
+ * Builds the position line this station puts on APRS-IS, or refuses to.
+ *
+ * Separated from the reporter so the packet can be tested without a socket. Every rule here
+ * comes from aprs-is.net/connecting.aspx, and each of them was being broken:
+ *
+ * - the path must be exactly `TCPIP*`, and was absent entirely
+ * - the line must not exceed 512 bytes including CRLF, and had no cap
+ * - the comment must not contain a line break, or it injects a second packet
+ * - a station with no position must not transmit, where 0.0 was substituted so 0 degrees north,
+ * 0 degrees east - a point in the Gulf of Guinea - went out under the operator's callsign
+ */
+object AprsBeacon {
+
+ /** The only path a client-originated packet may carry. */
+ const val PATH = "TCPIP*"
+
+ /** Destination for a position report with no addressee. */
+ const val DESTINATION = "APRS"
+
+ /** Maximum line length including the CRLF the caller appends. */
+ const val MAX_LINE_BYTES = 512
+
+ /** Comment limit for this position format, per the APRS specification. */
+ const val MAX_COMMENT = 43
+
+ /** Why a beacon could not be built. */
+ sealed interface Refusal {
+
+ /** No position was available. Transmitting 0,0 would claim the Gulf of Guinea. */
+ data object NoPosition : Refusal
+
+ /** The callsign is missing, so the packet would have no valid source. */
+ data object NoCallsign : Refusal
+
+ /** Latitude or longitude outside the possible range. */
+ data class ImpossiblePosition(val latitude: Double, val longitude: Double) : Refusal
+ }
+
+ /** Either a line ready to send, or the reason there is none. */
+ sealed interface Result {
+ data class Line(val text: String) : Result
+ data class Blocked(val refusal: Refusal) : Result
+ }
+
+ /**
+ * Build the position line.
+ *
+ * Returns [Result.Blocked] rather than a placeholder: a beacon is a claim about where the
+ * operator is, and there is no honest default for "nowhere".
+ */
+ fun build(
+ callsign: String,
+ ssid: String,
+ latitude: Double?,
+ longitude: Double?,
+ symbolTable: String,
+ symbolCode: String,
+ comment: String
+ ): Result {
+ if (callsign.isBlank()) return Result.Blocked(Refusal.NoCallsign)
+ if (latitude == null || longitude == null) return Result.Blocked(Refusal.NoPosition)
+ if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
+ return Result.Blocked(Refusal.ImpossiblePosition(latitude, longitude))
+ }
+ val source = AprsPacket.formatCallSsid(callsign.trim().uppercase(), ssid.trim())
+ val position = AprsPosition(
+ latitude = latitude,
+ longitude = longitude,
+ symbolTable = tableOf(symbolTable),
+ symbolCode = codeOf(symbolCode)
+ )
+ val header = "$source>$DESTINATION,$PATH:="
+ val body = position.toUncompressedString()
+ val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size
+ return Result.Line(header + body + sanitiseComment(comment, room))
+ }
+
+ /**
+ * Strip anything that would break the line, then trim to fit.
+ *
+ * A newline typed into the comment field used to end the packet early and start a second one
+ * from the remaining text - an injection the operator could trigger by accident.
+ */
+ fun sanitiseComment(comment: String, room: Int = MAX_COMMENT): String {
+ if (room <= 0) return ""
+ val cleaned = comment.asSequence()
+ // Printable ASCII only: line breaks split the packet, and control characters have no
+ // meaning in a comment while being able to confuse a parser.
+ .filter { it.code in 0x20..0x7E }
+ .joinToString("")
+ .trim()
+ val limit = minOf(MAX_COMMENT, room)
+ return if (cleaned.length <= limit) cleaned else cleaned.take(limit)
+ }
+
+ /**
+ * The symbol table byte, defaulting to the primary table.
+ *
+ * Must be `/`, `\` or an overlay character. It was previously whatever the operator typed
+ * first - any character at all, including one that breaks the fixed-width parse. aprs.fi
+ * names symbol misconfiguration as the most common reason a station never appears on the map.
+ */
+ fun tableOf(entry: String): Char {
+ val candidate = entry.trim().firstOrNull() ?: return TABLE_PRIMARY
+ return when {
+ candidate == TABLE_PRIMARY || candidate == TABLE_ALTERNATE -> candidate
+ candidate.isDigit() -> candidate
+ candidate in 'A'..'Z' -> candidate
+ else -> TABLE_PRIMARY
+ }
+ }
+
+ /** The symbol byte. Any printable character is a valid symbol; anything else is not. */
+ fun codeOf(entry: String): Char {
+ val candidate = entry.trim().firstOrNull() ?: return DEFAULT_SYMBOL
+ return if (candidate.code in 0x21..0x7E) candidate else DEFAULT_SYMBOL
+ }
+
+ private const val TABLE_PRIMARY = '/'
+ private const val TABLE_ALTERNATE = '\\'
+
+ /** Bytes the caller adds after the line. */
+ 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 = '>'
+}
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
new file mode 100644
index 00000000..08a7cf9e
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt
@@ -0,0 +1,172 @@
+package com.rtbishop.look4sat.core.domain.aprs
+
+import java.util.Locale
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
+ * broken by the previous one-line string template.
+ */
+class AprsBeaconTest {
+
+ private val original: Locale = Locale.getDefault()
+
+ @After
+ fun restoreLocale() {
+ Locale.setDefault(original)
+ }
+
+ private fun line(
+ latitude: Double? = 51.5,
+ longitude: Double? = -0.12,
+ callsign: String = "BG7NTA",
+ ssid: String = "5",
+ table: String = "/",
+ code: String = "[",
+ comment: String = "Look4Sat"
+ ): String {
+ val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
+ assertTrue("expected a line, got $result", result is AprsBeacon.Result.Line)
+ return (result as AprsBeacon.Result.Line).text
+ }
+
+ /**
+ * The specification is explicit: a client-originated packet carries TCPIP* in the path,
+ * "nothing more or less". The old template emitted `CALL>APRS:=...` with no path at all.
+ */
+ @Test
+ fun `the path is exactly TCPIP star`() {
+ val text = line()
+ assertTrue(text, text.startsWith("BG7NTA-5>APRS,TCPIP*:="))
+ assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
+ }
+
+ /**
+ * No position means no beacon. Substituting 0.0 put the station at 0N 0E - the Gulf of
+ * Guinea - on the global network, under the operator's own callsign.
+ */
+ @Test
+ fun `a missing position refuses to build rather than claiming zero`() {
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
+ AprsBeacon.build("BG7NTA", "5", null, null, "/", "[", "x")
+ )
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
+ AprsBeacon.build("BG7NTA", "5", 51.5, null, "/", "[", "x")
+ )
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
+ AprsBeacon.build("BG7NTA", "5", null, -0.12, "/", "[", "x")
+ )
+ }
+
+ /** A genuine 0,0 fix is legal and must still be sent - the refusal is about absence. */
+ @Test
+ fun `an actual zero position is still a position`() {
+ val result = AprsBeacon.build("BG7NTA", "5", 0.0, 0.0, "/", "[", "x")
+ assertTrue(result is AprsBeacon.Result.Line)
+ }
+
+ @Test
+ fun `an impossible position is refused`() {
+ val result = AprsBeacon.build("BG7NTA", "5", 91.0, 0.0, "/", "[", "x")
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.ImpossiblePosition(91.0, 0.0)),
+ result
+ )
+ }
+
+ @Test
+ fun `no callsign means no packet`() {
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
+ AprsBeacon.build("", "5", 51.5, -0.12, "/", "[", "x")
+ )
+ assertEquals(
+ AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
+ AprsBeacon.build(" ", "5", 51.5, -0.12, "/", "[", "x")
+ )
+ }
+
+ /**
+ * A newline in the comment ended the packet early and started a second one from the rest.
+ * The operator could trigger that by pressing return in a text field.
+ */
+ @Test
+ fun `a line break in the comment cannot split the packet`() {
+ val text = line(comment = "hello\r\nCALL>APRS,TCPIP*:=injected")
+ // Only the line break matters. The text of a second packet surviving inside the comment
+ // is harmless - without a terminator the server reads one line, and a comment is free to
+ // contain any printable characters the operator likes.
+ assertFalse(text, text.contains('\n'))
+ assertFalse(text, text.contains('\r'))
+ assertEquals("must remain a single line", 1, text.lines().size)
+ }
+
+ @Test
+ fun `control characters are stripped from the comment`() {
+ val text = line(comment = "a\tb\u0000c")
+ assertTrue(text, text.endsWith("abc"))
+ }
+
+ /** The line must fit in 512 bytes including the CRLF the client appends. */
+ @Test
+ fun `an over-long comment is trimmed to keep the line legal`() {
+ val text = line(comment = "x".repeat(600))
+ assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512)
+ }
+
+ /** The comment limit for this format is 43 characters. */
+ @Test
+ fun `the comment respects the format's own limit`() {
+ assertEquals(AprsBeacon.MAX_COMMENT, AprsBeacon.sanitiseComment("y".repeat(100)).length)
+ }
+
+ /**
+ * Symbol misconfiguration is, per aprs.fi, the most common reason a station never appears.
+ * The table byte was previously the first character of whatever was typed.
+ */
+ @Test
+ fun `the symbol table falls back to the primary table when invalid`() {
+ assertEquals('/', AprsBeacon.tableOf(""))
+ assertEquals('/', AprsBeacon.tableOf("!"))
+ assertEquals('/', AprsBeacon.tableOf(" "))
+ assertEquals('/', AprsBeacon.tableOf("/"))
+ assertEquals('\\', AprsBeacon.tableOf("\\"))
+ // Overlays are legal: a digit or an upper-case letter selects the alternate table.
+ assertEquals('7', AprsBeacon.tableOf("7"))
+ assertEquals('S', AprsBeacon.tableOf("S"))
+ }
+
+ @Test
+ fun `the symbol code falls back when unprintable`() {
+ assertEquals('>', AprsBeacon.codeOf(""))
+ assertEquals('>', AprsBeacon.codeOf(" "))
+ assertEquals('[', AprsBeacon.codeOf("["))
+ }
+
+ /**
+ * Coordinates are fixed-width digits. A locale that formats decimals with a comma would
+ * corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
+ */
+ @Test
+ fun `a comma-decimal locale does not corrupt the coordinates`() {
+ val reference = line()
+ for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
+ Locale.setDefault(Locale.forLanguageTag(tag))
+ assertEquals("locale $tag changed the packet", reference, line())
+ }
+ }
+
+ /** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
+ @Test
+ fun `the line is pure ascii`() {
+ val text = line(comment = "café 北京")
+ assertTrue(text, text.all { it.code in 0x20..0x7E })
+ }
+}