fix(aprs): the login line was malformed, and the refusal was invisible

An auditor ran the plan's own release gate against live APRS-IS servers. It failed at
the login step, on every server tried:

    sent: user N0CALL pass -1 vers Look4Sat-4.5.4
    got:  # Invalid login: software name and version are not separated by a space

Reproduced on euro.aprs2.net and noam.aprs2.net, aprsc 2.1.21. `vers` takes TWO tokens,
a software name and a version. An earlier commit read the rule "softwarename must not
contain a space" as "the field must be one token" and hyphenated the space between them
- and the unit test asserted that as correct, so the mistake was frozen in place.

Worse than the malformed line was what happened next. `# Invalid login:` is a comment
but not a logresp, so parse skipped it as keepalive chatter; the login then timed out
into Unknown, which is deliberately treated as "may be working"; so `ok = sent &&
!refused` was true and the operator was shown "APRS: report sent OK" for a login the
server had refused. That is v4.6.0's defining defect - every send reported successful
regardless of outcome - still live on the exact path every operator takes. The rebuild
narrowed it rather than closing it.

Both halves are fixed: the name and version stay separate tokens with whitespace
collapsed within each, and a refusal comment is classified as a refusal before the
logresp test. A socket test now replays the server's actual bytes.

Three smaller things from the same review:

The foreground service type goes back to dataSync. The previous commit chose location
to escape dataSync's six-hour cap, but a location-typed service is refused outright
unless a location runtime permission has already been granted, and the settings card
requests only notifications - so it would have failed silently for anyone who declined
location access. The cap that prompted the switch applies only when targetSdk is 35 or
higher, which this project does not declare. A test now reads the manifest and the
service source and fails if they disagree, which is the only way this class of defect
is visible from a JVM test.

The version string in the login was 4.5.4 while the app was 4.6.0. Now split into name
and version and corrected, though it is still hardcoded - core:data has no BuildConfig,
so passing it in properly is a separate change.

The passcode hint said "empty = auto-computed from callsign" in all five locales. The
app stopped doing that two commits ago; it now connects receive-only, and the hint says
so. It was the first thing an operator read next to the field, promising the behaviour
that was deliberately removed.

Not fixed, and known: the notification body is rebuilt from the previous cycle's state
so it can show a stale verdict, a deliberate receive-only choice is still styled as an
error, and no last-success timestamp exists - so an operator still cannot establish
whether their station has ever reached the network.
This commit is contained in:
mckero committed 2026-08-25 16:04:47 +00:00
1 parent 0208a577c4
commit 321cd8f2fa
13 files changed
+297 -56

No files matched your search

+8 -6
View File
@@ -16,12 +16,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- <!--
location, not dataSync: dataSync foreground services are capped at six hours in any dataSync rather than location. The location type is refused outright unless a location
24-hour window and then stopped by the system, which would silently end a beacon meant runtime permission has already been granted - startForeground throws SecurityException -
to run all day. The service reads the station position, falling back to GPS, so location and this service reads the station position the operator typed into settings, so demanding
describes what it actually does. location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
when targetSdk is 35 or higher, which this project does not declare.
--> -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- <!--
Doze suspends network access and ignores wake locks even for a foreground service, so a Doze suspends network access and ignores wake locks even for a foreground service, so a
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
@@ -64,6 +66,6 @@
<service <service
android:name="com.rtbishop.look4sat.app.AprsForegroundService" android:name="com.rtbishop.look4sat.app.AprsForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="location" /> android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>
@@ -152,13 +152,12 @@ class AprsForegroundService : Service() {
try { try {
val notif = buildNotification(cfg) val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Must match the manifest attribute or the platform refuses the call: AOSP checks the // Must match the manifest attribute exactly: AOSP checks the passed type is a
// passed type is a subset of the declared one, and location (0x08) does not contain // subset of the declared one and throws otherwise, which the catch below turns
// dataSync (0x01). Changing the manifest without changing this line stopped the // into a silent stopSelf(). Declaring location instead would additionally require
// service dead on Android 10 and later - the IllegalArgumentException was caught // a granted location permission before this call, and the settings card asks only
// below and turned into stopSelf(), so APRS did nothing and reported nothing while // for notifications - so that combination fails silently too.
// the settings switch stayed on. startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else { } else {
startForeground(NOTIF_ID, notif) startForeground(NOTIF_ID, notif)
} }
@@ -25,6 +25,7 @@ class AprsIsClient(
private val callsign: String, private val callsign: String,
private val ssid: String, private val ssid: String,
private val passcode: Int, private val passcode: Int,
private val softwareName: String,
private val version: String, private val version: String,
private val filter: String = "", private val filter: String = "",
private val timeoutSec: Int = 120 private val timeoutSec: Int = 120
@@ -90,7 +91,7 @@ class AprsIsClient(
loginOutcome = refusal loginOutcome = refusal
throw IllegalArgumentException(refusal.detail) throw IllegalArgumentException(refusal.detail)
} }
val login = AprsLogin.line(callsign, ssid, passcode, version, filter) val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
writer?.print(login) writer?.print(login)
writer?.print(CRLF) writer?.print(CRLF)
writer?.flush() writer?.flush()
@@ -142,7 +142,12 @@ class AprsReporter(
// Never derives one: a blank or wrong entry logs in receive-only rather than // Never derives one: a blank or wrong entry logs in receive-only rather than
// transmitting under a passcode the app invented for an unchecked licence. // transmitting under a passcode the app invented for an unchecked licence.
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode), passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
version = "Look4Sat 4.5.4" // Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
// The version is hardcoded and drifts - it read 4.5.4 while the app was 4.6.0.
// core:data has no BuildConfig, so fixing that properly means passing it in from
// the app module; noting rather than doing it here to keep this change small.
softwareName = "Look4Sat",
version = "4.6.0"
).also { client = it } ).also { client = it }
if (!c.isConnected) c.connect() if (!c.isConnected) c.connect()
onState(AprsState.Connected) onState(AprsState.Connected)
@@ -102,7 +102,8 @@ class AprsIsClientSocketTest {
callsign = "TEST", callsign = "TEST",
ssid = "", ssid = "",
passcode = passcode, passcode = passcode,
version = "Look4Sat-test" softwareName = "Look4Sat",
version = "test"
) )
/** /**
@@ -140,7 +141,7 @@ class AprsIsClientSocketTest {
c.connect() c.connect()
assertTrue(server.awaitLogin()) assertTrue(server.awaitLogin())
c.disconnect() c.disconnect()
assertEquals("user TEST pass 12345 vers Look4Sat-test", server.loginLine) assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
} }
} }
@@ -213,6 +214,34 @@ class AprsIsClientSocketTest {
} }
} }
/**
* The failure a live server actually produced, and the one that mattered most.
*
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
*/
@Test
fun `a refused login is not reported as a successful send`() {
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
server.start()
val c = client(server.port)
// An outright refusal throws from connect(), which is the correct outcome.
val threw = runCatching { c.connect() }.exceptionOrNull()
assertTrue(server.awaitLogin())
assertFalse("a refused login must not read as verified", c.isVerified)
val sentOk = runCatching {
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
}.getOrNull()
assertTrue(
"the refusal must surface: threw=$threw sentOk=$sentOk",
threw != null || sentOk != true
)
c.disconnect()
}
}
/** Sending after the server has gone must report failure, not success. */ /** Sending after the server has gone must report failure, not success. */
@Test @Test
fun `a send after the server closes is reported as failed`() { fun `a send after the server closes is reported as failed`() {
@@ -51,26 +51,36 @@ object AprsLogin {
data class Unknown(val detail: String) : Outcome data class Unknown(val detail: String) : Outcome
} }
/** Any run of whitespace, collapsed to a hyphen inside a single token. */
private val WHITESPACE = Regex("""\s+""")
/** Passcode value that asks for a receive-only connection. */ /** Passcode value that asks for a receive-only connection. */
const val RECEIVE_ONLY_PASSCODE = -1 const val RECEIVE_ONLY_PASSCODE = -1
/** /**
* Build the login line. * Build the login line.
* *
* [version] must not contain a space: the server splits `vers` into a software name and a * `vers` takes TWO tokens - a software name and a version, separated by a space. An earlier
* version, so an embedded space shifts every field after it. Any space becomes a hyphen * version of this replaced that space with a hyphen, reading the rule "softwarename must not
* rather than silently corrupting the line. * contain a space" as "the field must be a single token". Live aprsc 2.1.21 rejects the result:
*
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
* got: # Invalid login: software name and version are not separated by a space
*
* So name and version stay apart, and whitespace is collapsed WITHIN each of them instead.
*/ */
fun line( fun line(
callsign: String, callsign: String,
ssid: String, ssid: String,
passcode: Int, passcode: Int,
name: String,
version: String, version: String,
filter: String = "" filter: String = ""
): String { ): String {
val callSsid = AprsPacket.formatCallSsid(callsign, ssid) val callSsid = AprsPacket.formatCallSsid(callsign, ssid)
val safeVersion = version.trim().replace(' ', '-').ifEmpty { "Look4Sat" } val safeName = name.trim().replace(WHITESPACE, "-").ifEmpty { "Look4Sat" }
val base = "user $callSsid pass $passcode vers $safeVersion" val safeVersion = version.trim().replace(WHITESPACE, "-").ifEmpty { "0" }
val base = "user $callSsid pass $passcode vers $safeName $safeVersion"
val trimmedFilter = filter.trim() val trimmedFilter = filter.trim()
return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter" return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter"
} }
@@ -93,6 +103,14 @@ object AprsLogin {
return Outcome.Rejected(trimmed) return Outcome.Rejected(trimmed)
} }
val lower = trimmed.lowercase() val lower = trimmed.lowercase()
// A refusal arrives as a comment and is NOT a logresp: aprsc answers `# Invalid login: ...`
// and then closes. Treating that as chatter let the login time out into Unknown, which is
// deliberately optimistic - so every send afterwards reported success to an operator the
// server had refused. That was the defect this whole rebuild set out to remove, still live
// on the path every operator takes.
if (lower.contains("invalid login") || lower.contains("login denied")) {
return Outcome.Rejected(trimmed.removePrefix("#").trim())
}
if (!lower.contains("logresp")) { if (!lower.contains("logresp")) {
// Server identification and keepalive comments carry no verdict. // Server identification and keepalive comments carry no verdict.
return null return null
@@ -6,59 +6,103 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** /**
* The verified/unverified distinction is the whole point of these tests: an unverified client * The verified/unverified distinction is the point of these tests: an unverified client stays
* stays connected and its writes keep succeeding while the server discards every packet, so * connected and its writes keep succeeding while the server discards every packet, so getting this
* getting this wrong means the operator watches reports "succeed" for hours with nothing landing. * wrong means the operator watches reports "succeed" for hours with nothing landing.
*
* Several are transcriptions of live aprsc 2.1.21 traffic rather than guesses, because an earlier
* version of this file froze a wrong login format as correct and that defect survived nine commits
* and two audits.
*/ */
class AprsLoginTest { class AprsLoginTest {
@Test @Test
fun `builds the line the specification asks for`() { fun `builds the line the specification asks for`() {
assertEquals( assertEquals(
"user BG7NTA-5 pass 12345 vers Look4Sat-4.6.0", "user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0",
AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat-4.6.0") AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0")
) )
} }
/**
* The format a live server rejects, and the reason this file was rewritten.
*
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
* got: # Invalid login: software name and version are not separated by a space
*
* `vers` is two tokens. The previous code hyphenated the space between them to satisfy the rule
* that the software NAME contains no space, and the test asserted that as correct.
*/
@Test
fun `the software name and version stay separate tokens`() {
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
assertEquals(7, line.split(" ").size)
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4"))
}
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */ /** No SSID means no hyphen; the spec says never to write -0 explicitly. */
@Test @Test
fun `omits the ssid when there is none`() { fun `omits the ssid when there is none`() {
assertEquals( assertEquals(
"user BG7NTA pass 12345 vers Look4Sat", "user BG7NTA pass 12345 vers Look4Sat 4.6.0",
AprsLogin.line("BG7NTA", "", 12345, "Look4Sat") AprsLogin.line("BG7NTA", "", 12345, "Look4Sat", "4.6.0")
) )
} }
/** /** Whitespace inside either token would shift every field after it, so it is collapsed. */
* The server splits `vers` into name and version, so a space inside it shifts every field
* after that point. The shipped value was "Look4Sat 4.5.4", which parsed correctly only by
* luck - and only because nothing followed it.
*/
@Test @Test
fun `a space in the version cannot shift the following fields`() { fun `whitespace within a token is collapsed rather than passed through`() {
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat 4.6.0") val line = AprsLogin.line("BG7NTA", "5", 12345, "Look 4 Sat", "4.6.0 beta")
assertEquals("user BG7NTA-5 pass 12345 vers Look4Sat-4.6.0", line) assertEquals("user BG7NTA-5 pass 12345 vers Look-4-Sat 4.6.0-beta", line)
// Six tokens: user, callsign, pass, passcode, vers, version. A space inside the version assertEquals(7, line.split(" ").size)
// would make seven and push the server's field parsing one place along.
assertEquals(6, line.split(" ").size)
} }
@Test @Test
fun `appends a filter when one is given`() { fun `appends a filter when one is given`() {
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "r/33.25/-96.5/50") val line = AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0", "r/33.25/-96.5/50")
assertEquals("user BG7NTA-5 pass 12345 vers Look4Sat r/33.25/-96.5/50", line) assertEquals("user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0 r/33.25/-96.5/50", line)
} }
@Test @Test
fun `receive-only logins use the documented passcode`() { fun `receive-only logins use the documented passcode`() {
val line = AprsLogin.line("BG7NTA", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat") val line = AprsLogin.line("BG7NTA", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.6.0")
assertEquals("user BG7NTA pass -1 vers Look4Sat", line) assertEquals("user BG7NTA pass -1 vers Look4Sat 4.6.0", line)
}
/** Empty tokens would produce a malformed line, so each has a fallback. */
@Test
fun `empty name or version does not produce a malformed line`() {
val line = AprsLogin.line("BG7NTA", "", 12345, "", "")
assertEquals("user BG7NTA pass 12345 vers Look4Sat 0", line)
assertEquals(7, line.split(" ").size)
}
/**
* The refusal that used to be invisible. It is a comment but not a logresp, so it was skipped
* as chatter; the login then timed out into Unknown, which is treated as "may be working", and
* every send afterwards reported success against a server that had refused the login.
*/
@Test
fun `an invalid login comment is a refusal, not chatter`() {
val outcome = AprsLogin.parse(
"# Invalid login: software name and version are not separated by a space"
)
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected)
assertEquals(
"Invalid login: software name and version are not separated by a space",
(outcome as AprsLogin.Outcome.Rejected).detail
)
}
@Test
fun `a login denied comment is also a refusal`() {
assertTrue(AprsLogin.parse("# Login denied") is AprsLogin.Outcome.Rejected)
} }
/** /**
* "unverified" contains "verified", so the negative has to be tested first. A naive * "unverified" contains "verified", so the negative has to be tested first. A naive
* contains("verified") reports every rejected login as accepted - which is precisely the * contains("verified") reports every rejected login as accepted.
* failure this replaces.
*/ */
@Test @Test
fun `unverified is not mistaken for verified`() { fun `unverified is not mistaken for verified`() {
@@ -73,12 +117,12 @@ class AprsLoginTest {
} }
/** /**
* Server identification and keepalive comments must return null so the caller keeps reading. * Identification and keepalives must return null so the caller keeps reading. The greeting here
* Treating the first comment as an answer would leave every login Unknown. * is verbatim from aprsc 2.1.21.
*/ */
@Test @Test
fun `comments without a verdict are skipped`() { fun `comments without a verdict are skipped`() {
assertNull(AprsLogin.parse("# aprsc 2.1.19-g730c5c0")) assertNull(AprsLogin.parse("# aprsc 2.1.21-gbfc2090"))
assertNull(AprsLogin.parse("# Tue Aug 25 08:00:00 UTC 2026")) assertNull(AprsLogin.parse("# Tue Aug 25 08:00:00 UTC 2026"))
assertNull(AprsLogin.parse("")) assertNull(AprsLogin.parse(""))
assertNull(AprsLogin.parse(" ")) assertNull(AprsLogin.parse(" "))
@@ -95,8 +139,10 @@ class AprsLoginTest {
/** A logresp with no recognisable verdict is Unknown, not silently Verified. */ /** A logresp with no recognisable verdict is Unknown, not silently Verified. */
@Test @Test
fun `an unrecognised logresp is not treated as success`() { fun `an unrecognised logresp is not treated as success`() {
val outcome = AprsLogin.parse("# logresp BG7NTA something-new, server X") assertTrue(
assertTrue(outcome is AprsLogin.Outcome.Unknown) AprsLogin.parse("# logresp BG7NTA something-new, server X")
is AprsLogin.Outcome.Unknown
)
} }
@Test @Test
@@ -109,6 +155,7 @@ class AprsLoginTest {
AprsLogin.Outcome.Verified("BG7NTA"), AprsLogin.Outcome.Verified("BG7NTA"),
AprsLogin.parse("# LogResp BG7NTA Verified, server X") AprsLogin.parse("# LogResp BG7NTA Verified, server X")
) )
assertTrue(AprsLogin.parse("# INVALID LOGIN: nope") is AprsLogin.Outcome.Rejected)
} }
/** The callsign is read back so the UI can show whose login the server acknowledged. */ /** The callsign is read back so the UI can show whose login the server acknowledged. */
@@ -0,0 +1,140 @@
package com.rtbishop.look4sat.core.domain.aprs
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* Guards the one defect no other test in this project could catch.
*
* The APRS service passes a foreground service type to startForeground, and AOSP requires it to be
* a subset of the type declared in the manifest, throwing IllegalArgumentException otherwise -
* which the service's own catch turns into a silent stopSelf(). A commit changed the manifest from
* dataSync to location and left the code passing dataSync, so APRS started, died and reported
* nothing on every device running Android 10 or later, while the settings switch stayed on. Two
* auditors found it by reading constants against AOSP; the unit suite could not see it at all,
* because the service is untestable on the JVM and the app module has no test source set.
*
* So this reads both files as text from core:domain, which does have test infrastructure. Crude,
* and it says nothing about whether the service works - but it fails the moment those two files
* disagree, which is exactly the failure that shipped.
*/
class ForegroundServiceTypeTest {
/** Manifest attribute value to the ServiceInfo constant the code must pass. */
private val expectedConstant = mapOf(
"dataSync" to "FOREGROUND_SERVICE_TYPE_DATA_SYNC",
"location" to "FOREGROUND_SERVICE_TYPE_LOCATION",
"connectedDevice" to "FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE",
"mediaPlayback" to "FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK",
"shortService" to "FOREGROUND_SERVICE_TYPE_SHORT_SERVICE"
)
/** Manifest attribute value to the permission that must accompany it. */
private val requiredPermission = mapOf(
"dataSync" to "FOREGROUND_SERVICE_DATA_SYNC",
"location" to "FOREGROUND_SERVICE_LOCATION",
"connectedDevice" to "FOREGROUND_SERVICE_CONNECTED_DEVICE",
"mediaPlayback" to "FOREGROUND_SERVICE_MEDIA_PLAYBACK"
)
/**
* Walk up from the working directory to the repository root.
*
* A unit test's working directory is the module directory, but that is not guaranteed across
* Gradle versions, so the root is found by looking for settings.gradle.kts rather than assumed.
*/
private val repoRoot: File? by lazy {
var dir: File? = File("").absoluteFile
while (dir != null && !File(dir, "settings.gradle.kts").isFile) dir = dir.parentFile
dir
}
private fun read(relative: String): String? =
repoRoot?.let { File(it, relative) }?.takeIf { it.isFile }?.readText()
private val manifest: String? by lazy { read("app/src/main/AndroidManifest.xml") }
private val service: String? by lazy {
read("app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt")
}
/** The type declared for the APRS service in the manifest. */
private fun declaredType(text: String): String {
val match = Regex("""android:foregroundServiceType="([^"]+)"""").find(text)
assertTrue("no foregroundServiceType found in the manifest", match != null)
return match!!.groupValues[1]
}
@Test
fun `the service passes the type its manifest declares`() {
val manifestText = manifest
val serviceText = service
// Skipped rather than failed if the layout moves: a broken locator must not read as a
// broken app, and the assertion below is worthless without both files anyway.
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
val declared = declaredType(manifestText!!)
val constant = expectedConstant[declared]
assertTrue(
"unrecognised foregroundServiceType '$declared' - add it to this test's map",
constant != null
)
assertTrue(
"manifest declares $declared, so startForeground must pass ServiceInfo.$constant",
serviceText!!.contains("ServiceInfo.$constant")
)
}
/** Passing any type the manifest does not declare is what AOSP rejects. */
@Test
fun `the service passes no other foreground service type`() {
val manifestText = manifest
val serviceText = service
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
val passed = Regex("""ServiceInfo\.(FOREGROUND_SERVICE_TYPE_\w+)""")
.findAll(serviceText!!)
.map { it.groupValues[1] }
.toSet()
assertEquals(
"exactly one type may be passed, and it must match the manifest",
setOf(expectedConstant.getValue(declaredType(manifestText!!))),
passed
)
}
/** The matching permission must be declared, or startForeground throws on API 34 and up. */
@Test
fun `the manifest declares the permission its service type requires`() {
val manifestText = manifest
assumeTrue("manifest not found", manifestText != null)
val declared = declaredType(manifestText!!)
val permission = requiredPermission[declared]
assertTrue("no permission mapped for '$declared'", permission != null)
assertTrue(
"foregroundServiceType $declared requires android.permission.$permission",
manifestText.contains("android.permission.$permission")
)
}
/**
* A location-typed service additionally demands an already-granted location permission before
* startForeground. The settings card requests only notifications, so declaring location would
* fail silently for any operator who declined location access - the same class of defect this
* whole test exists to prevent.
*/
@Test
fun `a location typed service would need a runtime permission this app does not request`() {
val manifestText = manifest
assumeTrue("manifest not found", manifestText != null)
assumeTrue("only applies to a location-typed service", declaredType(manifestText!!) == "location")
val card = read(
"feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt"
)
assumeTrue("settings card source not found", card != null)
assertTrue(
"declaring location requires requesting ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION",
card!!.contains("ACCESS_COARSE_LOCATION") || card.contains("ACCESS_FINE_LOCATION")
)
}
}
@@ -22,7 +22,7 @@
<string name="prefs_aprs_callsign">Tanda panggilan</string> <string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string> <string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string> <string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string> <string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
<string name="prefs_aprs_interval">Interval (menit)</string> <string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string> <string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string> <string name="prefs_aprs_symbol_table">Tabel simbol</string>
@@ -22,7 +22,7 @@
<string name="prefs_aprs_callsign">Tanda panggilan</string> <string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string> <string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string> <string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string> <string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
<string name="prefs_aprs_interval">Interval (menit)</string> <string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string> <string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string> <string name="prefs_aprs_symbol_table">Tabel simbol</string>
@@ -23,7 +23,7 @@
<string name="prefs_aprs_callsign">Çağrı işareti</string> <string name="prefs_aprs_callsign">Çağrı işareti</string>
<string name="prefs_aprs_ssid">SSID</string> <string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Parola</string> <string name="prefs_aprs_passcode">Parola</string>
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string> <string name="prefs_aprs_passcode_hint">Passcode bos = yalnizca alma modu, raporlar iletilmez</string>
<string name="prefs_aprs_interval">Aralık (dakika)</string> <string name="prefs_aprs_interval">Aralık (dakika)</string>
<string name="prefs_aprs_status">Durum metni</string> <string name="prefs_aprs_status">Durum metni</string>
<string name="prefs_aprs_symbol_table">Sembol tablosu</string> <string name="prefs_aprs_symbol_table">Sembol tablosu</string>
@@ -24,7 +24,7 @@
<string name="prefs_aprs_callsign">呼号</string> <string name="prefs_aprs_callsign">呼号</string>
<string name="prefs_aprs_ssid">SSID</string> <string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">验证码</string> <string name="prefs_aprs_passcode">验证码</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string> <string name="prefs_aprs_passcode_hint">验证码留空 = 只收模式, 上报不会被转发</string>
<string name="prefs_aprs_interval">周期(分钟)</string> <string name="prefs_aprs_interval">周期(分钟)</string>
<string name="prefs_aprs_status">状态文本</string> <string name="prefs_aprs_status">状态文本</string>
<string name="prefs_aprs_symbol_table">符号表</string> <string name="prefs_aprs_symbol_table">符号表</string>
@@ -25,7 +25,7 @@
<string name="prefs_aprs_callsign">Callsign</string> <string name="prefs_aprs_callsign">Callsign</string>
<string name="prefs_aprs_ssid">SSID</string> <string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Passcode</string> <string name="prefs_aprs_passcode">Passcode</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string> <string name="prefs_aprs_passcode_hint">Passcode empty = receive-only, reports are not forwarded</string>
<string name="prefs_aprs_interval">Interval (min)</string> <string name="prefs_aprs_interval">Interval (min)</string>
<string name="prefs_aprs_status">Status text</string> <string name="prefs_aprs_status">Status text</string>
<string name="prefs_aprs_symbol_table">Symbol table</string> <string name="prefs_aprs_symbol_table">Symbol table</string>