Commit Graph
100 Commits
Author SHA1 Message Date
mckero 0208a577c4 fix(aprs): do not tell a mistyped passcode it is in receive-only mode
The previous commit derived "wants receive-only" from AprsPasscode.canTransmit, which is
a boolean over four cases. Both a deliberate -1 and a mistyped passcode return false, so
fixing the mis-diagnosis in one direction introduced it in the other: measured against
the shipped algorithm, three entries - a passcode off by one digit, an arbitrary number,
and a non-numeric entry - were all told they were connected in receive-only mode, when
what they needed to hear was that the passcode does not match the callsign.

classify already distinguishes these; only its ReceiveOnly case counts as a deliberate
choice. A test now pins the distinction, including the fact that all four entries are
equally unable to transmit - which is exactly why the boolean was not enough.

Found by probe before review, not by the suite, which had no test for the reporter's use
of this and still does not.
2026-08-25 15:25:32 +00:00
mckero eb66a77cae refactor(qrz): move the grid lookup out of the composable
LogTab read the QRZ cookie straight out of SharedPreferences through LocalContext,
inside composition, on every submission - disk access in a composable, around the
repository layer, with the client referenced by fully-qualified name inline. And it
did `if (grid != null)`, so a lookup that failed for any reason left the QSO without
a grid and told the operator nothing.

IQrzGridLookup lives in core:domain, QrzGridLookup in core:data owns the cookie read,
and the view model exposes lookupGrid. The composable now takes a callback and handles
each outcome: a locator is attached, no locator on file passes quietly because nothing
is wrong, an expired cookie says to paste a fresh one, and an unreachable QRZ says so.
That is what the four-outcome QrzGrid type from ce68f487 was for - until now nothing
consumed it and the old nullable client was still the one being called.

Note for anyone extending RadarScreen: the local holding the view model cannot be
referenced as `viewModel` inside a lambda, because that name also resolves to the
composable factory function. Hence the explicitly typed local.

The old QrzGridClient is now unused here but left in place; removing it belongs with
the settings screen, which still calls it to validate a pasted cookie.
2026-08-25 15:18:40 +00:00
mckero 0a67f74369 fix(aprs): the service could not start at all on Android 10 and later
The previous commit changed the manifest's foregroundServiceType to location and left
startForeground passing FOREGROUND_SERVICE_TYPE_DATA_SYNC. AOSP requires the passed
type to be a subset of the declared one - location is 0x08, dataSync is 0x01 - and
throws IllegalArgumentException otherwise, a check that has been there since API 29.
That throw landed in the surrounding catch, which calls stopSelf().

So APRS started, died, and said nothing. No notification, no beacon, no Toast, no
last-report row, and the settings switch stayed on because the config had already been
saved. This is worse than the defect the rewrite was written to fix: reporting success
for packets that never left at least sometimes worked, whereas this never ran at all,
on essentially every device in use, with no visible symptom. Two auditors found it
independently by reading the constants against AOSP's own check.

Two more findings from the same review.

Receive-only was reported as a wrong passcode. Both a deliberate -1 and a mismatched
entry log in with -1, and the server answers "unverified" to each, so the operator who
chose receive-only - the one way to test a setup without putting anything on the network
- was told to go and fix the passcode they had set on purpose. The report now carries
whether receive-only was asked for, and says so instead.

The card could show "failed - sent". The detail string was the write's own verdict, and
a write that succeeds on a refused login is exactly the case where those two disagree.
A failure now reports what actually failed.

Also: the packet is built before connecting. The reporter used to open a session and log
in only to discover it had nothing to send, which for an operator with no station
position set meant a pointless login every five minutes.

Still outstanding, and the reason this is not enough on its own: nothing tests the
service, so neither this defect nor the missing line terminator in 7ac54f0a could have
been caught by the suite. Both were found by audit. A location-typed foreground service
on API 34+ may also require a granted location permission before startForeground, which
the settings card does not request - that needs checking on hardware.
2026-08-25 15:18:15 +00:00
mckero e0900778f0 fix(log): say why a callsign was not logged instead of dropping it
`submit()` opened with `if (call.length < 3) return`. During a pass the operator typed
a callsign, pressed done, and nothing happened - no entry, no message, no way to tell
the app had decided against them. None of the logging software surveyed for this work
- N1MM+, DXLog, PoLo, HAMRS - discards a submission silently.

Validation is deliberately loose, because strictness costs more than it saves. Checked
against 28 real callsigns, a typical strict pattern rejects 16 of them: W1AW/4, 2E0ABC,
9A1CCY and SV2ASP/A among others. A pattern permissive enough to accept those also
accepts a Maidenhead locator as a callsign. There is no regex that catches typos without
throwing away legitimate calls, so CallsignEntry rejects only what cannot be a callsign
- empty, one character, illegal characters, all digits, all letters - and reports doubt
as a warning that still logs the contact.

Two warnings exist. A six-character grid-shaped entry says so, because grid and callsign
are exchanged together on FM satellites and the fields sit side by side. A station already
worked this pass says so too, without blocking: the same station on a later pass is a
legitimate new contact, and contest loggers default to working duplicates - DXLog
describes refusing them as an outdated habit.

That warning also replaces the duplicate suppression, which was a 300ms window comparing
the last callsign, admitted in its own comment to be a workaround. It could silently
discard a real second contact, and a set of calls worked this pass is both honest and
more useful. It survives configuration changes via rememberSaveable.

Not addressed here: the QRZ grid backfill still reads the cookie out of SharedPreferences
from inside a composable through LocalContext, and still reports nothing when a lookup
fails. IQrzGridLookup is added for that, but wiring it needs the container, the view model
and the UI to change together.
2026-08-25 14:32:42 +00:00
mckero 2ff8643988 fix(aprs): build a legal packet, and keep beaconing when the screen locks
The position line was one string template, and it broke four rules at once.

No path. The specification says a client-originated packet carries TCPIP* in the
path, "nothing more or less", and there was none - `CALL>APRS:=...` went out bare.

No position meant 0,0. When the station QTH was unset and no GPS fix was available,
`lat ?: 0.0` put the operator at 0 degrees north, 0 degrees east - a point in the
Gulf of Guinea - on the global network, under their own callsign. There is no honest
default for "nowhere", so AprsBeacon refuses instead and the reporter says why. A
genuine 0,0 fix is still legal and still sent; the refusal is about absence.

No comment sanitising. A line break typed into the status field ended the packet and
started a second one from the remaining text, which an operator could trigger by
pressing return. Measured: the old builder emitted two lines from one call, the second
impersonating whatever callsign the text contained. Only printable ASCII survives now.

No length cap. A 600-character status produced a 636-byte line against a 512-byte
limit including CRLF. The comment is trimmed to whatever room is left after the
header and the coordinates, bounded also by the format's own 43-character limit.

Symbol handling was whatever character the operator typed first, including one that
breaks the fixed-width parse. It now accepts only what the specification allows -
the two table selectors and overlay characters - and falls back to the primary table.
aprs.fi names symbol misconfiguration as the most common reason a station never
appears on the map, so this is not cosmetic.

Separately, beaconing stopped whenever the screen locked. The interval was a coroutine
delay inside the reporter, and Doze suspends network access and ignores wake locks even
for a foreground service: the timer fired on schedule and then could not reach the
network, while the notification went on claiming the service was running. The service
now books each beacon with setExactAndAllowWhileIdle, which is the only scheduling that
survives Doze, and reschedules after each tick so a changed interval applies at once.
If the operator has revoked exact alarms it falls back to an inexact one, which beacons
late rather than not at all.

The foreground service type changes from dataSync to location. dataSync is capped at
six hours in any 24-hour window on recent Android and then stopped by the system, which
would silently end a beacon meant to run all day; the service reads the station position
and falls back to GPS, so location describes what it actually does.

The interval floor becomes five minutes rather than one. This station is fixed or
walking, and APRS-IS etiquette is to beacon no more often than the position changes.

The packet builder moves to core:domain as pure logic, so all of this is testable
without a socket - including that a comma-decimal locale cannot corrupt the coordinates,
which nothing covered before.
2026-08-25 14:17:23 +00:00
mckero b19c78441c feat(aprs): link out to request a passcode instead of computing one
The settings card had a "Compute passcode" button that derived the value from the
callsign and filled the field in. It was added by request, so it stayed while the
previous commit removed the same derivation from the connection path - which left
the app contradicting itself: the background no longer invented a passcode, but the
UI still offered to.

APRS-IS treats the passcode as a licence check and states that supplying it to a
user is the software author's responsibility. APRSdroid carries the identical
algorithm in the same source file and deliberately does not use it for this, opting
to validate what the operator typed and link out to request one. Filling the field
in claims a check that nobody performed.

The button now opens the passcode request page. AprsPacket.passcode stays in
core:domain because validating an entry means recomputing the expected value, and
its import is dropped from the card, which no longer needs it.
2026-08-25 13:18:51 +00:00
mckero 262ae45432 fix(aprs): stop inventing a transmit passcode, and let the report notices appear
AprsReporter derived a passcode from the callsign whenever the operator's entry was
unusable:

    passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign)

Measured against the shipped algorithm for BG7NTA, whose passcode is 21162, four
inputs produced a transmit passcode the operator never obtained: blank, whitespace,
non-numeric, and an explicit -1. That last one is the documented receive-only value,
so `takeIf { it >= 0 }` also made receive-only unreachable - and a receive-only login
is the one way to confirm a setup works without putting anything on the network,
which is exactly how this feature was supposed to be validated before release.

This is a policy question more than a bug. APRS-IS states that supplying the correct
passcode to a user is the software author's responsibility, and the passcode functions
as a licence check for transmitting. APRSdroid carries the same algorithm in the same
source file and deliberately does not use it to fill a blank, validating the operator's
entry instead. AprsPasscode follows that: it classifies an entry as Transmit,
ReceiveOnly, Mismatch or NotANumber, and anything not usable logs in as -1. The
connection still works and the operator is told separately that reports are not being
forwarded, but no packet goes out under a code the app made up.

AprsPacket.passcode stays, because validating an entry means recomputing the expected
value. Nothing substitutes it for a missing one.

Separately, and worse than the line above: the report notices never appeared at all.
onReport is invoked from AprsReporter's Dispatchers.IO scope, where constructing a
Toast throws because the thread has no Looper - and the surrounding runCatching
swallowed it. So the whole reporting path, including the unverified-login warning
added in the previous commit, was writing messages nobody could see. They now post to
the main looper. The one in startReporting is left alone: onStartCommand already runs
on the main thread.

Still outstanding for APRS, and not addressed here: the 0N 0E position fallback, the
missing TCPIP* path, the unbounded status field, the coroutine delay that does not fire
in Doze, and the dataSync foreground service type. Also unaddressed is the "Compute
passcode" button in AprsCard, which offers the operator the derived value directly and
so contradicts the policy this commit establishes - it was added by request, so it needs
a decision rather than a quiet removal.
2026-08-25 12:37:04 +00:00
mckero 7ac54f0a37 fix(aprs): report a failed send as failed, and a refused login as refused
Two defects made every APRS failure invisible. sendPacket ended with
`.getOrElse { Pair(true, "OK") }`, so a read that threw - including on a dead
socket - was reported as a successful send. And the login check threw inside a
runCatching whose result was discarded, so a server that refused to verify the
passcode could not propagate: aprsc keeps such a client connected and its writes
succeed while silently discarding every packet, which the app reported as success.
An audit put it plainly - twelve commits are all fix(aprs), none added a
socket-level test, so "it worked" was never evidence a packet had landed.

sendPacket now separates the cases. A read timeout stays a success, because
APRS-IS does not acknowledge position reports and silence is the normal outcome.
A closed stream or an IOException is a failure. The catch order matters and is
load-bearing: SocketTimeoutException extends IOException, so reversing them would
mark every normal report as failed.

The login handshake follows the spec: read the server's identification line first,
then log in, then read until a verdict arrives. AprsLogin holds that as pure logic
in core:domain with the parsing that decides it, including one trap worth naming -
"unverified" contains "verified", so the negative has to be tested first or every
refusal reads as acceptance. An explicit refusal now fails the report and shows
the operator its own message pointing at the callsign and passcode, in five
locales. A response we could not parse does not, since the packets may well be
landing and blaming the passcode would send them to fix something that works.

Three defects came out of review after that. The verdict is now bounded by a
deadline rather than a five-line budget, because a server that sent six keepalives
before its answer turned an accepted login into Unknown - telling the operator
their passcode was wrong when it had just been accepted. The greeting gets a short
two-second probe instead of the full login window, which cost eight seconds on
every connect to a server that sends none. And a refusal detected in the greeting
now aborts the connection instead of being overwritten by the next read, which had
made that branch and its comment a lie.

The worst of the three was mine: rewriting the write as print + flush dropped the
line terminator entirely. APRS-IS is a line protocol, so the server's reader never
saw a packet, while the send reported success and the read timed out into the
"silence is normal" branch. It broke healthy connections rather than dead ones and
was designed to have no symptom. Both the packet and the login line now end in an
explicit CRLF as the spec requires, rather than println's platform separator.

That defect is why this adds AprsIsClientSocketTest, which runs the client against
a stand-in server and reads the bytes back: it asserts two packets arrive as two
lines, that a login line arrives complete, that keepalive chatter does not bury the
verdict, that a greeting-less server connects promptly, and that a send to a closed
peer reports failure. Nothing in the pure-logic tests could have caught a missing
newline. Note for anyone extending it: closing the ServerSocket leaves an
established connection alive, so the dead-peer test has to close the accepted
socket - assuming otherwise made a correct implementation look broken.

AprsPacket.formatLogin is deleted, its work moved into AprsLogin.line, which also
replaces spaces in the version string because the server splits that field on
whitespace and the shipped value contained one.
2026-08-25 10:30:41 +00:00
mckero ce68f48765 feat(qrz): tell an expired cookie apart from a station with no grid
The grid lookup returned String? and swallowed everything with catch { null }, so a
timeout, an expired cookie, a QRZ layout change and a station that simply has not
published a locator were one indistinguishable blank. The operator saw an empty grid
with no way to know that re-pasting their cookie would fix it. There was also no
retry at all, on a phone, mid-pass, on mobile data.

QrzGrid names the four outcomes and QrzGridParser holds the parsing, which is pure
string work and now testable without a network. The fetch moves to core:data as
QrzGridSource, using the project's own OkHttp client with three attempts and 700ms
then 2000ms of backoff. Only transport failures and 5xx are retried; a 4xx would
repeat identically. This also gets java.net.URL I/O out of core:domain, which that
module is meant to stay clear of for the KMP move.

Classifying signed-out took two goes. Keying on the detail table being absent held
for an expired cookie - QRZ genuinely serves no detail rows to an anonymous visitor,
verified against a live response - but an audit found that a callsign QRZ has never
heard of returns HTTP 200 with no detail rows either, because QRZ serves its search
form instead. That would have reported a mistyped callsign as an expired cookie and
sent the operator into settings mid-pass to re-paste one that was never broken. It
now keys on QRZ's own "Login is required for additional detail" notice, so an absent
locator degrades to the harmless outcome and only QRZ actually asking for a login
triggers the cookie prompt. All three cases are measured against live responses.

Not yet wired in: LogTab and SettingsScreen still call the old QrzGridClient, so
nothing changes for the operator yet. Cutting over needs an interface in core:domain
and a MainContainer provider, because feature modules cannot reach core:data
directly - and the cookie itself belongs in SettingsRepo rather than the separate
prefs file a composable currently reads through LocalContext.
2026-08-25 08:39:40 +00:00
mckero 38f939bd49 fix(wavelog): resolve the LoTW satellite name from the catalogue number
LoTW refuses a QSO whose SAT_NAME is not spelled as its accepted list has it - its
own help page gives AO7 against AO-7 as a rejection - so the name we upload decides
whether a contact can ever be confirmed. The old code derived it with
substringBefore('('), which returns the descriptive half of a TLE name rather than
the OSCAR designator: measured against live Celestrak amateur data, 0 of 96
satellites resolved to something LoTW accepts. ASRTU-1 went up as ASRTU-1 where
LoTW wants AO-123.

Keying on the name cannot be made to work, because the sources disagree. Of the 49
satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
differently - 43017 is RADFXSAT (FOX-1B) in one and AO-91 in the other, 43700 is
ES'HAIL 2 against QO-100 - so which name a QSO got depended on where the operator
fetched their TLE. The catalogue number is identical everywhere, so the table is
keyed on it and OrbitalPass.catNum is now threaded through to the QSO and persisted.

The name path stays as a fallback for contacts logged before the number was
recorded, and got two fixes of its own: it tries either side of the parentheses
rather than assuming the designator is on the left, and tolerates a differing
separator so RADIO ROSTO (RS15) reaches RS-15. Resolution now returns the list's own
spelling, so Arsene is not uploaded as ARSENE and rejected the same way AO7 would be.

Measured on the same data: 3 names resolved before, 20 by name alone now, 30 with
the catalogue number, and no satellite that used to resolve stopped resolving.

The table gained the nine TEVEL-2 satellites after an audit found them missing.
Every source writes those TEVEL2-N while LoTW has TEV2-N, which stripping separators
does not bridge - TEVEL21 is not TEV21 - so they resolved to nothing at all. They
launched in 2025 and are workable now. Their numbering is not sequential: 63217 is
TEVEL2-1 while 63213 is TEVEL2-4.

All 38 entries were cross-checked two independent ways: every catalogue number
appears in the app's own configured sources under a name consistent with the LoTW
spelling, and ARRL's startDate for each name agrees with the launch year in the
TLE international designator - which is what would catch a number pointing at the
wrong object, since a name can match by luck. Nothing here was typed from memory;
an early hand-written draft had AO-123 as 62690 when it is 61781.
2026-08-25 08:38:45 +00:00
mckero bdc6db5aff build: bump to 4.6.0 (versionCode 467)
Carries the transcript-stall fix, which was committed but never pushed - v4.5.9 was
tagged at the version-bump commit before it, so the APK users have does not contain
it and their history box still appears to delete text.

Release notes gain one line in the five locales that carry them, describing that fix.
2026-08-25 06:18:59 +00:00
mckero 92499b1cf1 feat(wavelog): map NORAD catalogue numbers to LoTW satellite names
LoTW refuses a QSO whose SAT_NAME is not spelled as in its accepted list - its own
help page gives AO7 against AO-7 as a rejection - so the name we upload has to match
exactly. The existing code derives that name with substringBefore('('), which returns
the descriptive part of a TLE name rather than the OSCAR designator: measured against
live Celestrak amateur data, 0 of 96 satellites resolved to a name LoTW accepts.
ASRTU-1 uploads as ASRTU-1 where LoTW wants AO-123.

Keying on the name cannot be made to work, because sources disagree. Of the 49
satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
differently - 43017 is RADFXSAT (FOX-1B) in one and AO-91 in the other, 43700 is
ES'HAIL 2 against QO-100 - so which name a user gets depends on the source they
happen to fetch from. The NORAD catalogue number is identical everywhere, so this
table is keyed on it.

Coverage is 29 entries, not the 112 names LoTW lists, because the rest are satellites
no source still carries: they have re-entered, no user can track them, and a mapping
for them would never be consulted. Every number was read out of live TLE data from the
app's own configured sources rather than typed from memory - a first attempt at writing
them by hand had AO-123 as 62690 when it is 61781.

Three names matched more than one catalogued object and were settled by which object
the amateur-specific sources carry. ARISS is 25544, the station; the full catalogue
also lists ISS (UNITY), (ZVEZDA), (DESTINY) and (NAUKA), which are modules. IO-117 is
53109, named GREENCUBE (IO-117) by four sources against R4UAB alone calling it
ROBUSTA 1F. TO-108 is 44881, in all three amateur sources, where 44879 is TIANQIN 1.

Not yet wired into the upload path: WavelogQso carries only a satellite name, so the
catalogue number has to be threaded through from the radar screen first. This commit
adds the table and its tests only, leaving behaviour unchanged.
2026-08-25 06:16:08 +00:00
mckero 828fd0fb6f fix(cw): stop the transcript stalling while audio waits to be archived
The history box appeared to delete text. Audio leaving the 20 s live window is
decoded into the archive only once a full 15 s batch has accumulated, so until
then its characters were in neither place: not in the live decode, which had
scrolled past them, and not in the history, which had not seen them yet.

Measured on a 20 WPM timeline, the concatenated transcript held at 40 characters
from t=24 s to t=34.5 s - eleven seconds of no growth - then jumped to 70 when the
batch flushed. Up to 30 characters sat in that gap. Reading it as deletion is
reasonable; the text really was missing from the box.

The pending batch is now decoded too, on the same 1.5 s cycle as the live window,
and shown as a provisional tail after the committed text. The final archive decode
replaces it, having the whole batch for context. The transcript is monotonic
afterwards: +3 characters every cycle with no stalls.

Decoding each 100 ms capture chunk instead would have removed the gap entirely but
measured 14x the inference load - over 250% of one core across ten minutes - and a
chunk that short carries under two dot-lengths of context, so the decode would be
poor as well as expensive. One extra inference per redecode cycle costs 24.7%
against 18.3%.

Discarding buffered audio drops the provisional text with it, since that text
describes audio that no longer exists. Committed text stays: it was correct for
audio that really was archived.
2026-08-23 11:40:22 +00:00
mckero 07df22d287 build: bump to 4.5.9 (versionCode 466)
The v4.5.8 tag was already published against the version-bump commit alone, so
the twelve commits of actual work had no release to land in - moving the tag made
the CI job fail on an existing release rather than replacing it. A new version
number is the right way round, per the project's own rule against re-cutting a
tag.

Release notes are unchanged: the five locales already describe exactly what these
commits contain.
2026-08-23 11:06:21 +00:00
mckero ac45ed0efb docs: describe the waterfall, transcript and screen-reader work in 4.5.8
Three lines the release notes were missing, across the five locales that carry
them: the waterfall now spanning the whole audio band, the transcript following
new text, and the CW waterfall and AMSAT day cells being readable by a screen
reader.
2026-08-23 10:55:58 +00:00
mckero 96bbb022e8 fix(cw): follow the transcript reliably, and keep the AMSAT grid dense
Two corrections to 10c415fa and 0889a3bd, keeping what those got right and
undoing what they cost.

The transcript now follows new text through an explicit follow flag rather than
comparing scroll position against maxValue. maxValue is written during layout,
after the composition that would read it, so the comparison tested the previous
frame's height: following fell progressively short of the true bottom and, once
the gap passed the slack, latched the operator out of follow-mode until they hit
the exact end. Scrolling away still stops it, which is the point.

The AMSAT day cell goes back to 28 dp. Raising it to 48 dp for the minimum touch
target measured a 71% increase in row pitch - 14 satellites per screen down to 8
on a 6.1" phone - and comparing many satellites at a glance is what that page is
for. Compose cannot extend a touch target past the layout bounds, so this is a
choice rather than a fix; 28 dp is also what shipped before, so the regression
was mine. The contentDescription added alongside it stays, since it costs nothing.
2026-08-23 09:46:36 +00:00
mckero b6753a4fa6 Revert "fix(cw): scale and band-pass the shifted audio instead of clipping it"
This reverts commit 0889a3bd88.
2026-08-23 09:42:45 +00:00
mckero 0889a3bd88 fix(cw): scale and band-pass the shifted audio instead of clipping it
The mixer runs above unity for any ordinary input - the Hilbert kernel's L1 gain
is 2.51, so amplitude 0.7 peaks at about 1.76 - and the output was hard clipped
to fit. Clipping squares the waveform off and generates odd harmonics, which the
widened waterfall would now put on screen.

Measured, the harmonics happen to be harmless today: TARGET_HZ is a quarter of
the sample rate, so 3f, 5f, 7f and 9f all fold back onto the tone itself and
out-of-band energy stayed at 0.00%. That is a coincidence between two constants,
not a property of the design. At a 700 Hz target the third harmonic folds to
1100 Hz - inside the analysis window, where no filter may remove it and the model
would read it as a second tone.

So two changes, because neither alone is enough. A peak-following gain scales the
mixer output to fit rather than clipping it: measured 0 of 3200 samples on the
rail, against a clipped waveform parking there for much of every cycle. And a
95-tap windowed-sinc band-pass over the model's window removes whatever the mix
leaves outside it - images, harmonics, the far sideband - measured at 58-60 dB
rejection with 0.09 dB of passband ripple and out-of-band energy down to 0.0002%.
The gain is shared across chunks so it cannot step at a boundary, and the filter
carries tap history for the same reason the Hilbert filter already did.

The band-pass adds 47 samples of linear-phase group delay, 14.7 ms, which delays
the keying envelope without distorting it - 4% of a dot at 40 WPM.

CwToneShifterStreamingTest's boundary criterion was wrong, and the band-pass
exposed it: distanceToBoundary measured only forward, so the first samples of a
chunk came out 320 away from "the" boundary and counted as interior when they are
the far side of the same seam. Both filters need samples ahead of the output they
are producing - 32 for the Hilbert transform, 47 for the band-pass - and with the
distance measured to the nearest boundary either way, interior divergence is
0.000116 against a 0.01 budget.

Also: the CW transcript now follows the newest text, but only while the operator
is already at the bottom, so scrolling back to read earlier traffic is not undone
by the next decoded character.
2026-08-23 06:27:10 +00:00
mckero 10c415fabd feat(cw): draw the whole audio band so an out-of-window tone is visible
The waterfall showed only the model's 400-1200 Hz window, so a tone outside it
was absent from the picture entirely. Measured on keyed audio, the brightest
column in that narrow view swings 1.01x between key-down and key-up against
13.76x for a tone in range - it carries no keying at all, so the operator could
not tell a signal was present, let alone where it was. Markers alone could not
fix that: they pointed at a frequency with nothing drawn there.

compute() now takes an optional bin range, defaulting to the model's own, so the
decoder path is byte-identical and the golden-vector test still holds. The
display asks for DC to Nyquist, 129 bins against 65. The FFT already computed
every bin - this only changes which are kept - so the cost is a wider copy.

The decoder window is framed and faintly lifted, since half the picture is now
outside what the model reads and nothing said which half.

Marker fixes found while reviewing the render: the tone marker was orange, which
is a colour the inferno ramp itself passes through, so a marker sitting on the
trace it pointed at was indistinguishable from the keying gaps in that trace -
invisible in exactly the case it existed for. It is cyan now, and both markers
are pips in a gutter above the spectrum rather than lines across it.

Also from the release audit:

- compute()'s bin-count guard was written as a three-term disjunction, which any
  custom range satisfies regardless of bin count, leaving the model invariant
  unenforced for the caller most able to break it. Rewritten as an implication,
  with a Nyquist bound so no range can index past the FFT output.
- signalStrength was gated on a confirmed out-of-window tone, which is false when
  detection fails - and it fails for a slow fist, measured at prominence 2.5
  against a 4.5 threshold for 15% duty. So the meter still read half scale beside
  an empty transcript. It now requires a tone confirmed decodable: 11 flow
  combinations, 3 wrong before, 0 wrong after.
- detectedToneHz never expired, so after retuning into the band the hint kept
  naming the frequency the operator had left, indefinitely. It now clears after
  10 s without a tone, which is clear of any real gap - the longest being 1.7 s
  between words at 5 WPM.
- The waterfall label read estimatedPitch while the hint read detectedToneHz, two
  numbers up to 800 Hz apart both claiming to be the tone. Both read the latter.
- Removed a redundant toFloat() that the compiler warned about.

Accessibility, untouched until now: the waterfall was a bare Canvas and the AMSAT
day cells bare Boxes, so both announced nothing at all - on the status page that
is the entire content of the screen. Both now carry a contentDescription naming
the tone or the day's worst status and report count. The AMSAT tap target goes
from 28 dp to 48 dp with the coloured tile still 28 dp, so the grid keeps its
density. Strings in all nine locales for both modules.
2026-08-23 05:50:59 +00:00
mckero 984a139a81 feat(amsat): let the operator choose the day-cell style
Opinion split on the stripes, so Settings > Other now has a switch. On by
default, since the flat tile it replaced hid intra-day outages, which is the
problem the stripes were introduced to solve.

Flat mode is deliberately not the old behaviour. The old cell took its colour
from the first slot with a report and its count from that same slot, so a day
that worked in the morning and failed all afternoon read as "worked" - measured
across eight representative day shapes, two of them had their failure hidden
outright, and the count reported 1 where the day held 24 reports. Flat mode now
takes the day's worst status and the day's total count, so the summary can
understate detail but not hide bad news. The help text says so, in case someone
turns the switch off expecting the tile they remember.

The count is drawn in black or white by relative luminance rather than always
white: on the telemetry amber, white measured 1.83:1 against WCAG's 3:1 for
large text, and that cell does carry a count whenever a day held nothing but
telemetry reports. All six status colours now clear 3:1, the worst being 3.03.

SatStatusViewModel collects the setting rather than reading it once - the switch
is on another screen, so the operator is always elsewhere when they change it
and would otherwise return to the old style.

Strings in all nine locales.
2026-08-23 02:56:05 +00:00
mckero 4cb03111bc fix(cw): stop the decoder claiming a healthy signal it cannot hear
With tone shift off and the operator tuned outside 400-1200 Hz, the page did not
go quiet - it went confidently wrong. Three measurements, all reproduced against
the real spectrogram path:

estimatedPitch is (32 + loudestBin) * 12.5 - shiftHz with the bin confined to
0..64, so with no shift applied it can only ever report 400-1200 Hz. It cannot
express 1500 Hz, and it does not try: it publishes whichever window edge the
leakage piles against. For a 1500 Hz tone that is 1200 Hz.

That leakage is not faint. The waterfall normalises to the loudest value on
screen, so 50 of 65 bins clear the 0.06 draw threshold and the picture shows a
keyed-looking column pinned to the right edge - the 1200 Hz column runs 25 times
the 400 Hz one.

signalStrength is prominence over the window mean, so the same leakage scores
0.78 and paints the meter to 78% of full width.

So the operator got a strong-signal bar, a plausible 1200 Hz readout, a picture
that looked like a signal, and an empty transcript, with nothing saying why.

The scan that can see past the window now runs whether or not shifting is
enabled - it is the only measurement that can - and publishes through a new
detectedToneHz flow kept separate from estimatedPitch. Overloading the latter is
what let the 1200 Hz claim out in the first place, so the two meanings stay in
two flows. The shift decision still only happens when the setting is on. Cost is
one 121-bin scan every 2 s.

The meter now reads zero when a tone is out of range and not being shifted in: it
is a claim that something decodable is present, and in that state nothing is.

A line under the waterfall says which case the operator is in - the tone was
moved in, or it is out of range and tone shift is off, naming the frequency and
the remedy. Strings in all nine locales; feature:cw only had five, so values-es,
values-ru, values-si and values-uk are new, with the Turkish apostrophe escaped.

CwToneShifterTest pins the premise the hint rests on: that the scan reports tones
the model window excludes, at 120, 250, 1400 and 1500 Hz.
2026-08-23 01:42:37 +00:00
mckero 23f47d9122 fix(cw): keep the shift marker visible when the pitch readout goes negative
The guard suppressed every marker, the target line included, whenever the
reported pitch was not positive. Shifting a low tone UP makes that routine:
pitch is (loudestBin * 12.5 - shiftHz), so with a 100 Hz tone shifted +700 Hz it
goes negative for 25 of the 65 bins, down to -300 Hz, and updateSignalMetrics
applies no prominence test so mains hum in a key-up gap is enough to park the
argmax down there. 77 reachable (tone, bin) pairs across 100-350 Hz produce it.
The result was the display showing nothing at all while the shift was active -
exactly what the previous commit set out to fix.

The target line is now drawn on the strength of the shift alone, since a shift
being applied is the fact worth showing and it does not depend on the pitch. A
non-positive pitch marks the low edge, which is where such a tone actually is,
and only the numeric label is suppressed because the number itself is nonsense.
A NaN pitch previously slipped past all three comparisons and rendered the HIGH
edge marker labelled "0 Hz"; it now draws the target line only.

TONE_SHIFT_TARGET_HZ reads CwToneShifter.TARGET_HZ instead of recomputing the
window midpoint. The two are equal today by coincidence, not construction:
retuning either would leave the green line marking a frequency nothing is
delivered to, silently. CwToneShifterTest now pins TARGET_HZ inside the window
and clear of its edges, which is the one part of this the JVM suite can hold.

The label side now tips at the target rather than the window maximum, so a pitch
sitting on the upper edge gets its text on the same side as its line.
2026-08-23 01:15:03 +00:00
mckero 5a45aab2b1 fix(cw): make the out-of-window tone marker actually visible
The edge marker was drawn outward from the canvas edge, so every one of its
three line segments fell outside the clip and nothing rendered. Measured at a
typical 320 px width: 0 of 3 segments visible on either side. That is the one
case the marker exists for - an out-of-window tone is absent from this picture
by definition, so with the marker clipped away the operator has no signal at all
that a shift is happening. Which is what was reported.

It is now a solid bar along the edge the tone lies beyond, plus a chevron whose
arms open inward from it, so the whole marker sits inside the clip while still
reading as pointing off-picture.

Three further defects in the same code:

The frequency label was pinned to TopStart while its background rect tracked the
tone's frequency, so at 1500 Hz the rect sat at x=278 and the text at x=11. The
rect is gone and the label now sits on whichever side the marker is on.

Markers were drawn after two early returns that fire on an empty or silent
spectrum. A shift is deliberately held through key-up gaps, so the markers were
blinking out during the very silences the shift survives. They now draw
unconditionally, after the spectrum so it cannot bury them.

dashCount floored, leaving up to 8 px of the column undrawn at the bottom.

Also extracts the marker drawing into a DrawScope extension, hoists the shared
colours and the target frequency to file-level constants, and rounds the label
instead of truncating it.
2026-08-23 00:28:54 +00:00
mckero 9367878702 fix(cw): show the correct original tone frequency in the waterfall label
The Canvas marker was fixed to draw at estimatedPitch, but the overlay Text
still computed its label from estimatedPitch + toneShiftHz, which showed the
shifted position (800 Hz) instead of the original tone (e.g. 1500 Hz).
2026-08-22 15:53:52 +00:00
mckero 8fbc639a82 fix(cw): draw the original-tone marker at the correct waterfall position
estimatedPitch is already corrected back to the original tone frequency
(the spectrogram computes from shifted audio, and updateSignalMetrics undoes
the shift), so adding toneShiftHz to it again placed the orange marker at the
shifted position - right on top of the green target line, making them
indistinguishable.

The orange marker now goes directly on estimatedPitch. When the original pitch
is outside the visible 400-1200 Hz band, an arrow at the nearest edge points
toward it instead.
2026-08-22 15:36:17 +00:00
mckero fa73328936 feat(cw): show tone-shift markers on the waterfall spectrogram
When the tone-shift feature moves a tone into the model's 400-1200 Hz window,
the waterfall now shows two visual markers so the operator can see what is
happening: a green dashed line at the target (800 Hz) and an orange frequency
label at the top-left showing the original pitch.

The waterfall draws the RAW audio, not the shifted audio, so a 1500 Hz tone was
always invisible regardless of the shift setting. The markers close the gap:
the operator can now see that a tone was detected and where it was moved, even
when the original pitch is outside the visible band.

activeShiftHz is now a StateFlow exposed through ICwDecoder so the UI can
observe it without polling.
2026-08-22 15:32:33 +00:00
mckero 50a644f417 build: bump to 4.5.8 (versionCode 465)
AMSAT status page: 12 two-hour stripes per day, UTC calendar days, two distinct
greys for no-report vs no-data, and a data-coverage marker from the summary
endpoint that flags satellites crowded out of the global 500-record pull.
2026-08-22 13:05:31 +00:00
mckero 7a2bbb8701 chore(amsat): update User-Agent to match the current release version
All three AMSAT endpoint calls still declared Look4Sat/4.5.5 while the project
has been at 4.5.7 for several releases. The API does not appear to validate the
header, but it misrepresents the client version in server logs.
2026-08-22 12:34:53 +00:00
mckero 018a3afd2b fix(amsat): mark satellites whose reports were crowded out of the global pull
The API caps at 500 records regardless of the hours requested. With 88 catalog
satellites, eight of them more active than 50 reports per 72 hours, quieter
satellites get crowded out. Measured live: the global pull returned 500 reports
covering 36 satellites, while the summary endpoint reported 743 reports across 38
satellites. 26 of 38 satellites had incomplete data, and two (PO-101_[FM] and
TEVEL2-6_[FM]) had zero reports in the global pull despite having reports in the
summary.

The summary endpoint (api/v1/summary.php) returns per-satellite report counts in
one request, so the fix adds one extra call rather than the 88-request
alternative of per-satellite pulls. A satellite whose global pull is incomplete
gets a subdued "68 / 116" marker next to its name, telling the operator the page
knows there is more data it could not fetch. The marker is silent when the
summary is unavailable or the counts match, so the feature degrades gracefully.

The earlier no-data grey (0xFFE8E8E8) already prevented the worst case: slots
crowded out of the global pull were marked as "we never looked" rather than
claiming "nobody reported". The marker now closes the remaining gap: the page
can honestly say "we know there are 116 reports for this satellite but we could
only show you 68 of them".

Also fixed a subagent mutation-testing residue: the coverage floor had been
moved from global (reports.minOfOrNull) to per-satellite (satReports.minOfOrNull)
and left in the tree. One test caught it (coverage is judged from all reports,
not one satellite's), proving the test has teeth.

Adds getAmSatSummary to IRemoteSource and RemoteSource, parseSummary to
AmSatRepository, and summaryCount to SatStatus. All eight test-file
implementations of IRemoteSource were updated for the new method.
2026-08-22 11:47:42 +00:00
mckero 3612e662e7 fix(amsat): distinguish slots we have no data for from slots nobody reported
Grey meant two different things. The API caps at 500 records however many hours
are requested: measured against the live endpoint, a 72-hour request returned 500
reports spanning only 49 hours, so the oldest 9.5 hours of the third day had no
data at all. Those cells were painted the same grey as "nobody reported", which
claimed knowledge we did not have - 352 of 3168 cells on a real page, a third of
the third day's column.

Slots entirely older than the earliest report in the response now use a lighter
grey. Coverage is judged from all reports rather than per satellite: a quiet
satellite has no reports of its own, but the slots it shares with the rest of the
response were still covered, so it must read as "not heard" rather than "unknown".

The two greys are now in the legend, which previously listed only the four active
states. That matters more than it sounds: on the live page 81% of cells are
"nobody reported" and 11% are outside our data, so a user looking at a mostly-grey
row had no way to tell a dead satellite from a gap in what we fetched. The legend
chips use a solid dot, so the two greys stay distinguishable despite the 25%
alpha background. Strings added to all nine locales.

Three tests cover it: a day entirely before the data starts, a day straddling the
boundary, and an empty response marking nothing as covered.
2026-08-22 10:27:44 +00:00
mckero 79215e7623 test(amsat): pin the slot arithmetic against hostile dates and boundaries
The UTC alignment landed with tests covering the normal cases; these cover the
ones that would have made it wrong quietly.

Midnight arithmetic is exercised at exactly midnight, a second either side,
every leap-day combination around 2028-02-29, both year boundaries, and the
first of all twelve months in a leap and a non-leap year. Since the code steps
back a day by subtracting 86400 rather than using Calendar arithmetic, those
dates are where a naive step would drift.

Every slot edge across all three days is probed at the boundary and one second
either side, asserting each instant occupies exactly one cell and that the cell's
day matches the report's UTC date - `until` versus `..` on the slot range is a
one-character mistake that would double-count edge reports.

Also pinned: the shared Calendar is not re-read after the labels loop (it points
at the oldest day by then), repeated calls are idempotent, duplicate catalogue
names produce duplicate rows carrying the same report, reports for names absent
from the catalogue are dropped, and the build stays linear in reports rather than
quadratic.

Adds a comment recording why reusing that Calendar is safe: each pass assigns
timeInMillis outright instead of adjusting fields.

235 tests pass.
2026-08-22 09:15:23 +00:00
mckero 8f646d76f9 fix(amsat): align the status grid to UTC calendar days, one stripe per slot
Two defects in our own AMSAT page, both found by auditing the change that exposed
them.

The day columns claimed to be dates but were a rolling window anchored on the
fetch time. Fetching at 06:07 UTC put 17.9 hours of yesterday into the cell
labelled today; measured against a live amsat.org page of 1021 reports, 73% of
them landed in the wrong day column and none matched the official cell. Days are
now UTC calendar days and slots are fixed UTC bands - slot 0 is 22:00-24:00, slot
11 is 00:00-02:00 - so a cell's contents match its label whenever it is fetched.

The day cell painted one colour for the whole day, taken from the first slot that
had a report, so a satellite that worked all morning and failed all afternoon
looked identical to one that worked once - the reported symptom. It now draws one
stripe per two-hour slot in the same 64x28 dp footprint. Twelve stripes are about
5 dp each, roughly 15 px at 440 dpi, and runs of the same status merge visually,
so a day reads as a few blocks rather than twelve lines. Every density from ldpi
up allocates all twelve without dropping one, and the 4 dp corner radius leaves
95% of the end stripes visible. The report count text is gone; tapping a day
still lists every report from it, which was already the richer view.

buildStatuses and ApiReport are internal rather than private so the grid contract
can be tested. AmSatSlotBuildTest drives it directly: fetchStatus cannot be
tested here because the parsing around it uses Android's JSONObject, a JVM stub
that makes every call return null - eight of nine tests written against it failed
for that reason before being rewritten.

Also corrects three KDoc comments claiming 5 days when the code builds 3, and
records in AGENTS.md that the status colours are ARGB literals in core:data,
duplicated in MainTheme, which anything needing themeable or colour-blind-safe
colours has to fix first.
2026-08-22 06:59:39 +00:00
mckero ea125d7db4 refactor(cw): move the shift decision into core:domain so tests can reach it
Mutation testing found the decision rule was effectively untested. Four defects
injected into it - removing the silence guard, comparing shifts instead of
tones, never setting the hysteresis anchor, and inverting the comparison - all
left the entire suite green. The rule lived inside CwDeepDecoder, which needs an
Android Context and a loaded ONNX session, so tests could only restate it, and a
restated rule cannot fail when the real one is wrong.

CwShiftDecider now holds the rule as a pure class that both the decoder and the
tests drive. Its outcome is reported as an enum so the decoder's logging is a
presentation concern rather than a second copy of the logic. CwShiftDeciderTest
targets each of the four surviving mutants directly.

MIN_PROMINENCE lowered from 8.0 to 4.5. Raising it to 8.0 last round overshot:
measured on 400 ms windows of keyed CW in noise, a comfortably copyable signal
reaches only 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB, so 8.0 silently refused
to shift weak out-of-window signals - the exact failure the feature exists to
prevent. Pure noise peaks at 2.2-3.4, so 4.5 keeps zero false positives across
40 noise windows while retaining the weak end. A false tone is worse than a
missed one: it moves a good signal out of range, whereas a miss leaves the audio
alone until a stronger window arrives. Windows dominated by keying gaps measure
2.4 and are indistinguishable from noise at any threshold; those are skipped.

Test files reorganised to match: the decision rule is covered by
CwShiftDeciderTest against real code, signal-level properties by
CwToneShiftSignalTest, and the restated-logic file it replaces is gone.

80 CW tests pass, golden vectors included.
2026-08-22 04:02:28 +00:00
mckero fdb44af9ff fix(cw): stop silence and edge estimates from defeating the tone shift
Two audit findings, both measured, both able to silently disable the feature.

A detection window landing in a keying gap used to collapse an established
shift to zero. CW is keyed, so gaps are normal: over 180 s of keyed audio at
1400 Hz, 11 of 90 detections saw no tone, and each one wiped the decode window
and left the next ~2 s buffered unshifted - outside the model's range and
therefore invisible to it. Absence of a tone is now absence of evidence and the
active shift is retained.

Hysteresis moved from shift space to tone space, anchored on the pitch that
produced the active shift. The old rule required a non-zero previous shift and
a needed shift, so it lapsed exactly where the jump is largest: at the 1200 Hz
edge one 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside"
(a large shift). Measured 35 window drops in 60 detections for a 1205 Hz tone,
and 10 in 10 for a bare one-bin hop. A shift of zero is a real state, not the
absence of one. Slow drift still catches up, since the anchor bounds staleness
at the margin rather than letting it accumulate.

Detection prominence raised from 3.0 to 8.0. Pure noise peaks at 2.0-3.3 times
its own spectral mean, so 3.0 admitted roughly one noise window in five as a
"tone" - and a false tone is worse than none, since it moves a good signal out
of range. Keyed CW measures 47-51, so the gap is wide.

Shifted output is clamped to the +/-1.0 range the spectrogram assumes. The
Hilbert kernel's L1 gain is 2.51, so mixing overshoots: a full-scale square
wave measured 2.35 and even a plain sine 1.05.

The detection pool moved to core:domain as CwDetectionPool so its ring
behaviour can be tested directly - mutation testing showed the previous private
implementation was unreachable from any test. Its chronological-order contract
now has 11 tests driving the real class.

Removed the write-only detectedToneHz field.

74 CW tests pass, golden vectors included.
2026-08-22 02:35:58 +00:00
mckero f6db55b35c perf(cw): pool detection samples in a ring buffer
The detection pool shifted its whole array down one slot per incoming sample
once full. Detection is throttled to 2 s but the pool fills in 400 ms, so for
the remaining 1.6 s of every cycle each chunk arrived at a full buffer: 320
copies of 1280 floats per chunk, measured at 24320 whole-array moves per 10 s
of audio, all on the capture thread.

Writing to a ring index is O(1) per sample. Draining walks the ring from the
oldest slot so the analyser still receives the most recent audio in
chronological order - a test feeds a ramp past capacity and asserts the exact
contents, since getting the wrap wrong would splice the waveform and corrupt
every estimate silently.
2026-08-22 01:37:16 +00:00
mckero 1b8f8c46f6 fix(cw): drop stale audio on a tone-shift change, and damp detector jitter
Follow-up to the tone-shift feature, closing gaps the audits surfaced.

Toggling the setting, or the detector settling on a materially different shift,
now discards the buffered audio. Without it the 20 s decode window kept feeding
the model samples moved by the old amount for up to 20 s after the user acted,
and updateSignalMetrics corrected the pitch readout by an offset that no longer
matched the window. Text already committed to the history is kept: it was
correct when it was decoded.

The previous-state flag is nullable and seeded from the current setting on the
first chunk, so a decoder created while the setting is already on does not
report a spurious change and wipe an empty buffer. reset() clears it back to
null for the same reason. Two decoders can be live at once (the CW screen and
the Radar panel) and each tracks its own state.

Re-shifting is now gated by a 40 Hz hysteresis. Detection resolution is 12.5 Hz
and a real tone wanders, so without it an estimate hopping between adjacent
scan bins would drop the window every 2 s - costing far more decoding context
than re-centring gains. 40 Hz absorbs two bins of jitter while still following
a genuine retune; a test pins both halves of that trade-off.
2026-08-22 01:15:11 +00:00
mckero 9798107d37 feat(cw): optionally shift out-of-window CW tones into the model's range
DeepCW only analyses 400-1200 Hz - its input tensor is 65 bins wide, fixed at
training time - so a CW note outside that range is invisible to the decoder.
This adds an opt-in preprocessing step that moves such a tone to 800 Hz, the
window centre, extending the usable pitch range without touching the model.

Single-sideband mixing via a 63-tap Hilbert transformer. Plain real mixing was
measured and rejected: shifting 1500 Hz to 800 Hz left a fold-back image at
1000 Hz at 0.999 of the wanted amplitude, inside the window. Zero-stuff
upsampling plus lowpass handled downward shifts but left a 0.996 image when
shifting 300 Hz upward. The Hilbert approach measures clean on nine tones from
150 to 1550 Hz: one peak at the target, nothing above 0.3 relative amplitude.
In-window energy for a 1500 Hz input goes from 6.8% to 94.6%.

Only out-of-range audio is processed. A tone already inside 400-1200 Hz is
returned untouched (same array instance, no copy), and with the setting off the
audio path is exactly what it was before.

CwToneShifter.Streaming carries the Hilbert filter history and mixer phase
across capture chunks. Shifting each chunk in isolation left 62 of every 320
samples convolving against zeros, inflating envelope ripple to 8.7x the
whole-buffer baseline. A residual difference in the last ~3 samples of each
chunk is causal and documented: those output samples would need input that has
not been captured yet.

Detection pools chunks rather than gating on one. A capture chunk is 4410
samples at 44.1 kHz but only 320 after resampling to 3200 Hz, so requiring
1280 samples in a single chunk would have made the feature dead code - the two
independent audits both found this before it shipped. Detection now runs on a
pooled 0.4 s window, at most every 2 s.

Toggling the setting or a change in the detected shift drops the buffered
audio: the 20 s window would otherwise keep decoding samples moved by the old
amount, and the pitch readout could only be correct for one of them. The
readout itself subtracts the active shift so it shows the pitch on the radio,
not the shifted one.

Settings: OtherSettings.cwToneShiftEnabled, off by default, persisted and read
back in SettingsRepo, toggled from the Other card in Settings with a help line
explaining the 400-1200 Hz limit. Strings added to all nine locales. The
decoder reads the flag per chunk, so the toggle applies without restarting
capture.

Debug: the enabled-state transition, each detection verdict (no tone / inside
window / shifting by N Hz), and every shift change are logged, with the noisy
paths throttled to the 2 s detection interval. CwProbe records shift changes
only, keeping well inside its 1 MiB cap.

Tests: 8 shifter tests (detection sweep, noise rejection, pass-through
identity, image-free shifting across 8 tones, end-to-end spectrogram energy),
8 streaming tests (chunk continuity, history retention, reset semantics, chunk
sizes above and below the history window), and 6 gate tests including a
regression guard that a 320-sample chunk must be able to reach the detection
threshold. All 53 CW tests pass, golden vectors included.
2026-08-22 01:07:38 +00:00
mckero e3d7238721 chore(release): bump build number to 464 for the v4.5.7 rebuild
Version name stays 4.5.7 so the release is overwritten in place; the build
number must increase for Android to accept the update. This rebuild carries
the upstream rt-bishop merge (18 commits) on top of the 30 audit fixes.
2026-08-21 11:25:26 +00:00
mckero 40ba3fecdf chore(amsat): remove the HTML scraping path superseded by the JSON API
The merged upstream AMSAT implementation fetches status data from AMSAT's JSON
endpoints (getAmSatCatalog / getAmSatReports), so the fork's HTML scraping path
no longer has a caller:

- core/data/.../source/AmSatParser.kt (136 lines): parsed the amsat.org status
  table, deriving state from the page's inline colour codes.
- IRemoteSource.getStatusHtml() plus its RemoteSource implementation and the
  DatabaseRepoTest fake override.

Verified zero references repo-wide before removing, and again afterwards.
Request / CancellationException imports in RemoteSource remain in use by the
other fetchers. compileReleaseKotlin plus core:domain / core:data /
feature:map / feature:roaming unit tests stay green.
2026-08-20 15:00:13 +00:00
mckero 57d6f9d7ed chore(merge): drop dead leftovers from the upstream merge
Post-merge audit found code the merge left unreferenced:

- SettingsRepo: keySatelliteUrls / keyTransceiversUrls / separatorUrl were
  upstream's list-shaped data-source keys. The merge kept the fork's map-shaped
  DataSourcesSettings, so these three had a definition and zero uses.
- feature/status/res/drawable/ic_refresh.xml: SatStatusScreen imports
  core.presentation.R only, so its R.drawable.ic_refresh resolves to the
  core copy; the feature-local copy was never addressable. It was the only
  file under feature/status/src/main/res, so the directory goes with it.

Verified zero references with a repo-wide grep before removing each symbol.
compileReleaseKotlin plus core:domain / core:data / feature:map /
feature:roaming unit tests stay green.
2026-08-20 13:24:45 +00:00
mckero a654735337 merge: upstream rt-bishop main (18 commits) with conflict resolution
Merges rt-bishop/Look4Sat main (a42a5f1f, 18 commits: AMSAT status page,
customizable data sources, Doppler calculator, radar compass offset, per-sat
offset memory, localized date formats) into the fork's 30-commit audit
baseline.

Conflict resolution policy (user-directed):
- AMSAT feature (AmSatRepository, SatStatusScreen/ViewModel, SatStatus model):
  upstream version, which the user judged better built. MainScreen routes
  Screen.AmSat through SatStatusDestination().
- Localization: our values-zh/values-tr restored (upstream's merge dropped the
  fork-only strings); new upstream strings (sat_group counts, compass offset,
  frequency offset help) added in EN + ZH.
- fork-only features kept (CW decoder, Mutual/Roaming, WaveLog, APRS, Log tab,
  custom TLE/transceiver source switches): ours.
- Both sides' additions merged where independent: radar compass offset fields
  (Settings/SettingsRepo/RadarState), calculatorOffsetKHz action, wider linear
  transponder detection, deduplicateTransponders + its tests, sunrise/sunset
  tests merged with the moon hour-angle test.
- Sources kept as the fork's map structure (DatabaseRepo depends on it);
  satelliteModes list re-added for SelectionRepo. getSatelliteTypesIds /
  setSatelliteTypeIds re-added to ISettingsRepo+SettingsRepo; SharedDialog
  re-added to Components; providePairedBluetoothDevices added to
  IMainContainer/MainContainer.
- Icons renamed upstream (ic_satellites->ic_sputnik, ic_radar->ic_satellite)
  applied to Navigation/MutualScreen.

Build verified: all modules compileReleaseKotlin + unit tests
(core:domain, core:data, feature:map, feature:roaming) green.
2026-08-20 12:35:01 +00:00
mckero c547a126ee chore(release): bump build number to 463 for the v4.5.7 rebuild
Version name stays 4.5.7 so the existing release can be overwritten in place;
the build number must still increase for Android to accept the update.
2026-08-18 03:12:45 +00:00
mckero 16c746f552 fix(mutual): advance the search when refine collapses a pass window
findMutualPassesFallback skips a candidate with `if (refinedLos <= refinedAos)
continue` but left searchStart untouched, so the next loop iteration called
findNextMutualPass with the same start time and received the same pass again.
Today that branch is theoretically unreachable: refineEdge's 70 s window always
spans findNextMutualPass's 60 s sampling step, so the refined AOS/LOS can only
move inward and never cross. But the invariant is fragile - any future change
to the search step or the refine window (or a near-horizon pass whose
crossings land at the window edges) makes the loop spin forever on one pass,
freezing the query coroutine.

Advance searchStart past the collapsed pass before skipping, breaking the
cycle regardless of how the windows shift. Behaviour for the current reachable
paths is unchanged.
2026-08-17 17:56:09 +00:00
mckero 8f56ea05ec refactor(roaming): reuse positionToQth instead of the ported grid tables
RoamingScreen carried ~170 lines of decompiled range-lookup tables
(encodeLon/encodeLat) that re-implemented exactly what core:domain's
positionToQth already does. A probe calling both over 16,471 sampled
coordinates (every 2 degrees across the full globe) found byte-identical
8-char locators, so the tables were pure duplication - two implementations of
the same Maidenhead encoding that had to be kept in sync (the earlier boundary
fix had to be applied twice).

Replace them with positionToQth and split its standard-ordered output
(lonField latField lonSquare latSquare lonSub latSub lonSubsub latSubsub) back
into the per-axis segments the UI consumes: the 3x3 ring (first 4 chars),
markerLeft (lon subsquare), markerTop (lat subsquare). Out-of-range input
keeps the old blank-segment behaviour: positionToQth returns null, the locator
becomes spaces, qthNeighbors returns an empty ring, and the marker lookups
fall back to 0.

Net -160 lines. All existing RoamingState tests and the new equivalence probe
pass; :feature:roaming compiles.
2026-08-17 17:17:19 +00:00
mckero b749733289 fix(audio): keep cleanup from masking start failures or skipping release
AudioCapture.audioFlow's finally ran recorder.stop() then release() naked. If
startRecording() threw - permission revoked mid-request, audio device error -
the finally's stop() threw IllegalStateException (stop on an uninitialized
recorder), which replaced the original error AND skipped release(), leaking
the AudioRecord. The flow's caller saw "recorder failure" instead of "no
permission" and the native recorder was never freed.

Wrapping each cleanup step in runCatching preserves the original exception
while guaranteeing release() runs. Probe: a start failure previously surfaced
as RuntimeError with released=false; it now surfaces as the original
PermissionError with released=true.
2026-08-17 16:57:33 +00:00
mckero b4cfb16159 fix(radio): don't record frequencies the radio rejected
RadioTrackingService wrote lastSetTxFreq/lastSetRxFreq unconditionally after
calling setFrequency, ignoring its Boolean result. When the radio rejected the
frequency - the FT-817 CAT limit added in the previous commit, a dropped
Bluetooth link, or a failed ack - the remembered value no longer matched what
the radio actually holds. The manual-tuning detector then saw a phantom dial
change on the next read-back (read is the real frequency, lastSet is the one
that never landed) and entered tuning mode: it locked onto the wrong base and
kept rewriting the radio.

Probe of the state machine: before the fix, a rejected 1.26 GHz write against
a radio sitting on 145.5 MHz left lastSet at 1.26 GHz, so every subsequent
cycle read a 1.1 GHz gap and flagged manual tuning forever. After the fix the
lastSet is only updated on success, so the detector sees no change and the
loop keeps applying the next valid frequency. Same fix applied to the split
IC-705 path (setWorkingFrequency/setTxVfoFrequency).
2026-08-17 16:54:23 +00:00
mckero 7319cf8f5b fix(coroutines): propagate cancellation in remote source and status view model
RemoteSource's five suspend functions and SatStatusViewModel's two fetch paths
caught bare Exception, which also swallows CancellationException. When the
owning scope is cancelled (screen leaves, app closes) a cancelled network call
was reported as a null/error result instead of stopping: the caller kept
running until the next suspension point, and SatStatusViewModel wrote state
updates into an already-cancelled scope. Correct coroutine hygiene is to let
cancellation propagate - rethrow CancellationException before the generic
catch. Verified semantically with an asyncio probe: a swallowed cancel returns
a normal-looking null and the caller continues; a propagated cancel stops the
coroutine immediately.

No behaviour change for real errors; :core:data and :feature:status compile.
2026-08-17 16:46:09 +00:00
mckero baf2a7022d fix(aprs): hold the client lock across the response read in sendPacket
sendPacket wrote the packet under the lock but read the server response
outside it. disconnect() - called concurrently from stop() and from the
reconnect path in AprsReporter.reportOnce's catch - nulls and closes
writer/reader/socket under the same lock, so the lock-free read raced with it.
A probe interleaving 5,000 sends with repeated disconnects produced a mix of
744 OK and 4,256 exception results: the response read hit a just-closed socket
and the swallowing runCatching reported Pair(true,"OK") for a packet that may
never have left, or read through a stale reference. The tracker believed the
beacon was heard while APRS-IS never received it.

Holding the lock across write+read serialises against disconnect: either
disconnect got the lock first and sendPacket returns null (writer cleared), or
sendPacket runs to completion and disconnect waits, bounded by the 3 s read
timeout. Re-ran the interleaving probe: 3,000 sends, zero inconsistent
results. Compiles and :core:data tests stay green.
2026-08-17 16:33:12 +00:00
mckero b95a86c97f fix(radio): reject FT-817 frequencies the CAT protocol cannot express
The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution, so the
largest representable value is 999,999,990 Hz. encodeFrequencyBcd is exact
below that, but for anything above it the %08d formatting silently drops the
leading digit: 1,267.6 MHz encodes as 126.76 MHz. Verified against the release
bytecode - 1,000,000,000 Hz -> [10 00 00 00] -> 100,000,000 Hz, ten times
lower - and the SatNOGS catalogue has 17 transmitters with uplinks over 1 GHz
(QO-100 at 2400.05 MHz, several 23 cm links), so the wrong value is reachable
via RadioTrackingService when an FT-817 is mis-configured as the TX radio.
The tracking loop's read-back then locks onto the wrong band with no warning.

Reject out-of-range frequencies at setFrequency with a log and return false
instead of sending a corrupted command. In-range values are unaffected
(probe: 7.074/145.5/435.1 MHz and both 999,999,98x/99x MHz round-trip exactly;
every value above the limit is refused before the encoder runs).
2026-08-16 09:47:26 +00:00
mckero ed1fe66892 fix(geo): replace the clipLon while-loop with a modulo reduction
clipLon reduced longitudes by looping += 360 until in range. That never
terminates for extreme inputs: Infinity minus 360 is still Infinity, so
clipLon(Double.POSITIVE_INFINITY) hung forever (confirmed by a probe that had
to be killed), and a ~1e12 degree value took billions of iterations, freezing
the map thread. NaN came back as NaN either way.

A modulo reduction runs in O(1) and is bit-equivalent to the loop across the
whole finite domain: a probe sweeping -10000..10000 at 0.01 degree steps (2
million points) plus the boundary values -180/-179.999/0/179.999/180/180.001/
±360/±540 shows zero mismatches. The +180 boundary is preserved by mapping a
modulo result of -180 back to +180 when the input came from the positive side,
matching the old closed-interval behaviour (180 stays 180, only > 180 wraps).

Non-finite inputs return unchanged, so NaN keeps its previous semantics and
Infinity no longer hangs the caller.

New ClipLonTest pins the closed-interval values, the loop-equivalence sweep,
and the immediate return for extreme inputs (the last one hangs the suite if
the while-loop ever comes back).
2026-08-16 09:41:35 +00:00
mckero 1e24673632 fix(network): close sockets on setup failure and on write failure
Two leaks in NetworkReporter, the same family as the Bluetooth/APRS/radio
socket leaks fixed earlier:

1. ensureRotatorConnected/ensureFrequencyConnected assigned the field directly,
   so a channel that opened but threw during the rest of setup was never
   closed and remained referenced. Use a local `opened` and close it in the
   catch, matching the pattern used in AprsIsClient/Ic705Controller/
   Ft817Controller/BluetoothReporter.

2. write() only flipped connected=false on failure. The broken channel stayed
   in the field, the next ensure* reconnected and overwrote it, and the old
   channel was never closed. Now a failed write closes the channel and nulls
   the field. The null check is identity-based (socket === field) so a stale
   reference from a concurrent report can never close a newer channel.

State-machine probe: normal write keeps the socket, failed write closes and
nulls, next report reconnects fresh, and passing a stale reference does not
close the newer socket.
2026-08-16 09:36:08 +00:00
mckero ed0f5678b3 fix(cw): cap the crash-probe log file at 1 MiB
CwProbe.step() appended one line per call with no size limit, rotation or
cleanup, and it runs on every build: CwDeepDecoder is the only ICwDecoder
implementation and writes infer_begin + infer_done every 1.5 s inference tick.
Measured against the actual line format that is ~170 KB/hour, ~4 MB/day of
unbounded growth in files/probe_cw.txt while CW audio is monitored, plus
synchronous disk I/O on every inference.

Truncate when the file exceeds 1 MiB instead of deleting, so the probe keeps
the most recent diagnostics (the reason it exists: the last lines show where a
flash-crash died). Simulated 10 h of continuous use: 2.7 MB written in total,
file stays bounded around ~640 KB; previously it would have kept all 2.7 MB
and grown without limit.
2026-08-16 09:33:44 +00:00
mckero aac1fa0da5 fix(map): pick the pass by time instead of a field that is never set
The map info panel chose which pass to describe with

    allPasses.find { it.catNum == catnum && it.progress < 1 }

but OrbitalPass.progress defaults to 0 and nothing in the repository or the
prediction layer ever assigns it - PassesViewModel computes progress on its own
local copy of the list and never writes it back. The predicate was therefore
always true, so the lookup returned the satellite's *first* pass forever.

Simulated over an ISS timeline with three passes (10:00, 12:00, 14:00), 4 of 6
sampled instants were wrong: from 10:30 onwards the panel still pointed at the
10:00 pass with its countdown frozen at 00:00:00, instead of counting down to the
12:00 and 14:00 passes. Only re-fetching the pass list refreshed it.

Select by time now: the pass currently in progress, otherwise the earliest one
still upcoming. Extracted as the pure internal selectCurrentOrNextPass so it is
testable, with the reasoning recorded so the progress field is not reintroduced
as a filter here.

Restoring the old predicate fails 5 of the 6 new tests; with the fix
:feature:map:testDebugUnitTest, :core:domain:test, :core:data:testDebugUnitTest
and :feature:roaming:testDebugUnitTest are all green.
2026-08-14 19:49:32 +00:00
mckero c7bb253981 fix(map): close both sides of an antimeridian crossing
The ground track is cut into polylines so none of them spans 180 degrees, but the
cut only ever appended the outgoing edge point. The next polyline therefore began
at the first sample past the meridian - typically around -178 - so the drawn
track stopped at the edge on one side and reappeared inland on the other,
leaving a visible gap on every orbit that crosses the Pacific.

The edge point also reused the *next* sample's latitude, so the closing leg
jumped: for 179 -> -178 spanning 14 -> 16 degrees latitude the edge was placed at
16.0 instead of the true crossing at 14.667.

Now a crossing closes the current polyline on the edge it leaves through and
opens the next one on the opposite edge, both at the interpolated crossing
latitude, so the seam is continuous.

The split is extracted as the pure internal splitAtAntimeridian/crossingLatitude
pair, which also gives feature:map its first unit tests. Verified against a
standalone Java probe first (eastward, westward, repeated crossings, and a track
hugging the edge without crossing), then as Kotlin tests: restoring the old
single-point behaviour fails four of them, and the current code is green
alongside :core:domain:test and :feature:roaming:testDebugUnitTest.

Also drops the misleading "left/right terminal position" comments: the branch
that fires when the previous sample sat near +180 is the eastward crossing, and
it correctly closes on +180.
2026-08-14 19:19:42 +00:00
mckero af96fe1cf0 test(predict): pin the Moon hour angle to a reduced range
MapViewModel converts MoonPosition.gha into the sub-lunar longitude with
`if (gha <= 180) -gha else 360 - gha`, which is only a valid longitude while gha
stays inside 0..360. Nothing enforced that: getMoonPosition relies on
`while (teg > 360) teg -= 360` reducing GMST before the single
`if (gha < 0) gha += 360` correction, and the raw GMST polynomial is about
3.5e6 degrees today, so losing that one line silently pushes the Moon marker
millions of degrees off the map instead of failing loudly.

Sweep a synodic month at 37-minute steps (1,167 samples) asserting gha stays in
0..360 and the derived longitude in -180..180, plus a check that the hour angle
advances 10-20 degrees per hour.

Verified the test has teeth: deleting the teg reduction makes both cases fail;
with the current implementation :core:domain:test is green. No production change
- the existing code is correct.
2026-08-14 18:54:26 +00:00
mckero 27d41eb2ee fix(amsat): render only the days the API actually returned
The status grid always drew six day columns, but the AMSAT reports endpoint
cannot supply six days for the full catalogue. Measured against the live API:

  limit=500  -> meta.count=500, covers 4 days (Aug 11..Aug 14)
  limit=1000 -> meta.count=500, same 4 days   (server clamps the limit)
  hours=336  -> meta.count=500, same 4 days   (window size does not help)
  before/offset/page -> ignored, same 500 newest rows

With ~90 catalogued satellites the 500 newest rows only reach about four days
back, so the two oldest columns were guaranteed to be uniformly gray. Gray means
"no report" in this UI, so the screen asserted nobody reported those days when
the truth was that the data was never fetched.

Derive the column count from the oldest report actually received, capped at six.
On live data that yields four columns labelled Aug 14..Aug 11 instead of six with
Aug 10 and Aug 9 blank. The UI already renders whatever days it is given, so no
UI change is needed.

Also name the request constants and record what was measured about the endpoint,
so the 500 is not mistaken for an arbitrary choice that can simply be raised.

Note for a future change: the per-satellite form of the endpoint
(reports.php?name=...) is not affected by the cap - sampling eight satellites
returned 926 rows spanning eight days, i.e. full six-day coverage - but it needs
one request per satellite (~0.8 s each, ~68 s for the whole catalogue), so
switching to it is a deliberate trade-off rather than a bug fix.

:core:data:compileReleaseKotlin, :core:data:testDebugUnitTest and
:core:domain:test all BUILD SUCCESSFUL.
2026-08-14 18:30:31 +00:00
mckero 2bac6655f3 fix(amsat): align status slots to their UTC calendar-day labels
AmSatRepository labelled columns by calendar date but filled them by slicing a
rolling 72-slot window ending at fetch time. The two timelines coincide only
near 23:59 UTC. At common fetch times the status grid lied about dates:

  UTC 00:00: 72 / 72 slots under the wrong label
             "today" column contained all of yesterday
  UTC 12:00: 36 / 72 wrong; every column straddled two dates
  UTC 13:37: 30 / 72 wrong
  UTC 23:59:  0 / 72 wrong (the accidental alignment case)

Anchor the six columns on UTC midnight instead. Every SatDay now covers exactly
[day 00:00, next day 00:00), split into twelve 2-hour slots newest-first so the
UI's existing first-non-gray lookup still chooses the latest daily report.

A standalone Java probe porting the old arithmetic reproduced the 72/72,
36/72 and 30/72 mismatches. Porting the new formula gives 0/72 mismatches at
00:00, 12:00, 13:37 and 23:59 UTC.

Also restore core:data's unit-test compilation. DatabaseRepoTest's fakes were
stale after IRemoteSource gained AMSAT methods and ISettingsRepo's zero-arg GPS
setter became suspend; the whole data test suite previously could not compile,
so data-layer regressions were untestable. Updated the fake members and verified
:core:data:testDebugUnitTest plus :core:data:compileReleaseKotlin BUILD
SUCCESSFUL. The product code does not use org.json in JVM tests because Android
org.json stubs throw there, so the date math remains verified by the standalone
same-JVM probe rather than a misleading mocked parser test.
2026-08-14 18:08:46 +00:00
mckero 2da7127fd3 fix(roaming): derive the 3x3 grid from qthNeighbors
The decompiled per-edge branches computing the surrounding nine squares had two
independent defects.

1. Field letters stepped past the alphabet

Every branch moved a field with raw character arithmetic (`str[0] - 1`,
`str5[0] + 1`) and Maidenhead fields only run A..R, so coordinates near the
edges of the world produced squares outside the alphabet:

  (-89.9, -179.9) -> [@A91, AA01, AA11, @A90, AA00, @A10, @@99, A@09, A@19]
  ( 89.9,  179.9) -> [RS80, RS90, SS00, RR89, RR99, SR09, RR88, RR98, SR08]

2. Some moved cells kept the old field letter

The north-edge branch advanced the latitude field for the top-centre cell only,
leaving the two top corners in the previous field:

  centre AA19 -> ported [AA00, AB10, AA20, ...]
                 correct [AB00, AB10, AB20, ...]

Cross-checked against the shared qthNeighbors helper, which is already covered
by QthConverterTest including the AA00 and RR99 wrap cases:

  before: 64,800 sampled coordinates, 6,480 disagreed (all with centre square
          digits 00 or x9, i.e. the north edge and the 00 corner)
  after:  64,800 sampled coordinates, 0 disagree

The ring is plain Maidenhead arithmetic with no QTH-Locator-specific behaviour,
so call qthNeighbors instead of keeping a second, wrong implementation. The
now-unreferenced buildGrids branches are removed (grep confirmed the definition
was the only remaining occurrence). The existing OL42 reference grid and the
four ported edge-case tests still pass unchanged.

Reverting the fix fails both new regression tests; with it
:feature:roaming:testDebugUnitTest and :core:domain:test are green.
2026-08-14 17:34:06 +00:00
mckero fed9fe188e fix(roaming): assign exact grid boundaries to the correct cell
The QTH Locator port keeps the decompiled range tables, which close both
adjacent cells (`-20.0..0.0` then `0.0..20.0`). Kotlin's `when` takes the first
match, so any coordinate landing exactly on a field, square or subsquare
boundary was attributed to the previous cell:

  (0, 0)          II99xx99  should be JJ00aa00
  (1, 1)          JJ00lx99  should be JJ01ma00
  (22, 108)       OL31xx99  should be OL42aa00
  (22.5, 108.5)   OL42fl99  should be OL42gm00
  (22.25, 108.25) OL42cf99  should be OL42dg00

At the field level the locator is wrong by a whole 20 deg x 10 deg field, and
the 3x3 neighbour grid plus the red position marker are derived from the same
characters, so the whole Roaming screen pointed at the wrong square.

Cross-checking the port against core/domain positionToQth over the grid:
  before: 65,341 sampled points, 4 agreed
  after:  65,341 sampled points, all agree

The independent converter was confirmed correct first: it reproduces the
user-verified reference sample OL42ih45, and hand-computing lon=-179.75
(0.25 deg into the field, x12 -> subsquare index 3 = 'd') and lat=-90
(subsquare 'a', extended digit 0) matches it rather than the port.

Rather than rewriting the faithful lookup tables, nudge the input by 1e-10 so
the closed ranges behave like the standard half-open [low, high) cells, keeping
+90/+180 inside the final R cell. Seven real-world city samples and all existing
ported-behaviour tests, including (90, 180) -> RR99xx99, are unchanged.

Regression tests added for the boundary cases and for cross-implementation
agreement. Reverting the fix fails both; with the fix
:feature:roaming:testDebugUnitTest is green.
2026-08-14 16:59:30 +00:00
mckero 19ca5205fb fix(cw): make waterfall revision atomic and keep clear from resurrecting old data
Two races shared the same cause: pushSamples runs on the audio capture thread
while clear() runs on the Compose main thread.

1. Lost redraw notifications

Both paths did `_revision.value += 1`. That expands to get -> add -> set and is
not atomic. A controlled two-thread probe (20k increments each, five runs)
lost up to 6,402 increments / 16%; using StateFlow.update lost zero. Since
revision is the Canvas's only redraw signal, every lost update can leave the
waterfall showing stale rows. If both writes land on the same number, StateFlow
sees no value change and notifies nobody.

Use `_revision.update { it + 1 }` in both paths.

2. Clear resurrected pre-clear audio

pushSamples copies pending audio under the lock, deliberately performs FFT
outside it, then reacquires the lock to append rows. The exact interleaving:

  audio thread: take old audio, start FFT
  main thread:  user taps Clear -> rows/pending empty
  audio thread: old FFT completes -> appends old rows again

The display becomes empty then immediately redraws the audio the user cleared.
A deterministic thread probe reproduced old rows after clear. Add a generation
counter protected by the same lock: pushSamples records it before FFT and drops
the computed rows when clear incremented it meanwhile. Fixed probe remains empty.

Verification: :feature:cw:compileReleaseKotlin + full :core:domain:test BUILD
SUCCESSFUL; grep confirms no non-atomic revision increments remain.
2026-08-14 16:31:25 +00:00
mckero a7a6d70d40 fix(aprs): keep enabled switch consistent with actual service state
AprsForegroundService refuses to run when callsign is blank and calls
stopSelf(), but it never writes enabled=false back to AprsStore. AprsCard did
the opposite: toggling Enable first persisted enabled=true, then started the
service. On a fresh install with no callsign this produced a permanent lie:

  UI switch: ON   SharedPreferences: enabled=true   service: stopped

Leaving and reopening settings still showed ON even though APRS had never sent
a packet. Startup/restore code could then repeatedly try to launch a service
that immediately stops itself.

There were two entry paths with the same root cause:
1. Turning the switch on before entering a callsign.
2. Erasing an existing callsign in the dialog while APRS was already enabled.

The switch now opens the configuration dialog without persisting or starting
anything when callsign is blank. Saving the dialog also forces enabled=false
when the callsign was erased.

State-machine simulation: old state ends ON/stopped; both fixed paths end in a
consistent OFF/stopped state. :feature:settings:compileReleaseKotlin and full
:core:domain:test BUILD SUCCESSFUL.
2026-08-14 16:10:18 +00:00
mckero d5230b3bc6 fix(qth): correct Maidenhead boundaries and longitude wrapping
A full-domain round-trip probe found three related boundary bugs.

1. Exact positive limits wrapped the square/subsquare terms to zero

positionToQth clamped only the A-R field index. At +90 latitude / +180
longitude the field saturated at R, but all later terms used modulo and wrapped
to square 0 / subsquare a:

  (90, 180) -> RR00aa00 -> (80.002083, 160.004167)
  error: -9.998 deg latitude, -19.996 deg longitude

The existing test incorrectly asserted RR00aa00 and had therefore fossilised
the defect. Clamp shifted coordinates just inside the half-open upper bound so
the limits land in the final cell RR99xx99.

2. isValidPosition allowed longitude through +360

Maidenhead covers -180..180, but 181..360 was accepted and produced plausible
locators that decoded 20-200 degrees away:

  lon 181 -> decoded 161.004167  (error -19.996)
  lon 270 -> decoded 170.004167  (error -99.996)
  lon 360 -> decoded 160.004167  (error -199.996)

Restrict the converter contract to -180..180.

3. Locator validation allowed S-X as field letters

The first pair has 18 fields A-R, while only the later subsquare pairs use
A-X. The shared [A-X]{2} regex accepted SS00aa / XX99xx and decoded them past
the poles (up to lat 149.98, lon 299.96). Use A-R for the field pair.

The SettingsRepo caller had a separate wrapping bug that masked part of this:
it mapped longitude>180 by subtracting 180 (270 -> +90, wrong hemisphere)
instead of modulo 360 (270 -> -90). Fix that at the writer too.

Verification:
- standalone JVM sweep: 519,841 points, old code had 1,441 large-error points
  with max drift 9.997917 deg lat / 19.995833 deg lon
- new Kotlin regression sweep requires every 8-char round trip <=0.01 deg
- QthConverterTest BUILD SUCCESSFUL
- full :core:domain:test + :core:data:compileReleaseKotlin BUILD SUCCESSFUL
2026-08-14 15:57:32 +00:00
mckero 7d7abeb31e fix(aprs): prevent duplicate reporters on repeated service starts
Every ACTION_START - and every null intent delivered by START_STICKY - called
startReporting(), which always constructed a new AprsReporter and overwrote the
field without stopping the old one. AprsReporter owns an independent
SupervisorJob + periodic while(isActive) loop, so every overwritten instance
kept reporting forever and could no longer be reached by ACTION_STOP.

Simulation:
  five ACTION_START events: 5 running reporters, 4 leaked -> fixed: 1 / 0
  START + 3 sticky restarts: 4 running, 3 leaked -> fixed: 1 / 0
  mixed real sequence:      4 running, 3 leaked -> fixed: 1 / 0

At the default 10-minute interval, four leaked reporters send 24 duplicate
position packets per hour and open 24 needless connections; this also amplifies
the connect-time socket leak fixed earlier.

startReporting now returns when the current reporter is active. Config changes
remain correct: AprsCard explicitly sends ACTION_STOP before ACTION_START, so
the old reporter is stopped and nulled before the new configuration starts.

:app:compileReleaseKotlin BUILD SUCCESSFUL.
2026-08-14 15:38:08 +00:00
mckero 828f720955 fix(status): show gray cell instead of crashing on an empty day
AmSatParser deliberately uses getOrNull + mapNotNull while reading each day's
12 slots, so a shortened HTML row can legitimately produce SatDay(slots=[]).
StatusRow then selected the first non-gray slot and fell back to slots.first(),
which throws NoSuchElementException and crashes the entire AMSAT status screen.

Use firstOrNull for both lookups and render a zero-count gray placeholder when
no slot exists. Real amsat.org HTML currently has all 41 satellite rows at the
full 73 cells, but the parser's own tolerance contract means the UI must handle
what it can emit.

Verified against the live page: parser matches 41/41 rows and 477/477 reports;
:feature:status:compileReleaseKotlin BUILD SUCCESSFUL.
2026-08-14 15:27:43 +00:00
mckero 5f1f90067f fix(aprs): clamp altitude and wrap course to keep fixed-width fields
Both extensions are fixed-width decimal fields, but neither value was range
checked before formatting:

  formatAltitude(-50.0)      -> /A=-00164   ('-' eats a digit slot)
  formatCourseSpeed(_, 360f) -> /360/...    (course must be 000..359)
  formatCourseSpeed(_, -1f)  -> /-01/...    (widens the field)

A negative altitude is reachable from a below-sea-level position or a poor GPS
fix, and the malformed extension corrupts everything after it in the comment
field. Altitude now clamps to 0..999999, course wraps modulo 360, and speed
clamps to three digits.

Found by the same locale probe that produced the previous commit.
:core:domain:test BUILD SUCCESSFUL.
2026-08-14 15:12:30 +00:00
mckero f4e188261d 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.
2026-08-14 15:09:18 +00:00
mckero e1233dceaa fix(wavelog): preserve six-character grids in v1 ADIF upload
WaveLog v1 truncated every grid to four characters with gridsquare.take(4),
while v2 sent the same grid at full precision. QRZ backfill provides six-character
locators (e.g. OM89ab / FN31pr), so the v1 path - the one used by the user's
server in practice - degraded position precision from roughly 4.6 km to around
100 km and stored different data depending on which API version answered.

Send the complete grid through v1 as well. The ADIF length field is already
computed from the actual value, so six/eight-character locators need no special
handling.

TDD proof:
  old take(4): v1_adif_preservesSixCharacterGrid FAILED
  fixed:       WaveLogApiPayloadTest BUILD SUCCESSFUL
  full suite:  :core:domain:test BUILD SUCCESSFUL
2026-08-14 14:56:23 +00:00
mckero 09e728fa1b fix(data): close sockets when connect() fails after the handshake
All five connect paths opened a socket, completed the TCP/RFCOMM handshake,
and only afterwards stored it in a field. Any exception in between leaked the
socket: the catch block just flipped a boolean, and disconnect() can only close
what already reached the fields.

Leak windows (statements that can throw after the handshake succeeded):
  AprsIsClient.connect        soTimeout / tcpNoDelay / getOutputStream / getInputStream
  Ic705Controller.connect     outputStream / inputStream / sendAndWaitAck
  Ft817Controller.connect     outputStream / inputStream
  BluetoothReporter x2        outputStream

AprsIsClient is the worst case because AprsReporter retries on a timer
(intervalMin, minimum 1 minute) and nulls out the client after each failure,
so every failed attempt permanently loses one fd:

  failure rate   leaked fds/hour   time to exhaust 1024 fds
       5%              3.0              ~14.2 days
      20%             12.0               ~3.6 days
      50%             30.0               ~1.4 days
     100%             60.0              ~17 hours

Typical trigger is a weak link where the TCP handshake succeeds but the peer
immediately RSTs (overloaded or rate-limiting APRS-IS server). Once fds run out
nothing in the process can open a socket or file any more: TLE updates, AMSAT
status and WaveLog uploads all start failing with no obvious cause.

Each path now keeps a local reference to the socket it opened and closes it in
the catch block, also clearing the stream/socket fields so a half-initialised
connection is not mistaken for a live one.

Verified: :core:data:compileReleaseKotlin BUILD SUCCESSFUL; grep confirms all
five close calls are present.
2026-08-14 14:09:42 +00:00
mckero c1895506e0 fix(cw): flush archive buffer inside loop to stop dropping audio
CwDeepDecoder appended evicted samples with a bare bounds check:

    for (v in overflow) {
        if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
    }
    if (archiveSize >= ARCHIVE_THRESHOLD) { flush() }

Once archiveBuffer (64000 samples / 20 s) filled up mid-batch the remaining
samples were silently discarded, because the flush only ran after the loop.

Worst measured case: 47999 samples already accumulated (just under the 48000
flush threshold, so no flush) plus a 64000-sample overflow batch means 111999
samples pushed into a 64000 buffer -> 47999 dropped, i.e. 15 s of audio missing
from the permanently archived CW history.

Now the buffer is flushed as soon as it is full and before appending, so every
sample reaches archiveDecode. Simulation over five batch patterns: dropped
count goes 47999 -> 0 for the worst case and all 111999 samples are archived.

Bounds: single append() can evict at most capacity samples, so drainOverflow()
returns at most 64000 - archiveBuffer never needs to grow.
2026-08-14 13:28:55 +00:00
mckero 066fafbb81 fix(data): use Mutex to queue calculatePasses, not drop calls
The previous guard (if (_isCalculating.value) return) silently dropped
concurrent calls. Every call carries filter settings the user just applied,
so a dropped one left the list showing results for the previous filter:

  User clicks 'Apply' with elevation>=5
  -> UI updates to show elevation>=5
  -> calculatePasses(elevation>=5) called
  -> but if _isCalculating=true, return immediately
  -> list still shows elevation>=30 results

The guard window is wide: delay(1000) + real calculation time (hundreds
of ms to seconds), exactly when the progress indicator spins and users
naturally interact again.

Mutex serializes calls instead: the second one queues and eventually runs
with its own parameters. This also fixes the original concurrency issue
(duplicate parallel calculations) and adds finally {} so a thrown exception
cannot leave isCalculating stuck at true (frozen progress indicator).

Reverts the regression introduced in the previous attempt to add concurrency
protection.
2026-08-14 13:07:53 +00:00
mckero aedb3fee19 fix(radar): SwipeDeleteRow missing key() deletes wrong QSO
Without key(entry.id), Compose reuses component state by position.
When the list reorders mid-countdown (new QSO inserted at index 0,
or QRZ grid backfill triggers refreshTick++), the pending deletion
transfers to a different record and removes the wrong one.

Affected screens: LogTab and WavelogLogScreen.
2026-08-14 12:57:37 +00:00
mckero 77314e824e fix(radar): make pass auto-advance and duplicate-QSO guard actually work
自查上一轮修复时发现两处改动根本没生效, 属于我上一轮的误判, 这里改成真修复。

## 1. 过境结束不切换 (上一轮改动无效)

上一轮在 tick 循环里加了"过境结束就重新 findCurrentPass()"。但
findCurrentPass() 的第一级匹配是:

    passes.find { it.catNum == catNum && it.aosTime == aosTime }

其中 (catNum, aosTime) 来自 satelliteRepo.selectedPass, 而 selectPass()
只在 MainScreen 用户点击过境时调用 (grep 全仓库确认 3 处调用点全在
MainScreen), 雷达页运行期间该值不变。所以过境结束后重新查询仍然精确命中
同一个已结束的过境, nextPass != pass 恒为 false, 一次都切不过去。

模拟脚本复刻 findCurrentPass 四级回退 + tick 循环验证:
  修复前 ISS(10:00-10:10) 结束后, 到 10:19 仍在 tick ISS, 切换 0 次
  修复后 10:11 切到 NOAA-18(10:15-10:25), 切换 1 次
  边界: 最后一个过境结束后无下一个, 保持当前不崩溃

改为新增 findNextPassAfter(): 取 aosTime > current.losTime 的过境, 优先
同一颗卫星的下一圈 (雷达继续跟这颗星), 没有则退回任意卫星最早的那个。

## 2. 重复提交 QSO (上一轮改动无效)

上一轮加的 submitting 标志位没有任何作用: submit() 全程同步, 进入时置
true, 返回前置 false, 中间没有挂起点。两次 IME onDone 是两个独立事件,
第二次进来时标志早已复位。

改为记录上次入库的呼号与时间戳, 同一呼号在 2 秒内重复提交直接忽略。
这才是实际要防的场景 (误触两次回车存两条同样的 QSO)。

验证: :feature:radar:compileReleaseKotlin BUILD SUCCESSFUL
2026-08-14 12:45:33 +00:00
mckero 74902cddce fix(i18n): escape single quote in Turkish whatsnew string
Android AAPT requires single quotes in string resources to be
escaped as \' to avoid being interpreted as the start of an
escape sequence. The unescaped Ayarlar'ı triggered:
'Invalid unicode escape sequence in string'

values-tr/strings.xml:108 Ayarlar'ı → Ayarlar\'ı
2026-08-14 11:44:16 +00:00
mckero 8324903104 chore(release): bump versionCode to 462 and refresh whatsnew for v4.5.7
Increment versionCode 461 → 462 to allow reinstallation over the existing
v4.5.7 APK (required for覆盖发行版 to work on user devices).

Update whatsnew in all 4 locales (en/zh/tr/id+in) to document the 10 bug
fixes shipped in this release:
- Menu layout: Settings永久消失, AMSAT/WavelogLog forced migration
- DataParser: epoch parsing for UTC 00:00:01–00:01:26
- Radar: auto-switch to next pass, live Doppler offset
- Passes: division by zero in progress calculation
- SatelliteRepo: concurrent calculatePasses race
- WaveLog: duplicate QSO submission, grid square update race

Release notes now include both the DeepCW fp32 migration and the 10 fixes.
2026-08-14 11:38:59 +00:00
mckero a757474b06 fix(radar): 过境结束后自动切换到下一个过境
根因:
collectPassAndStartTickLoop() 启动时调用 findCurrentPass() 获取当前过境,
之后进入 while(isActive) 循环每秒 tickPass(),但从不重新检查过境是否结束。
结果:过境 LOS 后雷达页面继续显示旧卫星位置(冻结在地平线),用户必须
手动返回过境列表重新点击下一个过境。

触发条件:
1. 用户在过境进行中打开雷达页面
2. 过境结束时用户仍停留在雷达页面
3. passes 列表中存在后续过境

修复:
每次 tick 开始时检查 timeNow > pass.losTime,如果过境已结束则调用
findCurrentPass() 查找下一个过境。findCurrentPass() 的三级回退逻辑
(精确匹配 → 时间窗口 → 同卫星 → 第一个)保证能拿到合理的下一个过境。
如果找到且与当前 pass 不同,则重新 loadPassData() 加载新过境的电台和轨迹。

影响:
雷达页面现在会无缝切换到下一个过境,用户体验接近实时卫星跟踪软件。
如果 passes 列表为空(所有过境都结束),页面保持最后状态不崩溃。
2026-08-14 11:24:01 +00:00
mckero 9cfa238e5c fix(passes): 防止过境进度计算时除零崩溃
根因:
PassesViewModel.updateProgress() 计算进度时用 deltaNow / deltaTotal,
如果 TLE 损坏、轨道退化、或数据解析错误导致 losTime <= aosTime,
deltaTotal 为 0 或负数,除法触发 ArithmeticException 或产生 Infinity。

触发条件:
1. OMM/CSV 历元解析 bug(已在另一 commit 修复)导致时间错乱
2. 深空卫星 TLE 过期几十年,losTime 计算失败回退到 aosTime
3. 手动导入格式错误的 TLE

修复:
在除法前检查 deltaTotal <= 0f,跳过该过境的进度更新。
用户仍能看到过境列表,但异常过境不显示进度条(优雅降级)。

影响:
避免因单个异常 TLE 导致整个过境列表页面崩溃。
2026-08-14 11:22:54 +00:00
mckero 29ca4c29bb fix(data): 拒绝 calculatePasses 并发调用防止重复计算
根因:
SatelliteRepo.calculatePasses() 在耗时计算期间如果被重复调用,
会启动多个协程同时遍历卫星列表,导致:
1. 重复的 SGP4 轨道计算(CPU/电池浪费)
2. passes MutableStateFlow 被多次更新,触发下游 UI 重组风暴
3. _isCalculating 标志被后续调用覆盖,可能提前置 false

修复:
在 _isCalculating.value = true 之前检查当前值,如果已在计算中则
直接 return,拒绝并发调用。

影响:
防止用户快速切换过滤条件、旋转屏幕等场景下的重复计算。
_isCalculating 现在是真正的互斥信号量(简化版,无等待队列)。
2026-08-14 11:22:07 +00:00
mckero 82c8112575 fix(settings): RadioControlDialog 组合期间写状态
问题:Compose 运行时警告两次 'State write during composition' (split mode 重置 + baud rate 调整)。
根因:splitMode/baudRate 的条件写操作在组合 body 内直接执行 (if 块)。
修复:用 LaunchedEffect 隔离副作用,避免组合期间修改状态。
影响:消除运行时警告,避免潜在的组合跳帧或死循环。
2026-08-14 09:33:53 +00:00
mckero 20e5b2f617 fix(roaming): 权限授予后页面内刷新状态
问题:用户在页面内跳系统设置授予定位权限再回来,GPS 监听不启动(必须退出重进页面)。
根因:permissionLauncher 回调是空的 { },不更新 hasPermission 状态。
修复:回调里检查 FINE/COARSE 权限并更新 hasPermission,触发 DisposableEffect 启动 GPS 监听。
影响:用户授权后立即生效,无需退出重进。
2026-08-14 09:27:00 +00:00
mckero aa10d4170c fix(radar): SwipeDeleteRow 倒计时期间重新拖拽后归零偏移
问题:倒计时期间重新拖拽会取消 pending(正确),但 offsetX 仍是负值,行卡在滑开状态(要继续滑才能归位)。
根因:onDragStart 里只取消了 pending,没有归零 offsetX。
修复:onDragStart 取消 pending 的同时归零 offsetX。
影响:拖拽取消后行立即回到正常位置。
2026-08-14 09:24:26 +00:00
mckero aac3aee4bb fix(radar): QRZ 网格回填后刷新列表显示
问题:QRZ 爬虫异步回填网格后,列表里对应行不会立即显示网格(要等下次保存/删除操作才看到)。
根因:updateGridsquare 成功后没有触发 refreshTick++,UI 的 entries 列表不会重新计算。
修复:回填成功后调用 onSaved() 触发刷新。
影响:网格回填立即可见,改善用户体验。
2026-08-14 09:22:05 +00:00
mckero d749c5ab48 fix(domain): WavelogQueue.updateGridsquare 添加同步锁
问题:updateGridsquare 是唯一没有 @Synchronized 的修改方法,与 add() 并发(LogTab 主线程 add + 后台协程 updateGridsquare)会触发 read-modify-write 竞态,导致新 QSO 丢失。
修复:给 updateGridsquare 加 @Synchronized,与其他修改方法保持一致。
影响:消除数据丢失风险。
2026-08-14 09:19:41 +00:00
mckero 082e0c5bff fix(radar): 防止 WaveLog 重复提交 QSO
问题:键盘 onDone 连击会存两条相同的 QSO(时间戳/呼号/频率完全一致)。
修复:添加 submitting 状态变量,提交期间拒绝重复调用。
影响:误操作不再造成重复记录。
2026-08-14 09:14:47 +00:00
mckero e69d6dee4a fix(radar): 修复 offset slider 拖动时多普勒计算用旧值
问题:onValueChange 回调里 offsetHz 是派生状态的旧快照,拖动时计算的频率滞后一帧。
修复:在回调内立即从 newVal 计算 newOffsetHz,传给多普勒计算器。
影响:拖动 offset 时频率显示实时正确。
2026-08-14 09:11:45 +00:00
mckero cf93ca9f71 fix(ui): 修复菜单布局的三个严重 bug
Bug #1: moveToMain 误驱逐页面
- 根因:每次调用 moveToMain 都执行驱逐逻辑,即使页面本来就在主菜单
- 场景:拖拽主菜单内部顺序 → SettingsViewModel 遍历新顺序逐个调 moveToMain
  → 每次都判断 main.size > 5 → 误驱逐最后一个页面
- 修复:只在真正从 More 移到主菜单时才驱逐(加 wasInMore 标志位)

Bug #2: onReorder 传 resolve 输出污染状态
- 根因:SettingsScreen 拖拽回调传的是 resolve 输出(mainItems/subItems)
  而不是持久化输入(screenOrder/subMenuOrder)
- 场景:拖拽主菜单 → onReorder 传 [Radar, ..., Settings](完整列表)
  → Settings 被显式存入 screenOrder → 下次 resolve 当作用户手动放置 → 参与驱逐逻辑
- 修复:
  1. 拖拽主菜单时过滤掉 Settings(锁定页面用默认位置,不存持久化)
  2. 另一个菜单用输入的 screenOrder/subMenuOrder,不用 resolve 输出

Bug #3: onReorder More 菜单传错参数
- 根因:拖拽 More 菜单时传的是 mainItems(resolve 输出),不是 screenOrder
- 修复:改用输入的 screenOrder

影响:修复前,拖拽主菜单会丢页面,移页面到主菜单可能导致 Settings 消失
2026-08-14 00:43:23 +00:00
mckero 7cadded6ca fix(data): compute the OMM epoch day fraction numerically, not by string surgery
排查页面顺序问题时顺带审计发现: OMM/CSV 历元在子夜后约 86 秒内会被解析成
完全错误的值, 且不抛异常 —— 静默的错误数据。

## 根因

DataParser.parseCSV 用字符串拼接构造历元:

    val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
    val epoch = "${year.substring(2)}$day$frac".toDouble()

substring(1) 的意图是切掉 "0.123" 的前导 0。但 Double.toString() 在数值
小于 1e-3 时切换为科学计数法, 于是被切掉的是【有效数字】, 剩下的指数后缀
让整个字符串重新变成一个合法但语义完全错误的 double。

Kotlin 侧实测(单测失败信息):

    00:00:01.000  期望 25001.000011574073  实得 0.2500115740740741
    00:01:00.000  期望 25001.000694444443  实得 2.5001944444444444

与 JDK 侧独立验证逐位一致。00:01:26.4 之后 frac >= 0.001, 不再用科学计数法,
所以这个 bug 只在每天前 86.4 秒的历元上出现(约占 0.1%), Celestrak OMM 数据
里整分历元并不罕见。

## 影响

runCatching 抓不到(没有异常), 该卫星的 juliandDateOfEpoch 会推出 year=2000
day≈0, tsince 偏差约 26 年 —— 方位/仰角/过境预报彻底失效, 不是精度下降。
且用户无从察觉。

## 修复

改为数值相加, 不经过字符串:

    val dayFraction = (hour + min + sec + ms) / 86400000.0
    val epoch = "${year.substring(2)}$day".toDouble() + dayFraction

"25001".toDouble() + 0.0000115 = 25001.0000115, 无科学计数法风险。

## 验证

DataParserTest 新增 5 个历元回归测试(子夜整点/子夜后 1 秒/子夜后 1 分钟/
正午/当日最后一毫秒)。先确认前两个在旧实现下失败(failures=2), 修复后:

- DataParserTest 24 个测试全绿(原有 19 个无回归)
- :core:domain:test 全量 105 个测试 0 失败 0 错误
2026-08-13 16:20:07 +00:00
mckero 6fc2f560b7 fix(nav): resolve the menu layout in core:domain so page order actually applies
用户报告"把 AMSAT 从更多菜单移到主菜单没生效"。排查后发现这是两个互相
掩盖的 bug, 其中一个会让用户永久无法进入设置页。

## Bug 1 (致命): 移任意页进主菜单 -> 设置入口从所有菜单消失

对"主菜单上限 5"的定义两处不一致:
- SettingsScreen.kt:892 判断 mainItems.filter { it != "Settings" }.size >= 5,
  上限是【不含 Settings 的 5 个】, 于是认为 [Satellites,Passes,Radar,Map]
  还有空位, 直接追加 -> 共 6 项
- MainScreen.kt:165 的 .take(5) 上限是【含 Settings 的前 5 个】, 而 Settings
  排在最后 -> 被截断丢弃

结果 Settings 既不在底栏也不在更多菜单(它不在 subMenuOrder 里), 用户再也
进不去设置页, 无法自行改回, 只能清数据或重装。

## Bug 2 (用户报告): AMSAT / WavelogLog 移到主菜单被静默撤销

MainScreen.kt:161-163 给老用户补新页面的迁移逻辑【无条件执行】:

    .let { list -> if ("AMSAT" in list) list else list + "AMSAT" }

用户把 AMSAT 移出子菜单后 subMenuOrder 里没有它, 这段又加回去, 第 165 行
filter { it.screenId !in subOrder } 于是永远过滤掉 AMSAT。

副作用: 这个 bug 恰好把主菜单拉回 5 项内, 反而掩盖了 Bug 1 —— 所以用户只
看到"AMSAT 移不过去", 没触发"设置锁死"。

## Bug 3: 溢出页面凭空消失而非落入更多菜单

.take(5) 直接丢弃超出的页面, 它们既不在底栏也不在更多菜单。

## Bug 4: 设置页展示顺序与底栏实际顺序不一致 (WYSIWYG 失效)

两处各自实现排序: 设置页不做 take(5)、用 sortedWith 把 Settings 钉最后;
MainScreen 做 take(5)、Settings 位置由 screenOrder 决定。

## Bug 5: 更多菜单里 AMSAT / Roaming 当前页不高亮

MoreMenuPopup.kt:69-79 的 when (currentKey) 缺 AmSat 与 Roaming 分支,
MainScreen.kt:188-198 缺 Roaming 分支, 靠 else -> false 兜底, 新增页面时
不会有编译错误提醒。Screen 子类都是 data object, 直接用 currentKey == screen
即可, 新增页面自动生效。

## Bug 6: 设置页主菜单区不过滤 hiddenScreens

MainScreen 会过滤隐藏页, 设置页不过滤, 于是隐藏的页面仍占据设置页的位置且
"移出主菜单"按钮可点, 与底栏实际情况不符。

## 改动

新增 core/domain/navigation/MenuLayout.kt (纯 Kotlin, 为 KMP 就绪) 作为菜单
布局的唯一入口:
- resolve(): 持久化偏好 -> 底栏 + 更多菜单。先给 Settings 预留名额再截断,
  溢出页面落入更多菜单而非丢弃; 两个列表都没提到的页面按默认归位(升级不丢页)
- moveToMain() / moveToMore(): 移动语义集中一处, 拒绝把 Settings 移出

MainScreen 与 SettingsScreen 改为共用它, 删除双份实现与无条件迁移逻辑。

## 死代码清理 (每项已 grep 全仓库确认零引用)

- SettingsAction.ResetScreenOrder: 无任何派发点, 仅定义与 when 分支
- UiSettingsCard 的 onReorder 参数: 函数体内两个 DragOrderList 的 onReorder
  实参都调 onUpdateMenu, 从未调用该参数
- SettingsAction.ReorderScreens: 唯一引用是上面那个死参数
- MainScreen 的 navigateToRadar 参数: 函数体内唯一出现是注释掉的一行
- Navigation.kt 的 defaultScreenOrder / defaultSubMenuOrder: 迁移后零引用,
  默认值已在 MenuLayout 内

保留 entry<RadarDestination> 分支不动 —— RadarDestination NavKey 被
PassDetailsMatcher deeplink 使用。

## 验证

- MenuLayoutTest 13 个新测试全绿, 每个对应上面一个 bug 场景
- :core:domain:test 全量 100 个测试 0 失败 0 错误
  (DataParser 19 / Doppler 17 / Qth 8 / Transponder 7 / CwCtc 7 /
   CwDeepBuffer 13 / CwGolden 3 / CwSpectrogram 8 / MenuLayout 13 /
   WaveLogApi 5)
- :core:domain:compileKotlin + :core:presentation + :app +
  :feature:settings compileDebugKotlin => BUILD SUCCESSFUL
- 用 Python 复刻新规则重跑当初失败的全部场景: 5 个页面逐一移入主菜单,
  设置入口全部保住、页面无丢失; 隐藏页 + 移动组合无页面丢失; 幂等性通过
2026-08-13 16:10:41 +00:00
mckero 8adf861ed7 chore(release): refresh 4.5.7 whats-new and bump versionCode to 461
版本名保持 4.5.7 不变(用户要求覆盖同一个发行版, APK 文件名仍为
Look4Sat-Pro-4.5.7.apk)。

versionCode 460 -> 461: 手机上已安装 versionCode 460 的 4.5.7, 若 code
不变则覆盖安装会被系统拒绝。versionCode 对用户不可见。

pass_whatsnew_message 五语(en/zh/tr/in/id)补全本次全部改动, 之前只写了
DeepCW 那批, 本轮 UI 改动与 fp32 模型未包含:
- 内置完整版 fp32 模型
- 更多菜单改靠右窄面板
- CW 页移除无效返回键与误导性状态提示
2026-08-13 14:49:36 +00:00
mckero f4f6ec7db5 feat(cw): ship the full fp32 DeepCW model instead of the int8 build
用户要求内置完整版模型, 不要量化版。

assets/deepcw/model.onnx: 4,354,478 bytes (int8) -> 15,139,839 bytes (fp32)
sha256 ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02,
与上游 commit 8e264d2 发布的原始文件逐字节一致, 零修改。

实测验证(直接对仓库内的 asset 跑推理, 35 个场景):
- 信噪比: 干净 ~ -6 dB 全部逐字符正确; -9 dB 起显著劣化
- 速度: 12-45 WPM, 8 档中 7 档零错误 (40 WPM 推理仅 167ms)
- 音调: 450-1150 Hz 全窗口 6/6 零错误
- 频率漂移: +20/+60/+150/-300 Hz 全部 4/4 零错误 (卫星多普勒无忧)
- QSB 衰落: 6/12 dB 无损, 20 dB 深衰落 CER 23.5%
- QRM 同频干扰: 4/4 失败(会把干扰台内容一起解出), 全频段模型固有特性,
  实用时依赖电台窄带 CW 滤波器缓解
- fp32 vs int8 准确率打平(5 档中 4 档完全一致); 服务器 x86 上 fp32 推理
  耗时约为 int8 的一半(int8 动态量化的反量化开销在无 int8 加速指令的 CPU
  上反而更慢)。手机 ARM 侧表现待装机确认。

NOTICE.md / DEEPCW.md / README.md 同步更新: 移除 int8 量化派生的记录与复现
步骤, 改为声明未修改照搬上游。

代价: APK 体积约 59MB -> 70MB, 运行内存峰值上升。此前真机闪退的根因是 R8
缺 -keep ai.onnxruntime.** 规则(已修), 与模型大小无关。
2026-08-13 14:48:56 +00:00
mckero 5d89190348 ui: slim the More menu and drop dead controls from the CW page
用户反馈三处 UI 问题, 一并处理。

1. 更多菜单风格不符 + 遮盖感重
   - 去掉全屏 scrim 遮罩(0.35 alpha 压暗整页), 改为透明点击层, 点外部仍可关闭
   - Card 限宽 232dp 靠右下角, 从"全宽卡片"变成竖长条
   - 容器色 surfaceContainerHigh -> surfaceContainer, 与导航栏一致; 加 1dp 细边框
   - 动画从全屏 expandVertically + spring 弹跳改为右下角原点 150ms scaleIn
   - Card 加 clickable(enabled=false) 吞掉点击, 避免点卡片空白区误触关闭

2. CW 页左上角退出键点击无效 -> 删除
   根因: CwDecodeScreen(navigateUp: () -> Unit = {}) 是默认空实现, 而
   MainScreen 的 entry<Screen.CwDecode> 调用 CwDecodeScreen() 从未传入
   navigateUp, 所以点击必然无反应。按 AGENTS.md 无死代码原则删除按钮 +
   navigateUp 参数 + cw_back 字符串(五语)。

3. CW 页"正在监听"状态行在停止解码后仍显示 -> 删除
   estimatedPitch 在暂停后保留上次值, 状态行不会消失, 属误导。删除该 Text
   后 estimatedPitch / lastInferenceMs 两个 collector 成为死代码, 一并清理;
   cw_status_listening / cw_status_tone 字符串(五语)同步删除。
   signalStrength(瀑布图) 与 errorMessage(错误提示) 仍在用, 保留。

验证:
- :app:compileDebugKotlin + :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
- :core:domain:test => 31 个 CW 测试全绿 (7+13+3+8)
2026-08-13 14:48:08 +00:00
mckero f9fd7a2cfa Move the LICENSING NOTICE to a separate NOTICE file so GitHub detects AGPL-3.0
GitHub 的 license 检测器(licensee gem)要求 LICENSE 文件是纯许可证原文,
顶部加说明块会导致检测失败(显示 NOASSERTION/Other)。

根 LICENSE 恢复为纯 AGPL-3.0 原文(从 DeepCW-AGPL-3.0.txt 复制),
说明块移到根目录 NOTICE 文件(GitHub 也识别 NOTICE 文件)。
2026-08-13 13:40:45 +00:00
mckero 21f14848da Merge origin/main (resolve CW fldigi vs DeepCW conflicts, keep DeepCW) 2026-08-13 13:39:13 +00:00
mckero d1668f1c27 docs(cw): update in-app "what's new" for the DeepCW release
发版漏的一步: App 内检测到新版本时弹出的更新内容
(pass_whatsnew_message) 还停留在上一版 AMSAT/WaveLog 的说明,没写进本次
DeepCW CW 解码器的改动。

补上五语 (en/zh/tr/in/id) 的更新说明:
- DeepCW 神经网络 CW 解码 (弱信号解码率大幅提升)
- CW 解码历史永久保留
- CW 瀑布图 inferno 配色 + 平滑渐变
- 恢复 64 位 (arm64)
- 暂停/恢复误报修复
2026-08-13 10:20:30 +00:00
mckero d6b61eaa4b license: switch the root LICENSE to AGPL-3.0 for the combined work
用户指出 GitHub 上显示的许可证仍是 GPL-3.0,未反映合并 AGPL 组件的事实。

根因: 之前只在 README 加了 License 章节,根 LICENSE 文件仍是 GPL-3.0 原文,
GitHub 的自动检测(只扫 LICENSE 文件)自然还是 GPL-3.0。

修复: 合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理,根 LICENSE 改为:
- 顶部 LICENSING NOTICE 说明构成 (Look4Sat 代码 GPL-3.0 + DeepCW 模型
  AGPL-3.0-only, 合并作品按 AGPL-3.0 分发)
- 正文为 AGPL-3.0 原文 (从 DeepCW-AGPL-3.0.txt 复制)

原 GPL-3.0 原文保留在 feature/cw/licenses/Look4Sat-GPL-3.0.txt。
README License 章节同步更新,与新 LICENSE 一致。

此后 GitHub 侧边栏将识别为 AGPL-3.0。
2026-08-13 10:19:57 +00:00
mckero 26c714fc24 fix(cw): stop swallowing coroutine cancellation on pause/resume
用户反馈: 暂停再恢复时经常弹出 "CW decode failed: The coroutine scope
left the composition"。

根因: 暂停 (isListening=false) 使 LaunchedEffect 重启、旧的采集协程被
取消。若此刻 decodeWindow 正在 withContext(Dispatchers.Default) 里做 ONNX
推理, 取消传播时会抛出 LeftCompositionCancellationException
(message 即 "The coroutine scope left the composition", 是 CancellationException
的子类)。processBuffer 的 catch (Throwable) 把它当成解码失败吞掉, 既误报了
错误横幅, 又破坏了协程取消的正常传播。

修复:
- processBuffer 的 decodeWindow catch 里, CancellationException 直接
  rethrow (暂停导致的中断是正常流程, 不是错误)。
- archiveDecode 调用同样包 try-catch, CancellationException rethrow,
  其余异常只记日志不崩溃。

这是协程的标准纪律: 永远不要把 CancellationException 当业务异常吞掉。

版本: 4.5.5 -> 4.5.7 (versionCode 460)。此前多次删 v4.5.5 tag 重打导致
release 页面累积 12 个 draft 草稿, 已全部清除。
2026-08-13 09:55:15 +00:00
mckero 2e1d8b9c00 feat(cw): archive decoded history, polish the waterfall, document licensing
三个用户反馈一并解决:

1. 解码文字不再消失 (核心)
   旧实现: 20 秒环形缓冲满了就静默覆盖最旧样本, 文字随之从屏幕消失。
   新实现: CwDeepBuffer 新增 overflow —— 满时被覆盖的旧样本先进 overflow,
   解码器累积到 15 秒就单独解码一次, 结果追加到 historyText (只增不减)。
   UI 记录区显示 historyText + decodedText (历史稳定 + 当前窗口实时)。
   ICwDecoder 接口新增 historyText StateFlow。

   归档音频已离开主窗口, 不再被 CTC 修正, 故其文本是"最终版", 追加安全。
   归档窗口 15 秒: 内容已在 20 秒窗口里解过多次, 短一点几乎无损, 且推理
   开销小。

2. 瀑布图更好看
   配色从"深蓝->青->黄"换成 matplotlib inferno (黑->紫->品红->橙->黄),
   与静态频谱图保持一致。相邻 bin 之间用水平渐变做线性插值, 消除 65 列
   离散方块的像素感。

3. AGPL 合规补漏 (用户提醒: 仓库许可证没体现 AGPL 组件)
   README 新增 License 章节: 声明项目主体 GPL-3.0 + feature/cw 的 DeepCW
   模型 AGPL-3.0-only, 并说明合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理。

验证:
- :core:domain:test => 31 个 CW 测试全绿 (CwDeepBufferTest 新增 3 个
  overflow 归档测试: 顺序/清空/reset)
- :core:domain:compileKotlin + :core:data + :feature:cw:compileDebugKotlin
  => BUILD SUCCESSFUL
2026-08-13 08:04:05 +00:00
mckero f42d1f6c57 docs(cw): add DEEPCW.md recording DeepCW architecture, pitfalls, verification
记录 feature:cw 模块的完整集成知识, 供后续维护者免于重走弯路:
- 架构分层 (core:domain 纯 Kotlin 前后处理 / core:data ONNX 推理 / feature:cw UI)
- 模型规格与 int8/fp32 双版本取舍 (APK 内置 int8, fp32 作为 release 资产)
- 真机 release-only 的五个坑 (R8 keep / 惰性加载 / 线程 / 跨线程状态 / 双录音)
- 验证结果 (golden vector 79 测试 / 服务器端到端逐字符一致 / 真机 40WPM)

该文档与 skill 的 §7.55 根因坑互为参照: skill 记方法论, 这里记本模块实况。
2026-08-13 04:14:01 +00:00
mckero 3e25e1aa2f fix(cw): keep ai.onnxruntime.** through R8 to stop native crash
根因定位(确定性): release 构建开启 R8 混淆 (convention 插件的
PluginSetupUtils 设 isMinifyEnabled=true), 但 onnxruntime-android 的 AAR
不含 proguard consumer rules (仅 aar-metadata.properties)。于是 R8 把
ai.onnxruntime.* 的类重命名/裁剪, 而 ONNX Runtime 的 Java 绑定走 JNI、
native 侧按原始类名/方法名解析 —— 类名对不上即 native 崩溃, 不产生
任何 Java 堆栈, 正好解释"闪退回桌面、无任何日志"。

此前的所有内存/线程修复都无效, 因为崩溃点根本不在 Java 层。

修复:
1. app/proguard-rules.pro 新增 (ONNX Runtime 官方文档要求):
   -keep class ai.onnxruntime.** { *; }
   -dontwarn ai.onnxruntime.**
2. app/build.gradle.kts 的 release buildType 引用该规则文件。
3. onnxruntime 1.28.0 -> 1.25.1: 1.28.0 为 2026-07-25 发布(仅三周),
   1.25.1 更成熟; 同时排除 SIGILL 类骁龙 bug (该 bug 在 1.24.4 修复,
   Android AAR 对应 1.25.0+)。

验证: 服务器完整管线 (真实20s音频->频谱->ONNX->CTC) 解码逐字符正确,
与手机端同一份代码逻辑。R8 keep 规则为官方文档明确要求项。
2026-08-13 03:46:14 +00:00
mckero e0405b541f fix(cw): prevent double AudioRecord when toggling capture
CwDecodeScreen 的 LaunchedEffect 原先用内层 launch{} 启动音频采集。
isListening 在权限授予后由 false->true->(授予回调再次置 true), 加上
LaunchedEffect(isListening, permissionGranted) 的双 key 重启, 会造成
前一个 audioFlow().collect 尚未取消、新的又启动 —— 两个 AudioRecord
同时录音, 内存翻倍、音频混叠, 是低内存机 OOM 崩溃的实锤级嫌疑。

改为在 LaunchedEffect 协程体内直接 collect (取消时随父协程及时停止),
并保持 (isListening, permissionGranted) 双 key 以正确处理权限授予后
需要重启采集的情形。

同时清理不再使用的 kotlinx.coroutines.launch import。

验证: :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-13 03:16:36 +00:00
mckero c374058e1d perf(cw): ship int8-quantized DeepCW model (15MB -> 4MB)
用户真机闪退回桌面且无任何 Java 日志 => 高度怀疑进程被系统 LMK 杀掉
(内存不足) 或 native 层崩溃。模型加载 + ONNX 引擎初始化存在瞬时内存峰值,
fp32 模型 15MB 会放大这个峰值。

用 onnxruntime.quantization.quantize_dynamic 把模型权重动态量化为 QUInt8
(激活保持 float32):

- 体积: 15,139,839 -> 4,248,808 字节 (缩小 71%)
- 输入/输出名、形状、dtype 完全不变 -> Kotlin 代码无需改动
- 实测 CER (合成 CW 音频, fp32 vs int8 逐字符对比):
  SNR>=0dB 完全一致; -4dB 起两者一同劣化, 无显著差异
- onnx.checker 校验通过

AGPL 合规: NOTICE.md 记录派生信息 (原文件 SHA/量化后 SHA/转换步骤),
量化属模型修改, 按 AGPL 要求如实声明。复现命令已更新。

验证: :core:domain:test => 79 tests 全绿 (golden vector 测试锁定的是
频谱图前处理, 与模型文件无关)
2026-08-13 01:02:04 +00:00