Commit Graph
1135 Commits
Author SHA1 Message Date
mckero 3810e76ea2 fix(cw): the archive path raced itself when capture stopped
flush() runs on a different coroutine from processBuffer - on pause from the screen's
own effect, and from the app scope as the screen leaves - so both were appending to
committedText concurrently. That append is a read, an inference lasting hundreds of
milliseconds, and only then a write, so the window for interleaving is the whole
inference: the later write wins and an entire batch of text is gone. Modelled over 40
trials the unlocked version lost 160 characters, averaging a full batch each time.

Worse, both paths touch archiveBuffer and archiveSize. flush() zeroes the index after
copying out a snapshot; the capture coroutine then writes from zero into slots that
snapshot already covered, so the same audio decodes twice and the text appears twice.

Both failures land at the moment the operator stops listening and starts reading,
which is the worst possible time for the record to be wrong.

archiveLock now serialises the archive path. It is a separate mutex from
inferenceLock, which is a tryLock that drops work when contended - right for the live
window, where another decode is 1.5 s away, and wrong here, where dropping a batch
discards the audio for good. Mutex is not reentrant, so archiveDecode is split into a
locking shell and archiveDecodeLocked for callers already holding it.

reset() is left unsynchronised and now says so: an archive decode in flight can land
after it returns, leaving a few characters behind. Making it suspend to close that
window would push suspension onto every caller including a button handler, and the
operator who asked to clear can ask again.
2026-08-27 15:09:24 +00:00
mckero 5ed76dba31 fix(cw): the record deleted its own text while decoding
The record pane concatenated the live decode onto the archived text. The live decode
is the 20 s window, replaced wholesale every 1.5 s because DeepCW is a whole-segment
CTC model that rewrites earlier characters as more context arrives. So the tail of
the record kept changing and could get shorter - text vanishing from under the
operator while the decoder was still running.

A previous attempt (828fd0fb) added decodePending() to cover the gap while audio
waits to be archived, but the call site was never wired up. The function had no
callers and pendingText was only ever cleared, never assigned, so that fix has never
once run and the gap it targeted stayed open. Both are deleted here.

The record now binds to archived text only, which is append-only, so it cannot
shrink. That moves the whole problem to latency, which was 20 s window + 15 s batch:
nothing at all in the record for the first 35 s of a session, and thereafter a stall
of up to 15 s each cycle. The batch is now 4 s, holding the stall under the ~4.7 s a
seven-character call sign takes at 18 WPM, while the archive path still fires less
than half as often as the live redecode.

Neither holding place drains on its own: the live window only reaches the archive by
being pushed out by newer audio, and the pending batch only by filling up. So pausing
or leaving the screen discarded whatever was in flight - the end of every
transmission, the part with the call sign in it. flush() archives both, pending batch
first so the text is not transposed, and is called on pause and before close(). On
the way out it runs on appScope, because the screen's own scope is cancelled as it
leaves and would abort the decode.

Also: the record was an unlabelled grey box showing a bare ellipsis, which reads as a
disabled text field. It now has a label, an empty state that says what it is for, and
a copy button - until now there was no way to get the decoded text off the screen at
all, so an operator who had just copied a call sign by ear had to transcribe it a
second time by hand.

CwArchiveTimingTest covers the timing against the real constants rather than copies;
it caught a 5 s batch exceeding the call-sign bound during this change. The archive
path had no test coverage before.
2026-08-27 15:01:04 +00:00
mckero 78a6f270bf fix(cw): filter before decimating, so high tones stop smearing across the band
The operator reported that any tone leaked across the whole display - "even 3 kHz spreads
over the entire band, like taking a piss". The tone shifter was the suspect, since it had
been changed recently. It turned out to be innocent: the audio reaching it was already
ruined.

Capture runs at 44100 Hz and the model needs 3200 Hz, so resampleLinear decimates by a
factor of nearly 14. It interpolates between samples and nothing removes the content above
the new Nyquist of 1600 Hz first, which is the one thing decimation cannot skip. Measured on
44100 Hz input:

    3000 Hz tone  ->  ghost at  200 Hz, 119x the spectral mean
    2400 Hz tone  ->  ghost at  800 Hz
    1800 Hz tone  ->  ghost at 1400 Hz
    5000 Hz tone  ->  ghost at 1400 Hz

Each ghost is as strong as a real signal, so a tone nothing is transmitting on looks
entirely convincing. Worse for actually copying anything: the whole 1600-22050 Hz band of
hiss folds down on top of the signal and lifts the noise floor across the display. That is
the smearing.

CwAntiAlias is a 127-tap windowed-sinc low-pass, Blackman-windowed because sidelobe level is
what decides how much of the folded band survives, cut off at 92% of the target Nyquist so
the transition lands inside the discarded region. Measured suppression at the fold
frequency: 2400 Hz down 69 dB, 3000 Hz down 81 dB, 5000 Hz down 96 dB. 1800 Hz only makes
16 dB - it sits just past the 1472 Hz cut-off and 127 taps cannot be steeper without costing
more time than a phone has during a pass. The tests assert the measured numbers rather than
the ones I hoped for.

resampleLinear itself is untouched. Its comment notes it matches the reference implementation
DeepCW was trained against, so changing its arithmetic would move the spectrogram away from
what the model expects.

The streaming path holds output back by the group delay. A first attempt let the lookahead
taps read zeros at the end of each chunk, which diverged from whole-buffer filtering by
0.134 across the last 44 samples of every chunk - a click at each boundary. Holding output
back makes the two identical to within 1e-8. The cost is 63 samples, 1.4 ms, against a 20 WPM
dot of about 60 ms.

Both the decoder and the waterfall filter now. The waterfall mattered as much as the
decoder: it was showing the folded spectrum, which is what the operator was looking at.
2026-08-27 14:03:30 +00:00
mckero 8445c17033 fix: remove the receive-only notice, and stop the CW record scrolling itself
Two things the operator asked for after running 4.6.1.

The receive-only notice named a state this app does not have. APRS-IS lets an unverified
station connect and then discards its packets, which is what "receive-only" means at the
protocol level - but this app only reports its own position. There is no receiving side to
it, and none intended, so telling the operator they are in receive-only mode described a
mode that does not exist here. Without a passcode the packet does not arrive, and the
unverified notice already says exactly that. The string is gone from all five locales,
along with the AprsReport.receiveOnly field, which had no remaining consumer.

AprsPasscode.classify stays: loginValue still uses it, and its tests hold the distinction
between a deliberate -1 and a typo, which is a separate defect worth keeping fixed.

The CW history pane no longer follows the decode. Its whole purpose is to be read back, and
a record that scrolls itself is worse than paper - as the operator put it, if it scrolls
away then why use a decoder instead of listening and writing it down, since paper does not
erase itself. The single line above it is where new characters appear; that still scrolls,
because that is its job. A down arrow in the toolbar jumps to the newest text when wanted.

Not fixed here: logged times in the log page look wrong and inconsistent. I proposed a
timezone explanation and wrote a probe, and the probe disproved it - on a real JVM both the
session header and the row times are stable and both resolve to local time. That reverted
attempt is not in this commit. The cause is still unknown.
2026-08-27 07:31:20 +00:00
mckero e315c87f05 build: bump to 4.6.1 (versionCode 468)
34 commits since 4.6.0, 56 files, +4902/-365. Three areas that were diagnosed as broken and
rebuilt: APRS beaconing, WaveLog upload, and the satellite data source URLs.

The one that mattered most: 4.6.0 reported every APRS beacon as sent regardless of outcome,
so nobody running it could tell whether their station had ever reached the network. That is
fixed, and the login and refusal paths are verified against live APRS-IS servers - the old
login line was malformed and euro.aprs2.net, noam.aprs2.net and rotate.aprs2.net all refused
it, which the app read as success.

WaveLog uploads now read the reply body. A rejected contact used to be marked uploaded and
dropped from the queue, so the contact was lost while the screen said it went up.

In-app release notes updated in the five locales that carry them. Turkish, Indonesian and
Malay get the English text rather than the previous version's notes, which would otherwise
describe the wrong release.

What is NOT verified: no packet from this build has been confirmed on aprs.fi, and nothing
about the foreground service, the Doze-proof alarm or any composable has been executed on a
device - there is no emulator here. The transmit path needs a licensed callsign and a real
passcode. A six-step checklist for that is in
.hermes/plans/2026-08-26_aprs-verification-checklist.md.
v4.6.1
2026-08-27 01:00:24 +00:00
mckero e27d692e8f fix(log): the grid check let non-ASCII digits through and refused a legal length
Checked against ADIF 3.1.7 (2026-03-22, the current release) rather than my own reading. Two
of my rules were wrong.

Char.isDigit() is Unicode-aware and covers the whole Nd category - some 600 characters. So
Arabic-Indic, Devanagari, Persian and fullwidth digits all passed as a square pair, which a
localised keypad produces without the operator seeing any difference. The spec is explicit:
"Digit - an ASCII character whose code lies in the range of 48 through 57, inclusive."
Wavelog stores GRIDSQUARE verbatim, so such a value would never match a real grid in any
statistics or VUCC query - the exact failure this validation exists to prevent.

Two-character locators are legal. The GridSquare type is "a case-insensitive 2-character,
4-character, 6-character, or 8-character Maidenhead locator" and the GRIDSQUARE field
description repeats all four. My comment claimed Maidenhead had no other lengths, and a test
name asserted there was no two-character form. Both were wrong. It is accepted now with a
note that a field is accurate to about 1000km - the same treatment four characters already
had. That also uncovered a latent crash: the square-pair check read index 2 of a string that
may only have two characters.

What survived the check: the A-R field range is right, verified by replicating
qthToPosition's arithmetic - SS12AA decodes to 92N 182E, past both the pole and the
antimeridian, while RR99 is the last cell inside the world. Wavelog's own Qra.php validates
with the same range. The subsquare A-X range and digits in positions 7-8 are also correct.

On 10 and 12 character locators the spec says store the first 8 in GRIDSQUARE and the rest in
GRIDSQUARE_EXT. Neither WavelogQso nor Wavelog's field list carries GRIDSQUARE_EXT, so the
extra pair has nowhere to go; the field clips at 8, which produces the spec-correct GRIDSQUARE
value. Recorded in a comment rather than pretended to be deliberate.

17 tests now, including the four non-ASCII digit families and the two-character boundary.
2026-08-26 12:54:51 +00:00
mckero 6c67aa2718 feat(log): check a typed grid before it reaches the log
The grid field accepted anything six characters long, so "ZZ99ZZ", "123456" and a callsign
all reached WavelogQso.gridsquare and then the ADIF GRIDSQUARE field. Wavelog stores what
arrives, and a wrong square is worse than a missing one: it pollutes grid statistics and VUCC
tracking, where the error is invisible until an award check disagrees with the log.

GridEntry follows the rule the callsign field settled on - refuse only what is certainly
wrong. It rejects a length Maidenhead does not have, a field pair past R (S-X decodes beyond
the poles, which is how a plausible entry produces an impossible position), a square pair
that is not digits, and a subsquare past X. Everything else is accepted.

Four characters is accepted with a note that it is only accurate to about 100km, because
plenty of satellite operators exchange only the square and refusing that would reject good
data. The app's own isValidLocator could not be reused: it requires six characters and is
private.

Two things the field does better now. It takes eight characters rather than six, since the
extended form exists and truncating it would silently move the location. And case is
normalised on commit rather than while typing, so the cursor no longer jumps mid-entry - the
logged value is OL72ap, the conventional rendering, whatever was typed.

14 tests, including a cross-check that anything accepted at six characters or more also
decodes through the app's own qthToPosition. Without that the two would be free to disagree
about what a grid is.
2026-08-26 11:32:49 +00:00
mckero cb3ebe7870 feat(log): the counterpart grid can be typed
On FM satellites the grid is the exchange - it is what the other station sends you and what
you send back. Until now it could only arrive by scraping QRZ, which needs a cookie the
operator may not have pasted, and which returns nothing at all for a station with no locator
on file. So the field that carries the actual content of an FM contact was the one field the
operator could not fill in.

It is the second field, optional, six characters, uppercased. A typed grid also skips the
QRZ lookup entirely rather than racing it: what the operator heard on the air beats what a
web page says, and letting the scrape overwrite it would silently replace good data with a
guess.

The value is captured before the field clears, so the QSO carries it and the next contact
starts empty. WavelogQso.gridsquare already existed for the scraper to fill, so nothing about
the stored shape changes and no migration is needed.

This was the interaction study's second-ranked conclusion, after the editable time. Both come
from the same observation: the screen was built for someone typing during a pass, and the
operators it is for are working the radio instead.
2026-08-26 10:38:24 +00:00
mckero e2263668ce fix(log): the table grid was nearly invisible and the sent column vanished at night
Three contrast defects, each measured with a WCAG relative-luminance probe rather than
eyeballed.

GridLineColor was 0xFF3A3A3A: 1.65:1 against the navBar background and 1.36:1 against a
card, where Material asks 3:1 for non-text elements. Every rule and column separator on the
Log page is drawn with it, so the grid the page is built around was barely there - and gone
in sunlight. 0xFF6D6D6D is the lowest grey clearing 3:1 against both (3.62:1 and 3.00:1).

The upload column was a green tick and nothing else. This app applies a night filter that
zeroes green and blue, under which CheckGreen computes to 1.17:1 - the column disappeared
entirely. Changing the colour does not fix it, because colour was also the only thing
separating sent from waiting, which is the case Material calls out directly. The cell now
reads OK or an ellipsis, so the state survives both the filter and colour blindness, and a
contact that has not been tried is finally distinguishable from one that has.

The linear-transponder passband range was 11sp, below Material's body-small floor of 12sp.
That is the frequency an operator reads mid-pass to know where the transponder ends. The
session group header stays at 11sp: it is a label, not information.

Yellow survives the night filter at 4.69:1 because it is red-dominant, so the swipe and undo
affordances needed nothing here.
2026-08-26 10:25:39 +00:00
mckero 91263674a9 fix(log): a screen reader could not delete a contact at all
A Material 3 conformance audit measured three real defects on the logging screen.

Deleting was reachable only by dragging. SwipeDeleteRow declared no semantics, so TalkBack
saw a row of text with no actions - a switch or Voice Access user could not delete a record,
not with difficulty but at all. The arming threshold was 75% of row width, roughly 249dp of
continuous travel on a 360dp phone, against 120dp in this project's own SwipeableItem. Delete
and undo are now custom accessibility actions on the row, which is the case the Compose
accessibility guide names explicitly: swipe gestures should be exposed this way because they
are hard or impossible for users with motor impairments.

The undo affordance was a 29dp target with a five-second countdown running behind it - the
worst place in the screen to be hard to hit, because a miss is unrecoverable. Now 48dp by
64dp, matching the mode and time rows.

The trash glyph was the emoji U+1F5D1, which renders differently on every device and font
and which this project forbids as an icon. ic_delete.xml already existed and is used in three
other screens.

Not addressed, and worth recording from the same audit: the table grid line at 0xFF3A3A3A
computes to 1.65:1 against its background where Material asks 3:1, so the grid the Log page
is built around is nearly invisible and gone in sunlight; and under the app's night filter
the green upload tick collapses to 1.27:1 while being encoded in colour alone.
2026-08-26 07:13:43 +00:00
mckero 9576607fc6 fix(log): mark a held clock in words, not only in colour
Three corrections to the editable-time commit.

The held clock was distinguished only by colorScheme.primary. Material is explicit that
colour must not be the sole carrier of meaning, and roughly one man in twelve cannot
reliably separate that colour from the default text. Missing it costs every remaining
contact the wrong time and, for a pass across midnight UTC, the wrong day. The row now reads
"Held at 23:58" rather than just showing it in a different colour.

The comment on the state claimed rememberSaveable survives rotation but not process death.
Official documentation says the opposite: it goes through the saved instance state and does
survive system-initiated process death. A probe traced the one case that genuinely loses the
hold - the user swiping the app away - and not restoring it there is correct, since a clock
pressed hours ago would put the next session's contacts on the wrong day. The comment says
that now instead of something false.

MenuAnchorType is deprecated in favour of ExposedDropdownMenuAnchorType. Surfaced by a
subagent's build log rather than mine, because my grep filter was hiding warnings.
2026-08-26 06:43:53 +00:00
mckero 00b7b25cc1 feat(log): the contact time can be set, for transcribing after a pass
The screen stamped System.currentTimeMillis() with no way to change it, which assumes
contacts are typed as they happen. Serious satellite operators do not work that way: the
documented practice from AMSAT and DX Engineering is to record the pass and transcribe it
afterwards, because during eight minutes of a linear transponder there is no spare attention
for a keyboard.

Measured with a probe against a realistic pass - eight minutes, five contacts, twelve
minutes to transcribe: every contact was stamped 10 to 12 minutes late. LoTW wants both
sides within 30 minutes, so that survives a brisk transcription and fails a slow one.

The case that fails outright is a pass crossing midnight UTC. Transcribing 23:58 at 00:05
the next day put the contact a full 24 hours in the future, which can never be confirmed.
PassClock reads an absolute time later than now as belonging to the previous day, because
passes cross midnight routinely and transcription always happens afterwards.

One field takes both forms: an absolute UTC time (14:55 or 1455) or an offset (+3, -2m).
A separate widget for each is more to reach for than an operator wants while holding an
antenna. The parse is deliberately narrow - anything unclear is Unrecognised and the clock
stays put, because a mis-parsed time silently backdates a contact and nothing downstream
would catch it. The field says so while it is being typed rather than after committing.

A held clock is shown in the primary colour, since logging at the wrong time silently is the
failure this exists to prevent. The row is 48dp with Role.Button, like the mode row.

PassClock is pure and lives in core:domain with 15 tests. The day boundary is passed in
rather than computed there, because core:domain holds no calendar.
2026-08-26 06:32:34 +00:00
mckero a3e3932f73 fix(log): TalkBack could not find the mode row
The tappable mode row used a bare `clickable`, which declares no role. TalkBack read it as
two pieces of text with nothing to say it could be activated, so the only way to correct a
wrong mode was invisible to anyone using a screen reader - and the row had just become the
only way to reach that field.

Role.Button plus an onClickLabel naming the action. The label lives in the resource files
like every other user-visible string.

Caught by self-review against the project's own accessibility pattern in Components.kt
rather than by a test; Compose UI is not unit-tested here, so this class of defect is only
ever found by reading.
2026-08-26 06:17:38 +00:00
mckero 3d3db784a3 fix(log): the mode field kept the previous transponder's value
Two things about the mode on the logging surface.

It was held in `remember` with no key, so switching transponder mid-session kept the mode
from the transponder before it. The operator saw the old value in the field and it went out
with the upload - a wrong mode nobody chose. It is now keyed on the transponder uuid.

It was also a text field the operator had to look at on every contact, when the value comes
from the transponder record anyway. A pass lasts eight minutes; the study on satellite
logging is blunt that the fast surface should carry one typed field, not two. The mode is
now shown as a row and the field appears when the row is tapped, because a transponder
record can be wrong and the operator still has to be able to say so.

The row is 48dp tall, which is the Material Design minimum for anything tappable. Vertical
padding alone had left it around 20dp - visually fine, awkward to hit, and a real problem
for anyone with reduced dexterity.
2026-08-26 06:12:37 +00:00
mckero 08f9106749 feat(aprs): pick a map symbol from a list instead of typing two characters
The symbol table and code were free-text fields with no validation and no hint. Only the
first character was ever used, and only at packet-build time, so an operator could type
"satellite" into the table field, watch it persist, and beacon as "/" - the field lied about
what it did. aprs.fi's troubleshooting guidance puts transmit-side symbol misconfiguration
among the first things to check when a station never appears correctly.

The single strongest argument for a list: \S is Satellite/Pacsat but /S is SHUTTLE. One
keystroke apart, and both look right to someone typing from memory.

Fourteen entries covering fixed, on-foot, field, four vehicle classes, satellite, yagi,
phone, internet-only and handheld. Renderings are from aprs.org/symbols/symbolsX.txt
(WB4APR, Nov 2015) rather than recalled. A symbol the operator already set that is not on
the list appears first in the menu and stays selected, so opening the picker cannot silently
change an existing station's appearance.

The default changes from "/>" (CAR) to "/-" (House). The old default's own comment conceded
it was "a reasonable stand-in for a phone", but it showed every non-driving operator as a
vehicle. A house is right for most users and obviously wrong rather than misleading for the
rest. This cannot disturb an existing install: saveConfig writes every key unconditionally
and the enable switch calls it, so anyone who has ever turned APRS on has both symbol keys
on disk and the changed fallbacks cannot reach them. All three sites move together -
AprsStore's load fallback, AprsCard's blank-field fallback, and AprsBeacon.DEFAULT_SYMBOL -
because leaving one behind would substitute a car whenever the stored code was unusable.

The list lives in core:domain as pure data holding resource names rather than text, so the
wording stays in the locale files. Tests assert that every entry survives the transmit
sanitiser, that the pairs and description keys are unique, and that a pair off the list
reports as absent rather than resolving to something near it.
2026-08-26 05:53:46 +00:00
mckero 6db10b5b72 fix(sources): a dead custom URL no longer counts as a successful update
Found by an audit of the replace-semantics commit rather than by the change itself.

The success count added orbital and transceivers sources together, so one could stand in
for the other. With the built-in sources replaced there are two requests instead of 28: if
the operator's TLE URL was down and SatNOGS answered, the count was 1, no exception was
raised, and setUpdateSuccessful stamped a fresh timestamp for an update that refreshed no
orbital elements at all. That also suppressed the 48-hour auto-update retry, which keys off
that timestamp - so the operator was left with stale orbits, a screen saying the update
worked, and nothing scheduled to correct it.

The failure existed before this rebuild, but 26 other sources masked it. Narrowing the
source set made it easy to hit, which is why it belongs with these commits rather than in a
backlog.

Orbital sources are now counted on their own. A test covers the exact case: transceivers
answers, the custom TLE URL does not, and the update must raise rather than record success.

Also: an upload that found nothing waiting said "Uploaded 0 QSO". Accurate, but it reads
oddly when the queue was already clear, so that case has its own wording now.
2026-08-26 04:56:44 +00:00
mckero cd70e3654c fix(sources): a custom URL no longer wipes the manual-import type index
The previous commit indexed custom-URL satellites under "Other", which is the key manual
file import already writes. setSatelliteTypeIds overwrites rather than merges, so importing
a file and then updating from a custom URL erased each other's type index - a probe
confirmed it in both directions.

The satellites were never at risk: database rows survive because insertEntries is REPLACE
with no delete, and the selection is a separate id list. What was lost was their grouping in
the type filter. Still worth a distinct key, since a URL and a hand-picked file are
different things.

Custom URLs now use "Custom". Manual import keeps "Other". The test asserts the new key and
that "Other" stays untouched, so the collision cannot come back unnoticed.
2026-08-26 03:51:03 +00:00
mckero a25f0fe2cf fix(sources): a custom URL now replaces the built-in sources
Switching "Custom TLE URL" on used to mean "my source AND yours". The map overwrote only
the value keyed "All" and the other 26 built-in sources were still fetched, so pointing
Look4Sat at a mirror, a filtered subset, an offline server or a URL reachable on a censored
network did not stop it hitting Celestrak 26 more times. On a blocked link the real
behaviour was 26 failing requests.

This was a fossil rather than a decision: upstream has satelliteDataUrls as a plain list of
six URLs with no keys and no custom-URL concept, all fetched unconditionally. The fork
turned the list into a keyed map and bolted the override onto one key.

Three things made replacing safe to choose, all checked rather than assumed. Stored
satellites do not disappear, because insertEntries is OnConflictStrategy.REPLACE and
updateFromRemote deletes nothing first, so rows the new source does not mention survive.
The selection is a plain id list and is untouched. What degrades is the type filter for the
skipped keys, which goes stale rather than empty - the last known membership, not a claim
about the current fetch.

The key is now customSourceType ("Other"), not "All". setSatelliteTypeIds early-returns on
"All", so indexing there was always a no-op - satellites from a custom URL were never
reachable by the type filter at all. "Other" is what manual file import already uses, which
is the same meaning: satellites from a source the operator supplied. The existing test
asserted the "All" index, which means it was asserting a no-op; it now checks that no
built-in source is fetched and that the entries land somewhere the filter can see.

A second test covers the switch-off path, which must fetch every built-in source exactly as
before.
2026-08-26 03:44:59 +00:00
mckero b1dce9c518 fix(wavelog): the upload count included QSOs sent days ago
Two smaller findings from the same audit.

Entries already confirmed by the server were added to the success total, so re-running an
upload reported "N uploaded" counting contacts that went up days ago. They are skipped and
no longer counted.

A bulk reply that stored nothing read as an acceptance. `{"imported":0}` has a success
status and would have cleared the queue. The count is checked now. Look4Sat posts one QSO
per request so this was latent, but it would have become real the moment that changed.

The count check deliberately looks only at `imported` and `adif_count`, never
`adif_errors`: v1 answers a successful upload with `adif_errors:0` beside `adif_count:1`,
and matching the wrong key would have rejected every stored QSO. A probe confirms the six
relevant shapes classify correctly.
2026-08-26 02:22:00 +00:00
mckero 13c5fc9584 fix(wavelog): the response reader lost contacts three more ways
An audit cloned the Wavelog server and read the QSO endpoints rather than the
documentation. The previous commit had transcribed the wrong endpoint - `success`,
`successful` and `dupe` come from create_station; the QSO path answers `created` on
success and `abort` with a 400 when a record in a batch failed. Three defects followed,
and the worst reintroduced the very failure the class was written to prevent.

Matching the bare word "duplicate" anywhere in the body classified a hard rejection as a
duplicate, which maps to success and drops the QSO from the queue. This is not
hypothetical: the server's own rejection text is "Duplicate for <call>", built in
Logbook_model::import, and Api_v2 puts strip_tags'd copies of those messages into
validation_error bodies. Probed against real response bodies, five of ten lost the
contact. Only the status field counts now, or a 409.

An HTML body was accepted. A reverse proxy, a maintenance page or a PHP fatal answers 200
with HTML and no status token, so it fell through to Accepted and a misconfigured proxy
ate contacts silently. A body starting with `<` is Unreadable, which keeps the QSO queued.

A rejection from v2 stopped the upload. v2 refuses a legacy v1 key with 401 invalid_token
- the app sends the v1 key as a Bearer token, and Api_v2::authenticate requires a wl2_
prefix - so a v1-only operator could not upload at all. That was a regression against the
pre-fix code, which fell through on any non-2xx. Both v2 and the first v1 endpoint now
always fall through; only the last one is final, and the failure message carries the most
specific reason any endpoint gave plus all three status codes. The third was previously
dropped from that message.

Also: the v2 error envelope has no status key at all, so `"error":` is now recognised on
its own.
2026-08-26 02:11:55 +00:00
mckero e8e67b74cd fix(sources): the custom-source switch stopped turning itself off
Two defects in how the data source settings were read.

The switch reported a state nobody had chosen. `useCustomTLE` was ANDed with
`tleUrl != Sources.defaultTleUrl`, so an operator who enabled custom sources and then
typed the default URL by hand saw the switch flip itself off. It now reports what they set.

The example.com placeholder rewrite ran on every read. A 4.4.7-era build could persist
`https://example.com/tle.txt`, and the fix for that rewrote the value each time it was
read - so the stored value and the returned value disagreed indefinitely and nothing ever
settled it. It is now a one-time migration following migrateRCFormats, which writes the
correction back and records that it has run.

Not addressed here, and the reason the settings screen still misleads: a custom TLE URL
replaces only the source keyed "All" and the other 27 hardcoded sources are still fetched
unconditionally, so "use custom sources" actually means "my source plus 27 others". Which
way that should go is a decision about intent rather than a defect to patch, and upstream
fetches all of its sources unconditionally, which is where the behaviour came from.
2026-08-26 01:35:02 +00:00
mckero 758dc6d567 fix(wavelog): the auto-upload switch had nothing behind it
Deleting the ten-minute polling loop left the "auto upload" switch in settings with no
consumer - the operator could turn it on and nothing would ever act on it, which is worse
than the loop it replaced.

Uploading now happens when a contact is saved. That is what the switch always meant, and
doing it at that moment means somebody is present to see the outcome: a partial failure
says so, and the QSOs that did not go stay in the queue for a manual upload from settings.
The loop reported nothing at all - a grid mismatch hit an empty if block and every other
failure retried forever in the background.

The upload goes through the view model rather than the composable, so the log screen still
touches no repository.
2026-08-26 01:30:14 +00:00
mckero 4567f46867 fix(wavelog): a 200 is not an acceptance
WaveLogApi decided an upload had succeeded from the HTTP status alone. Wavelog validates
after responding, so a rejected QSO comes back as 200 with `{"status":"failed","reason":
"..."}` - and the uploader then called markUploaded and dropped it from the queue. The
contact was lost and the operator was told the upload succeeded.

Response shapes are transcribed from the Wavelog API reference, not guessed: success is
`status: success` or `successful`, a duplicate is `status: dupe` with a 200, failures are
`status: failed` with `reason` or `status: error` with `message`.

WavelogResponse reads the body. Four outcomes: accepted and duplicate both clear the
queue entry, because the log holds the QSO either way; rejected keeps it and surfaces the
server's own explanation; and a status field we cannot recognise also keeps it, since
costing a retry beats losing a contact. Parsed as text rather than with JSONObject because
org.json is compileOnly in core:domain and a JVM test would otherwise assert against a
stub. Whitespace around separators is collapsed before matching - a first attempt listed
spacings and missed `{ "status" : "failed" }`, which a probe caught.

Two other things in the same area.

The ten-minute auto-upload loop is gone. It retried the queue in the background with no
way to tell the operator anything: a grid mismatch was swallowed by an empty if block and
every other failure retried silently forever. A QSO that cannot be uploaded now waits for
a manual upload from settings, where the result is actually shown.

The upload path no longer builds user-facing text in Kotlin. UploadOutcome carried a
pre-formatted Chinese string, so the message ignored the device language whatever the
locale files said. It now reports a Reason the view model maps to resources, which needed
a format-argument overload on IShowToast to get a count into a localised message.
2026-08-26 01:20:26 +00:00
mckero fe6d0af8b7 fix: two regressions this rebuild introduced for non-APRS users
Both found by an auditor comparing behaviour against the released build rather than
against the intent of the change.

Prefix-first portable callsigns were rejected. CallsignEntry took the first segment -
`call.substringBefore('/')` - and required a letter and a digit in it. A portable call can
be written prefix-first, DL/W1AW or ZL/JA1ABC or OH/W1AW/MM, where the leading token is a
country prefix with no digit at all. Measured: five such forms were refused where the old
length-only check had accepted them. Any segment may now carry the callsign.

JSON cookie exports stopped working for QRZ. QrzGridParser.cookieHeader converts the JSON
array a browser extension produces into a Cookie header, and it had zero production
callers - the raw pasted text went straight into the header. The old client normalised it.
So an operator whose export had been working would see their cookie sent as a literal JSON
blob, QRZ would serve its signed-out page, and the app would tell them the cookie had
expired when it was perfectly good. A raw `k=v; k=v` paste was unaffected, which is why
this survived review.

Both are cases where a rewrite lost behaviour the old code had. Neither had a test.
2026-08-26 00:21:11 +00:00
mckero 3a8086eb05 chore: ignore Kotlin build caches outside the root module
The existing rule covered only /.kotlin/sessions/ at the repository root, so
build-logic/.kotlin/ showed up as untracked after any build.
2026-08-25 17:23:32 +00:00
mckero e8ad51fd1b fix(aprs): the ack read disagreed with the login parser
sendPacket had its own idea of what a server response means: any leading `#` counted as
harmless chatter. The login parser had just been taught that `# Port full` and `# Login
by user not allowed` are refusals - the server announcing it is about to drop us - so the
two paths reached opposite conclusions about the same line, and the send path was the
optimistic one.

Probed across the responses captured from live servers, they disagreed on four of six.

classifyAck now shares AprsLogin's judgement. A greeting or keepalive still counts as
sent, because APRS-IS does not acknowledge position reports and silence is the normal
outcome; anything the server says that is not harmless fails the report. A late login
verdict arriving here also counts as sent, since it is not about this packet and the
login state already carries it.

Two socket tests cover both directions: a `# Port full` after the write fails the report,
and a real captured keepalive after the write does not.
2026-08-25 17:16:40 +00:00
mckero 6859c825d7 fix(aprs): treat any unrecognised login response as a refusal
The previous commit listed the refusal wordings it knew - "invalid login" and "login
denied" - and skipped everything else as chatter. That list was incomplete. Probing the
parser against responses captured from live servers found three it missed:

    # Login by user not allowed     observed on rotate.aprs2.net
    # Port full
    # Server full

Each was skipped as a keepalive, so the login timed out into Unknown, Unknown is
deliberately read as "may be working", and every send afterwards reported success to an
operator the server had refused. Exactly the failure the previous commit fixed, reached
by a different wording.

Inverted: identification and keepalive comments are recognised positively, and anything
else the server says during login counts as an objection. The trade is that an unforeseen
harmless comment would read as a refusal - but that errs towards reporting failure rather
than claiming success, which is the direction this feature has been wrong in throughout.

The keepalive prefixes come from a live capture rather than guesswork. aprsc repeats its
own identification with a timestamp every twenty seconds:

    # aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 195.201.15.71:14580

Two tests had invented a `# Tue Aug 25 ...` date line and a `# keepalive N`, neither of
which any server sends. Both now use the captured format.

Also here: the QRZ cookie test in settings goes through the repository instead of
scraping from the UI. It was the last caller of QrzGridClient, which is deleted, and it
built its result from hardcoded Chinese strings inside the composable - those move to
resources, and the four outcomes are now distinguished, where before an expired cookie
and a station with no grid on file produced the same message.
2026-08-25 17:06:37 +00:00
mckero 271488a43e fix(aprs): the notification showed the previous cycle's verdict
updateNotification was called from onReport but read lastState, which onState only sets
afterwards - so the persistent notification was rebuilt from the previous report's
outcome. It now derives the state from the report in hand.

This matters most where it is least visible: an alarm-driven report at 03:00 posts a
Toast nobody sees, leaving the notification as the only surface, and that surface was
showing a stale verdict.
2026-08-25 16:10:53 +00:00
mckero 321cd8f2fa fix(aprs): the login line was malformed, and the refusal was invisible
An auditor ran the plan's own release gate against live APRS-IS servers. It failed at
the login step, on every server tried:

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

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

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

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

Three smaller things from the same review:

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

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

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

Not fixed, and known: the notification body is rebuilt from the previous cycle's state
so it can show a stale verdict, a deliberate receive-only choice is still styled as an
error, and no last-success timestamp exists - so an operator still cannot establish
whether their station has ever reached the network.
2026-08-25 16:04:47 +00:00
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.
v4.6.0
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.
v4.5.9
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.
v4.5.8
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