Compare commits

..
68 Commits
Author SHA1 Message Date
mckero abb73ffef7 build: bump to 4.6.2 (versionCode 469)
Carries the CW record fixes: the record no longer deletes its own text while decoding,
a drifting tone no longer wipes it instead of filling it, the archive path no longer
races itself when capture stops, and the decoded text can finally be copied off the
screen.

Also stops the APRS version string drifting, which the comment on it had predicted and
which had already happened again: it still read 4.6.0 while the app shipped 4.6.1, so
every station on the network was told the wrong version. AprsReporter now takes the
version as a parameter and the app module passes BuildConfig.VERSION_NAME, which
required turning on the buildConfig feature - AGP 8 does not generate the class
otherwise. The literal cannot fall out of step with the build again.
2026-08-27 15:41:07 +00:00
mckero cc4f156c83 fix(cw): a drifting tone wiped the record instead of filling it
The record could go from a screenful of text to less, and then to nothing at all, while
decoding was still running. The cause is dropBufferedAudio(), which runs on every change
of shift - and the shift tracks the detected tone, which drifts across a pass.

The live window is the only route into the archive: audio gets there by being pushed out
by newer audio. Clearing the window therefore did not merely discard 20 s, it reset the
progress towards ever archiving anything. Modelled at 18 WPM, a drift every 20 s meant
five minutes of listening archived not one character, however long the operator waited -
the window was always wiped before the first sample could be evicted. With the record
still concatenating the live decode at the time, each wipe also cut the visible
transcript short, which is the text going backwards and then never accumulating.

Retiring the window instead of dropping it fixes both. Those samples cannot stay - one
spectrogram over two shift amounts smears the tone - but they were shifted consistently
and they are complete, so they decode fine on their own. They are queued and archived by
the normal path, keeping dropBufferedAudio() non-suspending: both callers sit on the
synchronous capture path, and decoding there would put an inference inside the tone
scan. Modelled over 300 s, a drift every 5 s goes from 0 characters archived to 449.

An earlier attempt at this - keeping archiveSize instead of zeroing it - was wrong and
the probe rejected it: with the window wiped before it ever overflowed, that buffer was
empty, so preserving it preserved nothing.

The queue is synchronised (written from capture, drained from capture and flush) and
capped at four windows, dropping the oldest when full: a tone drifting on every
detection scan would otherwise queue faster than the decoder can drain.

Also from an audit of the previous two commits:

- The copy button was gated on the record, which is empty for the opening half-minute
  while the first batch accumulates. That greyed out the one control that rescues the
  text over exactly the short exchange most likely to be lost. It now copies the live
  window too, and the empty state says which surface updates sooner.
- cw_copy, cw_copied, cw_record_label and cw_record_empty were missing from values-id,
  values-in and values-tr, which carry the full UI strings, so those users saw English.
- Dropped a KDoc block left dangling by 8445c170, which had removed the constant it
  documented.
2026-08-27 15:24:01 +00:00
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.
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.
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
100 changed files with 9622 additions and 518 deletions

No files matched your search

+2
View File
@@ -70,4 +70,6 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
# Kotlin build caches in any module, not only the root - build-logic produces one too.
.kotlin/
.hermes/
+4
View File
@@ -110,6 +110,10 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
`core:presentation/MainTheme.kt`, so the data layer currently decides how the UI looks. Known debt, left as
upstream shipped it: the fix is a status enum in `core:domain` with the colour mapping in `core:presentation`.
Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first.
## Copilot Working Mode: Code-Only
+15
View File
@@ -15,7 +15,22 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
dataSync rather than location. The location type is refused outright unless a location
runtime permission has already been granted - startForeground throws SecurityException -
and this service reads the station position the operator typed into settings, so demanding
location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
when targetSdk is 35 or higher, which this project does not declare.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!--
Doze suspends network access and ignores wake locks even for a foreground service, so a
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
setExactAndAllowWhileIdle is the only scheduling that survives Doze, and at a five-minute
floor the system's one-alarm-per-nine-minutes throttle is not a problem.
-->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
@@ -1,5 +1,6 @@
package com.rtbishop.look4sat.app
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -11,7 +12,10 @@ import android.content.SharedPreferences
import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.IBinder
import com.rtbishop.look4sat.BuildConfig
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
@@ -35,10 +39,25 @@ class AprsForegroundService : Service() {
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
/** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
private const val ALARM_REQUEST = 4101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reporter: AprsReporter? = null
/**
* Handler on the main looper, for anything that must not run on the reporter's IO thread.
*
* onReport is invoked from AprsReporter's Dispatchers.IO scope, and Toast construction there
* throws because that thread has no Looper - an exception the surrounding runCatching then
* swallowed, so every report notice was silently discarded. The messages existed and no
* operator ever saw one.
*/
private val mainHandler = Handler(Looper.getMainLooper())
private var lastState: AprsState = AprsState.Idle
override fun onBind(intent: Intent?): IBinder? = null
@@ -51,6 +70,12 @@ class AprsForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_ALARM_TICK -> {
// Woken by the exact alarm. Reporting once and then booking the next tick, rather
// than using a repeating alarm, means a changed interval takes effect at once.
if (reporter == null) startReporting() else reporter?.reportNow()
scheduleNextTick()
}
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
@@ -85,18 +110,33 @@ class AprsForegroundService : Service() {
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
// The real version, so the login line cannot drift from the build again.
appVersion = BuildConfig.VERSION_NAME,
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
// Derived from this report rather than read from lastState: onState fires AFTER
// onReport, so the notification was being rebuilt from the previous cycle's
// verdict. For an alarm-driven report at 03:00 the notification is the only
// surface that survives, and it was showing the wrong one.
lastState = if (report.ok) AprsState.Connected else AprsState.Error
updateNotification(cfg)
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
// An unverified login needs its own message: the write succeeded, so a bare
// failure notice would send the operator looking at their network when the
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
// No receive-only notice. APRS-IS lets an unverified station connect and then
// discards its packets, but this app only reports its own position - there is no
// receiving side to it - so telling the operator they are "in receive-only mode"
// named a state that does not exist here. Without a passcode the packet does not
// arrive, and that is what the failure notice says.
val msg = when {
report.ok -> getString(R.string.aprs_toast_ok)
!report.verified -> getString(R.string.aprs_toast_unverified)
else -> getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
mainHandler.post {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
@@ -104,9 +144,12 @@ class AprsForegroundService : Service() {
)
reporter = rep
rep.start()
// The reporter beacons once on start; the alarm carries every one after that.
scheduleNextTick()
}
private fun stopReporting() {
cancelTicks()
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -117,6 +160,11 @@ class AprsForegroundService : Service() {
try {
val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Must match the manifest attribute exactly: AOSP checks the passed type is a
// subset of the declared one and throws otherwise, which the catch below turns
// into a silent stopSelf(). Declaring location instead would additionally require
// a granted location permission before this call, and the settings card asks only
// for notifications - so that combination fails silently too.
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notif)
@@ -197,4 +245,43 @@ class AprsForegroundService : Service() {
reporter = null
super.onDestroy()
}
/**
* Book the next beacon with an exact alarm.
*
* A coroutine delay was used before, which Doze defeats: the timer fires but network access is
* suspended and wake locks are ignored, even inside a foreground service. Only
* setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
* the system's throttle on how often such an alarm may repeat.
*/
private fun scheduleNextTick() {
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled) return
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
val at = System.currentTimeMillis() + minutes * 60_000L
runCatching {
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
if (exact) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
} else {
// The operator revoked exact alarms. An inexact one still beacons, just whenever
// the system decides, which beats not beaconing at all.
alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
}
}
}
private fun cancelTicks() {
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
runCatching { alarms.cancel(tickIntent()) }
}
private fun tickIntent(): PendingIntent = PendingIntent.getService(
this,
ALARM_REQUEST,
Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
}
// The APRS login line reports the app version to every station on the network. It used
// to be a literal in core:data and drifted twice, so the app module now reads
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
buildFeatures {
buildConfig = true
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@@ -1,16 +1,23 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
/**
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
*
* Two things here decide whether the operator can trust the app at all. A packet sent on a dead
* socket must not report success, and a login the server refused to verify must not look like a
* working connection - an unverified client stays connected while the server silently drops
* everything it sends.
*/
class AprsIsClient(
private val host: String,
@@ -18,6 +25,7 @@ class AprsIsClient(
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val softwareName: String,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
@@ -27,86 +35,188 @@ class AprsIsClient(
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/**
* What the server said about this login, or null before a login has been attempted.
*
* Kept as state rather than only thrown, because [AprsLogin.Outcome.Unverified] is not a
* connection error: the socket is up and writes succeed. The operator has to be told, or
* they will watch reports "succeed" for hours while nothing reaches the network.
*/
@Volatile
var loginOutcome: AprsLogin.Outcome? = null
private set
/** Connect + login (synchronous/blocking; call from a background thread) */
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && socket?.isClosed == false }
/** True when the server verified the passcode, so packets from this client are accepted. */
val isVerified: Boolean get() = loginOutcome is AprsLogin.Outcome.Verified
/**
* True only when the server told us it did NOT verify the login.
*
* Distinct from `!isVerified` on purpose. [AprsLogin.Outcome.Unverified] means the server
* said so and really is discarding our packets. [AprsLogin.Outcome.Unknown] means we could
* not recognise its answer - the packets may well be landing - so blaming the operator's
* passcode for that would send them to fix something that is not broken.
*/
val isRefusedByServer: Boolean get() = loginOutcome is AprsLogin.Outcome.Unverified
/**
* Connect and log in. Blocking; call from a background thread.
*
* Throws when the connection cannot be made or the server rejected the login outright.
* A login the server accepted but did not verify returns normally and leaves
* [loginOutcome] as [AprsLogin.Outcome.Unverified] for the caller to surface.
*/
@Throws(Exception::class)
fun connect() {
disconnect()
loginOutcome = null
val s = Socket()
try {
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.connect(InetSocketAddress(host, port), CONNECT_MS)
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
s.soTimeout = LOGIN_MS
// The spec has the client log in AFTER the server's identification line, so read the
// greeting first. Anything starting with # is a comment and may be skipped.
readGreeting(s)?.let { refusal ->
// The server refused before we even logged in. Previously this verdict was
// computed and then overwritten by readLoginResponse, so the branch was a lie.
loginOutcome = refusal
throw IllegalArgumentException(refusal.detail)
}
// Restore timeout
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
writer?.print(login)
writer?.print(CRLF)
writer?.flush()
loginOutcome = readLoginResponse()
s.soTimeout = timeoutSec * 1000
}
val outcome = loginOutcome
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
} catch (e: Exception) {
// Close the local socket before re-throwing, so it does not leak when
// an exception is raised after s.connect() but before socket = s.
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
// accumulate leaked fds until the process cannot open any more files.
// Close the local socket before re-throwing so it does not leak when the failure
// lands after connect() but before the field assignment - otherwise periodic
// reconnects accumulate file descriptors until no more can be opened.
runCatching { s.close() }
synchronized(lock) {
writer = null
reader = null
socket = null
}
throw e
}
}
/**
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
* Consume the server's greeting comment, returning a refusal when it is not one.
*
* Absence is tolerated because some servers send none, but on a short probe window rather
* than the full login timeout: waiting LOGIN_MS for a greeting that will never come cost
* eight seconds on every single connect to such a server.
*/
private fun readGreeting(socket: Socket): AprsLogin.Outcome.Rejected? {
val previous = socket.soTimeout
return try {
socket.soTimeout = GREETING_MS
val line = reader?.readLine() ?: return null
// A server that opens with anything but a comment is refusing us.
if (line.startsWith("#")) null else AprsLogin.Outcome.Rejected(line.trim())
} catch (ignored: IOException) {
null
} finally {
runCatching { socket.soTimeout = previous }
}
}
/**
* Read lines until the login verdict arrives, skipping keepalive comments.
*
* Bounded by the read timeout, so an unresponsive server cannot hang the caller.
*/
private fun readLoginResponse(): AprsLogin.Outcome {
// Bounded by a deadline, not a line count: comments are free to skip, and a chatty
// server that sent six of them before its verdict used to exhaust a fixed budget and
// turn an accepted login into Unknown - telling the operator their passcode was wrong
// when it had just been accepted.
val deadline = System.currentTimeMillis() + LOGIN_MS
while (System.currentTimeMillis() < deadline) {
val line = try {
reader?.readLine()
} catch (timeout: SocketTimeoutException) {
return AprsLogin.Outcome.Unknown("no response within ${LOGIN_MS}ms")
} catch (failure: IOException) {
return AprsLogin.Outcome.Rejected(failure.message ?: "login read failed")
} ?: return AprsLogin.Outcome.Rejected("connection closed during login")
AprsLogin.parse(line)?.let { return it }
}
return AprsLogin.Outcome.Unknown("no login response recognised")
}
/**
* Send one packet and report what happened.
*
* Returns null when there is no connection to write to. Otherwise a pair of whether the
* packet went out and a detail string for the operator.
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return null
w.println(packetLine)
// Reading the response inside the same lock: disconnect() may run concurrently and
// null these fields, and reading outside the lock raced with that - the read could
// hit a just-closed socket and be reported as a successful send.
// CRLF explicitly rather than println: the spec requires "TNC2 format terminated by
// a carriage return, line feed sequence", and println emits the platform separator,
// a bare LF on Android. An earlier draft of this method sent no terminator at all,
// which leaves the server's line reader waiting forever while every send reports
// success - exactly the failure this class exists to prevent.
w.print(packetLine)
w.print(CRLF)
w.flush()
if (w.checkError()) return Pair(false, "write failed")
// Read the server response inside the same lock: disconnect() (called
// concurrently from stop()/reconnect on another thread) nulls
// writer/reader/socket and closes them. Reading outside the lock raced
// with that: the response read could hit a just-closed socket and the
// swallowing runCatching reported Pair(true,"OK") for a packet that
// never left, or read through a stale reference. Serialising keeps
// the read on the connection this thread just wrote to. The 3 s read
// timeout bounds how long a concurrent disconnect waits.
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
val s = socket ?: return Pair(false, "not connected")
val previousTimeout = s.soTimeout
return try {
s.soTimeout = ACK_MS
classifyAck(reader?.readLine())
} catch (timeout: SocketTimeoutException) {
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
// nothing arriving means the line went out and the server had nothing to say.
// Telling this apart from a broken connection is the point of this method - the
// previous version treated EVERY exception as success, so a dead socket reported
// "sent OK" and made every real failure invisible, including a rejected login.
Pair(true, "sent")
} catch (failure: IOException) {
Pair(false, failure.message ?: "read failed")
} finally {
s.soTimeout = oldTimeout
runCatching { s.soTimeout = previousTimeout }
}
}.getOrElse { Pair(true, "OK") }
}
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
/**
* Interpret whatever the server sent back after a packet.
*
* Shares AprsLogin's judgement rather than keeping its own, because the two disagreed in a way
* that mattered: treating any leading `#` as harmless meant `# Port full` and `# Login by user
* not allowed` - both of which mean the server is about to drop us - were reported as a
* successful send. APRS-IS does not acknowledge position reports, so a harmless comment still
* counts as sent; anything the server says that is not harmless does not.
*/
private fun classifyAck(response: String?): Pair<Boolean, String> {
if (response == null) return Pair(false, "connection closed by server")
return when (val verdict = AprsLogin.parse(response)) {
// A keepalive or identification comment: no verdict, so the write stands.
null -> Pair(true, "sent")
is AprsLogin.Outcome.Rejected -> Pair(false, verdict.detail)
// A late login verdict is not about this packet, and loginOutcome already carries it.
else -> Pair(true, "sent")
}
}
fun disconnect() {
@@ -119,4 +229,16 @@ class AprsIsClient(
socket = null
}
}
private companion object {
/** Line terminator the protocol requires, independent of the platform's own. */
const val CRLF = "\r\n"
const val CONNECT_MS = 30_000
const val LOGIN_MS = 8_000
const val ACK_MS = 3_000
/** Probe window for the greeting, short because its absence is legitimate. */
const val GREETING_MS = 2_000
}
}
@@ -1,12 +1,11 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -34,12 +33,39 @@ data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
val detail: String,
/**
* False only when the server told us it did not verify the login.
*
* Carried separately from [ok] because the two are independent: a refused client's writes
* still succeed, so the packet leaves the phone and looks sent, while aprsc discards every
* one of them. Without surfacing it the operator can watch reports succeed for hours with
* nothing reaching the network. A login whose response we simply could not parse leaves this
* true, since the packets may be landing and the passcode is not at fault.
*/
val verified: Boolean = true,
/**
* True when the operator asked for a receive-only connection.
*
* Distinguished from a refused login because both log in with -1 and the server answers
* "unverified" to each: without this, deliberately choosing receive-only - the one way to
* test a setup without putting anything on the network - was reported as a wrong passcode
* and sent the operator to fix something they had set on purpose.
*/
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
/**
* The app's own version, reported to APRS-IS in the login line.
*
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
* network was told the wrong version. A caller in the app module can read the real one.
*/
private val appVersion: String,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
@@ -59,12 +85,11 @@ class AprsReporter(
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
// One report now; the service's exact alarm drives every one after this. The loop that
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
// schedule and then finds network access suspended, so the beacon stopped whenever the
// screen locked while the notification still claimed it was running.
job = scope.launch { reportOnce() }
}
fun stop() {
@@ -86,41 +111,92 @@ class AprsReporter(
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
// The packet is built BEFORE connecting: there is no reason to open a session and log
// in only to discover there is nothing to send, which happened every five minutes for
// an operator whose QTH was unset.
val pos = positionProvider()
val beacon = AprsBeacon.build(
callsign = cfg.callsign,
ssid = cfg.ssid,
latitude = pos?.first,
longitude = pos?.second,
symbolTable = cfg.symbolTable,
symbolCode = cfg.symbolCode,
comment = cfg.statusText
)
// Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
// of Guinea on the global network, under the operator's own callsign.
if (beacon is AprsBeacon.Result.Blocked) {
onState(AprsState.Error)
onReport(
AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
)
return
}
val packetLine = (beacon as AprsBeacon.Result.Line).text
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
// Never derives one: a blank or wrong entry logs in receive-only rather than
// transmitting under a passcode the app invented for an unchecked licence.
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
softwareName = "Look4Sat",
version = appVersion
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val sent = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
// A write that succeeded on a login the server refused to verify is not a delivered
// packet: aprsc takes it and drops it, which is what let every real failure hide.
// Only an explicit refusal counts against us though - a login whose response we
// could not parse may be working fine, and blaming the passcode for that would send
// the operator to fix something that is not broken.
val refused = c.isRefusedByServer
val ok = sent && !refused
// "sent" is the write's own verdict and reads as nonsense next to a failure - the card
// showed "failed - sent" for a refused login. When the refusal is what failed the
// report, say that instead.
val reported = when {
ok -> detail
refused -> "login not verified"
else -> detail
}
onReport(
AprsReport(
System.currentTimeMillis(), packetLine, ok, reported,
verified = !refused
)
)
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error", false))
}
}
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
/** A short reason for a refusal, for the operator's last-report line. */
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
AprsBeacon.Refusal.NoPosition -> "no position yet"
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
}
companion object {
/** Floor for the reporting interval, in minutes. */
const val MIN_INTERVAL_MIN = 5
}
}
@@ -24,6 +24,15 @@ object AprsStore {
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
/**
* A house, not a car.
*
* Only fresh installs see this: 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 this
* fallback cannot reach them.
*/
private const val DEFAULT_SYMBOL_CODE = "-"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
@@ -41,7 +50,7 @@ object AprsStore {
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
symbolCode = p.getString(KEY_SYMBOL_CODE, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE
)
}
@@ -24,7 +24,11 @@ import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -32,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.nio.FloatBuffer
@@ -48,29 +53,96 @@ import java.nio.FloatBuffer
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
*
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
* in; no spectral peak tracking or squelch gating is needed.
* in; no spectral peak tracking or squelch gating is needed. A tone outside that
* window is invisible to the model, so [CwToneShifter] can optionally move it in —
* see [isToneShiftEnabled].
*
* @param isToneShiftEnabled read on every chunk so toggling the setting takes effect
* without rebuilding the decoder. Defaults to disabled: with it off the audio path
* is byte-for-byte what it was before the feature existed.
*/
class CwDeepDecoder(context: Context) : ICwDecoder {
class CwDeepDecoder(
context: Context,
private val isToneShiftEnabled: () -> Boolean = { false }
) : ICwDecoder {
private companion object {
// internal, not private: the archive timing is user-visible behaviour and its test asserts
// against these constants directly rather than a copy that could silently drift.
internal companion object {
const val TAG = "CwDeepDecoder"
const val MODEL_ASSET = "deepcw/model.onnx"
const val METADATA_ASSET = "deepcw/model.onnx.json"
/** Evicted audio is decoded into permanent history once this much accumulates. */
const val ARCHIVE_SECONDS = 15.0
/**
* Evicted audio is decoded into permanent history once this much accumulates.
*
* This is the delay before decoded text reaches the record, and it is additive with
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
* of a session, and thereafter text that had scrolled out of the live window sat
* invisible for up to 15 s before landing - the record appeared to stall and, when
* it was still concatenating the live window, to delete what it had just shown.
*
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
* stall for longer than the one thing an operator most needs to read back - while the
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
*/
const val ARCHIVE_SECONDS = 4.0
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
/**
* Samples the detector needs for a usable estimate: 0.4 s at 3200 Hz, giving
* ~12.5 Hz resolution.
*
* A capture chunk is ~100 ms, which is 4410 samples at the 44.1 kHz capture
* rate but only 320 after resampling to 3200 Hz. Gating on a single chunk
* reaching this size would therefore never fire, so chunks are accumulated in
* [detectionPool] until enough audio is available.
*/
const val DETECT_MIN_SAMPLES = 1280
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
const val DETECT_INTERVAL_MS = 2000
/** Silence after which a tone reading is treated as stale. See runDetection. */
const val TONE_EXPIRY_MS = 10_000L
/**
* Minimum change in the required shift before the window is re-shifted.
*
* Two scan bins (12.5 Hz each) plus margin. Re-shifting drops the 20 s decode
* window, so a tone drifting slightly - or the estimate hopping to an adjacent
* bin - must not keep wiping context that is still perfectly decodable.
*/
const val SHIFT_HYSTERESIS_HZ = 40f
}
private val _decodedText = MutableStateFlow("")
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
/**
* Archived text: only appended to, so a pane bound to it never loses what it showed.
*
* This once carried a provisional tail meant to cover the gap while audio waited to be
* archived, but the decode feeding that tail was never wired up, so the tail was always
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
* batches, which no longer concatenate anything that gets rewritten.
*/
private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow()
/** Permanently archived text; the provisional tail is appended to this for display. */
private var committedText = ""
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
private val _detectedToneHz = MutableStateFlow<Float?>(null)
override val detectedToneHz: StateFlow<Float?> = _detectedToneHz.asStateFlow()
private val _activeShiftHz = MutableStateFlow(0f)
override val activeShiftHz: StateFlow<Float> = _activeShiftHz.asStateFlow()
private val _signalStrength = MutableStateFlow(0f)
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
@@ -83,18 +155,90 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
private val buffer = CwDeepBuffer()
/**
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
* keeps the extra inference cheap (short window) while long enough to be
* decoded accurately — the content has already been through the 20 s window
* many times, so a slightly shorter archive decode loses almost nothing.
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
* once and appended to [historyText]. The batch stays short enough that the record does
* not visibly stall, and accuracy barely suffers: this content has already been through
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
*
* Sized to the full window rather than the batch: [flush] hands over whatever the live
* window holds, which can be the whole 20 s.
*/
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
private var archiveSize = 0
/**
* Window contents retired by a change of shift, waiting to be archived.
*
* The live window is the only route into the archive - audio gets there by being pushed
* out - so clearing the window used to mean its audio was never decoded at all. When the
* shift changed more often than the window took to fill, that was every sample: modelled
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
* Synchronised on itself: written from the capture path and drained from both there and
* [flush], which run on different coroutines. Capped, because a signal drifting on every
* detection scan would otherwise queue windows faster than they can be decoded and grow
* without bound; past the cap the oldest goes, since newer audio is what is being read.
*/
private val retiredAudio = ArrayDeque<FloatArray>()
/** Windows held awaiting archival before the oldest is dropped. */
private val retiredAudioLimit = 4
/** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex()
/**
* Serialises the archive path, which mutates state the capture coroutine also touches.
*
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
* scope as the screen leaves - and both append to [committedText], which is a read, an
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
* later write wins and a whole batch of text is lost, precisely at the moment the
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
* makes the same audio decode twice.
*
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
* archive batch discards the audio for good).
*/
private val archiveLock = Mutex()
/** Decides what shift to apply from successive tone estimates. */
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
/** Wall clock of the last scan that actually found a tone, for [TONE_EXPIRY_MS]. */
private var lastToneAtMs = 0L
/** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */
private var lastDetectAtMs = 0L
/**
* Pools resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single capture
* chunk is only 320 samples once resampled, so detection has to pool several.
*/
private val detectionPool = CwDetectionPool(DETECT_MIN_SAMPLES)
/** Carries Hilbert filter history and mixer phase across capture chunks. */
private val streamingShifter = CwToneShifter.Streaming()
/**
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
*
* Built on the first chunk because the capture rate is not known until then. Without
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
* folded down on top of the signal.
*/
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
/**
* Previous value of the setting, so a toggle can invalidate buffered audio.
* Null until the first chunk: a decoder created while the setting is already on
* must not treat that as a change and wipe an empty buffer.
*/
private var toneShiftWasEnabled: Boolean? = null
private var environment: OrtEnvironment? = null
private var session: OrtSession? = null
private var chars: List<String> = emptyList()
@@ -171,10 +315,36 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
if (samples.isEmpty()) return
if (!ensureLoaded()) return
// Filter before decimating. resampleLinear interpolates without removing anything
// above the new Nyquist, so this has to happen first or the fold is already baked in.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(samples) ?: samples
// The filter holds back its group delay, so the first call returns nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val shouldRedecode = buffer.append(resampled)
val prepared = applyToneShift(resampled)
// Anything applyToneShift just retired from the window is older than what follows, so
// it is archived before the new audio is buffered - otherwise the record comes out
// with its text transposed. Archived whole rather than accumulated: it is already a
// full window's worth, and holding it back would only expose it to the next retirement.
val retired = drainRetiredAudio()
for (batch in retired) {
try {
archiveDecode(batch)
} catch (t: Throwable) {
if (t is CancellationException) throw t
Log.e(TAG, "retired audio decode failed", t)
}
}
val shouldRedecode = buffer.append(prepared)
// Archive audio that scrolled out of the live window. It is decoded once
// when a full archive chunk has accumulated, so old text does not vanish.
@@ -237,6 +407,182 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
}
}
/**
* Move an out-of-window tone into the model's analysis window when the user has
* enabled it.
*
* The detection scan is a bin-by-bin DFT, so it runs at most every
* [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it
* produces is cached in [_activeShiftHz] and applied to the chunks in between. A
* tone already inside the window yields a zero shift, and then this returns the
* caller's array untouched.
*
* @return the audio to buffer: [resampled] itself whenever no shift applies.
*/
private fun applyToneShift(resampled: FloatArray): FloatArray {
val enabled = isToneShiftEnabled()
// A toggle invalidates whatever is already buffered: those samples were moved by
// the old setting and cannot be un-shifted, so the 20 s window would keep
// decoding them - and the pitch readout would correct them by the wrong amount -
// for up to 20 s after the user acted. Seeded from the current setting on the
// first chunk so starting up with it already on is not treated as a change.
val previousEnabled = toneShiftWasEnabled ?: enabled
toneShiftWasEnabled = enabled
if (enabled != previousEnabled) {
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
dropBufferedAudio()
_activeShiftHz.value = 0f
_detectedToneHz.value = null
lastToneAtMs = 0L
shiftDecider.reset()
lastDetectAtMs = 0L
detectionPool.clear()
streamingShifter.reset()
}
// Detection runs whether or not shifting is enabled. It is the only measurement
// that can see past the model's window, so with it skipped an out-of-window tone
// left the UI with nothing truthful to show: the spectrogram's own pitch readout
// is arithmetically confined to the window and reports the leakage piled against
// the nearest edge, so a 1500 Hz tone published "1200 Hz" and a healthy signal
// level while decoding nothing at all.
detectionPool.add(resampled)
val now = System.currentTimeMillis()
val elapsed = now - lastDetectAtMs
if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) {
lastDetectAtMs = now
runDetection(detectionPool.drain(), shiftEnabled = enabled)
}
if (!enabled) return resampled
// Streaming keeps the Hilbert filter history and mixer phase across chunks;
// shifting each chunk in isolation distorted the 62 samples at its edges.
return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE)
}
/**
* Discard buffered audio that was shifted by a now-stale amount.
*
* The live window and the pending archive chunk both hold shifted samples that
* cannot be un-shifted, so they are dropped rather than decoded against the new
* shift. Text already committed to [historyText] stays: it was correct when decoded.
*/
/**
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
*
* The live window has to go: it holds samples moved by two different amounts, and one
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
* already left the window, they were shifted consistently, and they are complete, so
* discarding them threw away decodable audio for no reason. They are left in place here to
* be archived by the normal path, which keeps this function non-suspending: its two
* callers sit on the synchronous capture path, and making them suspend to run an inference
* here would put a decode inside the tone-detection scan.
*
* It mattered because this runs on every change of shift, which tracks the detected tone,
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
* permanently empty however long the operator listened: the window was wiped before any
* batch could complete, so nothing was ever committed. With the record still concatenating
* the live decode at the time, each wipe visibly cut the transcript short as well - text
* going backwards, then never accumulating at all.
*/
/** Take every retired window, oldest first, leaving the queue empty. */
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
if (retiredAudio.isEmpty()) {
emptyList()
} else {
retiredAudio.toList().also { retiredAudio.clear() }
}
}
private fun dropBufferedAudio() {
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
// in one spectrogram smears the tone - but they are internally consistent and
// complete, so they decode fine on their own. Handed to the archive path rather than
// decoded here, because both callers sit on the synchronous capture path.
val retiring = buffer.snapshot()
if (retiring.isNotEmpty()) {
synchronized(retiredAudio) {
while (retiredAudio.size >= retiredAudioLimit) {
Log.w(TAG, "retired audio queue full, dropping the oldest window")
retiredAudio.removeFirst()
}
retiredAudio.addLast(retiring)
}
}
buffer.reset()
// Committed text stays: it was correct for audio that really was archived.
_historyText.value = committedText
}
/**
* Feed one detection to [shiftDecider] and log what it decided.
*
* The rule itself lives in core:domain so it can be tested directly; keeping it here
* meant tests could only restate it, and a restated rule cannot fail when the real
* one is wrong - four injected defects once left the whole suite green.
*/
private fun runDetection(sample: FloatArray, shiftEnabled: Boolean) {
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
// Published either way: the UI needs the real pitch to say why nothing decodes
// when shifting is off and the tone is out of range. Held through silences for
// the same reason the shift is - CW is gaps, and a gap is not a retune - but not
// indefinitely: without an expiry the last out-of-band reading survived every
// silent scan, so after retuning into the band the hint kept naming a frequency
// the operator had left. Ten seconds clears comfortably any real gap, the longest
// being about 1.7 s at 5 WPM between words plus a few seconds of thinking.
val tone = analysis.toneHz
if (tone != null) {
_detectedToneHz.value = tone
lastToneAtMs = System.currentTimeMillis()
} else if (System.currentTimeMillis() - lastToneAtMs > TONE_EXPIRY_MS) {
_detectedToneHz.value = null
}
if (!shiftEnabled) return
val decision = shiftDecider.accept(analysis)
_activeShiftHz.value = decision.shiftHz
when (decision.outcome) {
CwShiftDecider.Outcome.NO_TONE -> Log.d(
TAG,
"toneShift: no tone in ${sample.size} samples, keeping shift=${decision.shiftHz}Hz"
)
CwShiftDecider.Outcome.WITHIN_HYSTERESIS -> Log.d(
TAG,
"toneShift: tone=${decision.toneHz}Hz within ${CwShiftDecider.DEFAULT_HYSTERESIS_HZ}Hz " +
"of anchor ${shiftDecider.anchorToneHz}Hz, keeping shift=${decision.shiftHz}Hz"
)
CwShiftDecider.Outcome.NO_SHIFT_NEEDED -> Log.d(
TAG,
"toneShift: tone=${decision.toneHz}Hz inside " +
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
)
CwShiftDecider.Outcome.SHIFTED -> Log.i(
TAG,
"toneShift: tone=${decision.toneHz}Hz outside window, " +
"shifting ${decision.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
)
}
if (decision.changed) {
// The window still holds audio moved by the old amount. Mixing two shifts in
// one spectrogram smears the tone, and the pitch readout could only be right
// for one of them, so rebuild the window from the new shift.
Log.i(TAG, "toneShift: shift changed, dropping buffered audio")
dropBufferedAudio()
streamingShifter.reset()
CwProbe.step("tone_shift tone=${decision.toneHz} shift=${decision.shiftHz}")
}
}
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
@@ -252,20 +598,61 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
updateSignalMetrics(spectrogram)
}
/**
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
*
* Two places hold audio that would otherwise never be decoded into the record: the
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
* contents only ever reach the archive by being pushed out by newer audio. Together
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
* - which includes the end of every transmission, the part with the call sign in it.
*
* Order matters: the pending batch left the window before anything still in it, so it
* has to be archived first or the record comes out with its text transposed.
*/
override suspend fun flush() = archiveLock.withLock {
// Oldest first, all the way down: retired window contents, then the batch accumulating
// towards the threshold, then what is still live.
for (batch in drainRetiredAudio()) {
runCatching { archiveDecodeLocked(batch) }
.onFailure { if (it is CancellationException) throw it }
}
if (archiveSize > 0) {
val pending = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
runCatching { archiveDecodeLocked(pending) }
.onFailure { if (it is CancellationException) throw it }
}
// Draining the window empties it, so a second flush cannot double-archive the tail.
val window = buffer.snapshot()
if (window.isNotEmpty()) {
buffer.reset()
runCatching { archiveDecodeLocked(window) }
.onFailure { if (it is CancellationException) throw it }
}
// The live line described audio that is now in the record; leaving it would show the
// same characters twice, in two places, one of them stale.
_decodedText.value = ""
}
/**
* Decode a chunk of audio that has scrolled out of the live window and
* append it to [historyText]. Unlike the live window this never replaces —
* the archived audio is final, so its text is permanent.
*/
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
archiveDecodeLocked(audio)
}
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram)
if (text.isNotEmpty()) {
_historyText.value += text
}
committedText += text
_historyText.value = committedText
}
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
@@ -322,20 +709,54 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
val absoluteBin = 32 + bestBin
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
// Undo the shift before reporting: the spectrogram sees the moved tone, but
// the readout must show the pitch the operator actually hears on the radio.
_estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat()
val mean = total / count
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
// The meter claims something decodable is present, so it needs a tone the scan has
// actually confirmed inside the window - not merely the absence of a confirmed
// out-of-window one. Requiring the confirmation is what covers the intermittent
// case: a slow fist out of band at 15% duty scores 2.5 against MIN_PROMINENCE 4.5,
// so no tone is reported, and a condition keyed on "confirmed outside" stayed false
// and let the meter read half scale on window-edge leakage beside an empty
// transcript - the exact reading this gate exists to suppress.
val confirmed = _detectedToneHz.value
val decodable = confirmed != null &&
(_activeShiftHz.value != 0f || CwToneShifter.isInsideWindow(confirmed))
_signalStrength.value = if (decodable) prominence else 0f
}
/**
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
* flight can land its batch after this returns, leaving a few characters behind. The
* operator asked to clear and can ask again; making this suspend to close that window
* would push it onto every caller, including a synchronous button handler.
*/
override fun reset() {
antiAlias?.reset()
buffer.reset()
synchronized(retiredAudio) { retiredAudio.clear() }
_decodedText.value = ""
_historyText.value = ""
committedText = ""
archiveSize = 0
_estimatedPitch.value = null
_detectedToneHz.value = null
lastToneAtMs = 0L
_signalStrength.value = 0f
_lastInferenceMs.value = 0
// Re-detect from scratch: the operator may have retuned before resetting.
_activeShiftHz.value = 0f
shiftDecider.reset()
lastDetectAtMs = 0L
detectionPool.clear()
streamingShifter.reset()
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
// current setting instead of reporting a spurious change.
toneShiftWasEnabled = null
}
override fun close() {
@@ -69,6 +69,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
@@ -111,7 +113,10 @@ class MainContainer(private val context: Context) : IMainContainer {
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
// 面板与独立 CW 页各自持有自己的解码器
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context) {
// Read per chunk so toggling the setting applies without restarting capture.
settingsRepo.otherSettings.value.cwToneShiftEnabled
}
override fun provideSaveImage(): ISaveImage = SaveImage(context)
@@ -131,6 +136,22 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
/**
* QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull
* it out of SharedPreferences through LocalContext, putting disk access inside composition.
*/
private val qrzGridLookup: QrzGridLookup by lazy {
QrzGridLookup(
context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE),
OkHttpClient.Builder()
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.build()
)
}
override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.qrz
import android.content.SharedPreferences
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
/**
* Grid lookup backed by the cookie the operator pasted into settings.
*
* Owns the cookie read so the log screen does not: a composable used to pull it out of
* SharedPreferences through LocalContext on every submission, which put disk access inside
* composition and went around the repository layer.
*/
class QrzGridLookup(
private val preferences: SharedPreferences,
httpClient: OkHttpClient,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) : IQrzGridLookup {
private val source = QrzGridSource(httpClient, dispatcher)
override suspend fun lookup(callsign: String): QrzGrid {
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
// No cookie and an expired one call for the same thing from the operator, so they report
// the same way rather than adding a fourth outcome nobody could act on differently.
if (cookie.isBlank()) return QrzGrid.SignedOut
return source.lookupGrid(callsign, cookie)
}
override suspend fun signedInAs(): String? {
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
if (cookie.isBlank()) return null
return source.lookupOwnCallsign(cookie)
}
companion object {
/** Where the settings screen stores what the operator pasted. */
const val PREFS_NAME = "qrz_cookie"
const val COOKIE_KEY = "cookie"
}
}
@@ -0,0 +1,112 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.qrz
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Reads a station's Maidenhead locator off its QRZ.com page.
*
* QRZ has no free lookup API for this, so the page is fetched with the operator's own session
* cookie and parsed. The cookie is pasted by the operator in settings and never built into the
* app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only
* fetches and retries.
*/
class QrzGridSource(
private val httpClient: OkHttpClient,
private val dispatcher: CoroutineDispatcher
) {
/**
* Look up [callsign]'s locator.
*
* Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout
* used to mean the QSO was logged without a grid and the operator was never told. Retries
* are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds:
* only transport failures are retried, since a page that loaded and parsed will not parse
* differently on a second attempt.
*/
suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid =
withContext(dispatcher) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut
val url = "$DB_URL${callsign.trim().uppercase()}"
fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid)
?: QrzGrid.Unreachable(MAX_ATTEMPTS)
}
/**
* The callsign the pasted cookie is signed in as, so settings can show the operator whose
* account it belongs to rather than just claiming success.
*/
suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) {
if (cookieHeader.isBlank()) return@withContext null
fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign)
}
/** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */
/**
* Normalise whatever the operator pasted into a Cookie header value.
*
* They paste either a raw `k=v; k=v` header or the JSON array a cookie-export extension
* produces. The old client normalised this and the rewrite dropped it, so a JSON export that
* used to work went out as a literal JSON blob, QRZ served its signed-out page, and the app
* told the operator their cookie had expired when it was perfectly good.
*/
private fun normalise(raw: String): String = QrzGridParser.cookieHeader(raw)
private suspend fun fetchWithRetry(url: String, rawCookie: String): String? {
val cookieHeader = normalise(rawCookie)
if (cookieHeader.isBlank()) return null
repeat(MAX_ATTEMPTS) { attempt ->
try {
val request = Request.Builder().url(url)
.header("User-Agent", USER_AGENT)
.header("Cookie", cookieHeader)
.build()
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) return response.body.string()
// A 4xx will repeat identically, so only server-side faults are worth retrying.
if (response.code < 500) return null
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("QrzGridSource attempt ${attempt + 1} failed: $exception")
}
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt])
}
return null
}
private companion object {
/** Bare form is the signed-in home page; a callsign appended is that station's page. */
const val DB_URL = "https://www.qrz.com/db/"
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat"
const val MAX_ATTEMPTS = 3
/** Waits before the second and third attempt. Short enough to finish inside a pass. */
val BACKOFF_MS = longArrayOf(700L, 2_000L)
}
}
@@ -15,8 +15,13 @@ import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
/**
* One report from the AMSAT API (data layer model).
*
* Internal rather than private so [AmSatRepository.buildStatuses] can be unit-tested:
* the JSON parsing around it needs Android's JSONObject, which is a stub on the JVM.
*/
internal data class ApiReport(
val id: String,
val name: String,
val callsign: String,
@@ -46,7 +51,20 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
// The summary endpoint tells us how many reports each satellite actually has,
// independent of the 500-record cap. Mark any satellite whose global pull is
// incomplete so the UI can show a data-coverage note.
val summaryJson = remoteSource.getAmSatSummary(hours = 72)
val expectedCounts = parseSummary(summaryJson)
val marked = statuses.map { status ->
val expected = expectedCounts[status.name]
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
if (expected != null && expected > actual) status.copy(summaryCount = expected)
else status
}
SatStatusPage(System.currentTimeMillis(), marked, reportMap)
}
/** Parse catalog JSON to list of satellite names */
@@ -80,6 +98,33 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
}
}
/**
* Parse summary JSON to per-satellite report counts.
*
* The summary aggregates across all statuses, so a satellite with both "heard" and
* "not heard" entries appears once; we sum its report_count across all its rows.
* Returns an empty map (not null) on failure so the caller can just check for
* missing keys — a failed summary call degrades gracefully to "no coverage marker".
*/
private fun parseSummary(json: String?): Map<String, Int> {
if (json == null) return emptyMap()
return try {
val arr = JSONObject(json).getJSONArray("data")
val out = mutableMapOf<String, Int>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val name = o.optString("name", "")
val count = o.optInt("report_count", 0)
if (name.isNotEmpty() && count > 0) {
out[name] = (out[name] ?: 0) + count
}
}
out
} catch (_: Exception) {
emptyMap()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
@@ -89,22 +134,63 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
}
}
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
/**
* Build one SatStatus (3 days x 12 two-hour slots) per catalog satellite.
*
* Days are UTC calendar days and slots are fixed UTC bands, matching amsat.org: day 0
* is today, its slot 0 covers 22:00-24:00 UTC and slot 11 covers 00:00-02:00, so both
* the day list and the slots inside it read newest-first.
*
* A rolling window anchored on "now" was wrong: fetching at 06:07 UTC put 17.9 hours
* of yesterday into the cell labelled today. Checked against a live amsat.org page of
* 1021 reports, 73% landed in the wrong day column.
*/
internal fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
// Midnight UTC today, the anchor every slot boundary is derived from.
utc.timeInMillis = nowSec * 1000
utc.set(Calendar.HOUR_OF_DAY, 0)
utc.set(Calendar.MINUTE, 0)
utc.set(Calendar.SECOND, 0)
utc.set(Calendar.MILLISECOND, 0)
val todayMidnightSec = utc.timeInMillis / 1000
// Reuses the same Calendar, which is safe only because each pass assigns
// timeInMillis outright rather than adjusting fields. After this loop it points at
// the oldest day, so anything added below must set the time again before reading.
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
// Oldest report across the whole response, marking how far back the data reaches.
// Taken globally 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.
//
// Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time
// fails to parse, and a single such record would drag this back to 1970 and mark
// nothing as uncovered, silently reverting the distinction.
val dataFromSec = reports.asSequence()
.map { it.reportedTimeUtcSec }
.filter { it > 0L }
.minOrNull()
?: todayMidnightSec
return names.map { name ->
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
val satReports = byName[name].orEmpty()
val days = (0 until 3).map { dayIdx ->
val dayStart = todayMidnightSec - dayIdx * 86400L
val slots = (0 until 12).map { slotIdx ->
// Slot 0 is the last band of the day, so the day reads newest-first.
val slotStart = dayStart + (11 - slotIdx) * 7200L
val slotEnd = slotStart + 7200L
val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
// A slot entirely before the data starts is unknown, not silent.
val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY
SatSlot(statusColor = colour, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
@@ -114,8 +200,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
)
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
SatDay(dateLabel = labels[dayIdx], slots = slots)
}
SatStatus(name = name, days = days)
}
@@ -154,5 +239,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
/**
* Slots older than the data we actually received.
*
* The API caps at 500 records however many hours are requested. Measured live: a
* 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest
* 9.5 hours of the third day with no data at all. Painting those the same grey as
* "nobody reported" claimed knowledge we do not have, so they get a lighter shade.
*/
private const val NO_DATA_GRAY = 0xFFE8E8E8
}
}
@@ -42,6 +42,17 @@ class DatabaseRepo(
private val customSourceType = "Other"
/**
* Type key for satellites fetched from a custom URL.
*
* Separate from customSourceType because setSatelliteTypeIds overwrites rather than merges, so
* sharing "Other" with manual file import meant each wiped the other's type index. The
* satellites stayed in the database and stayed selectable either way - only their grouping in
* the type filter was lost - but the two sources are different things and deserve different
* keys.
*/
private val customUrlType = "Custom"
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
@@ -67,26 +78,46 @@ class DatabaseRepo(
override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
}.filterValues { it.isNotBlank() }
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
// the real behaviour was 26 failing requests.
//
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
// and nothing is deleted before the insert, so rows the new source does not mention survive.
// The type index for the skipped keys goes stale rather than empty, which is the honest
// outcome - it is the last known membership, not a claim about this fetch.
//
// The key is customUrlType, not "All": setSatelliteTypeIds early-returns on "All", so
// indexing under it was always a no-op and satellites from a custom URL were never
// reachable by the type filter at all. They now are.
val tleUrls = if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank()) {
mapOf(customUrlType to dataSourcesSettings.tleUrl)
} else {
Sources.satelliteDataUrls.filterValues { it.isNotBlank() }
}
val radioUrls = if (dataSourcesSettings.useCustomTransceivers &&
dataSourcesSettings.transceiversUrl.isNotBlank()
) {
mapOf("SatNOGS" to dataSourcesSettings.transceiversUrl)
} else {
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
}
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
if (successCount == 0) {
throw java.io.IOException("All data sources failed to download")
// Orbital elements are counted on their own. A combined count let a successful transceivers
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
// rather than 28, so if that URL was down and SatNOGS answered, the total was 1, no
// exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update
// that refreshed no orbital data at all - which also suppressed the 48-hour auto-update
// retry that keys off that timestamp. The failure existed before but 26 other sources hid
// it; replacing them made it easy to hit.
if (tleResults.none { it.second != null }) {
throw java.io.IOException("No orbital data source could be downloaded")
}
// parse fetched data concurrently and associate with types
val importedEntries = tleResults.flatMap { (url, stream) ->
@@ -105,6 +105,8 @@ class SettingsRepo(
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyCwToneShiftEnabled = "cwToneShiftEnabled"
private val keyAmsatDayStripes = "amsatDayStripes"
private val separatorComma = ","
@@ -405,6 +407,8 @@ class SettingsRepo(
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
putBoolean(keyAmsatDayStripes, new.amsatDayStripes)
}
new
@@ -431,7 +435,9 @@ class SettingsRepo(
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f),
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false),
amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true)
)
//endregion
@@ -449,18 +455,44 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
/** Placeholders a 4.4.7-era build could persist. Neither is a reachable address. */
private val placeholderTleUrl = "https://example.com/tle.txt"
private val placeholderRadioUrl = "https://example.com/radio.json"
private val keyPlaceholderUrlsMigrated = "placeholderUrlsMigrated"
/**
* Replace the example.com placeholders an old build could store.
*
* Runs once, following the pattern of migrateRCFormats. This used to be a rewrite applied on
* every read, so the stored value and the returned value disagreed indefinitely and nothing
* ever settled the difference.
*/
private fun migratePlaceholderUrls() {
if (preferences.getBoolean(keyPlaceholderUrlsMigrated, false)) return
preferences.edit {
if (preferences.getString(keyTleUrl, null) == placeholderTleUrl) {
putString(keyTleUrl, Sources.defaultTleUrl)
putBoolean(keyUseCustomTle, false)
}
if (preferences.getString(keyTransceiversUrl, null) == placeholderRadioUrl) {
putString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
putBoolean(keyUseCustomTransceivers, false)
}
putBoolean(keyPlaceholderUrlsMigrated, true)
}
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
migratePlaceholderUrls()
// The switch is reported as the operator set it. It used to be ANDed with
// `url != default`, so typing the default URL by hand switched custom sources off by
// itself and the settings screen showed a state nobody had chosen.
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
?: Sources.defaultTransceiversUrl
)
}
@@ -68,7 +68,7 @@ class RemoteSource(
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat/4.5.5")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
@@ -87,7 +87,7 @@ class RemoteSource(
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat/4.5.5")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
@@ -101,4 +101,22 @@ class RemoteSource(
null
}
}
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
.header("User-Agent", "Look4Sat/4.5.7")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat summary exception: $exception")
null
}
}
}
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
override fun invoke(resId: Int) {
invoke(context.getString(resId))
}
override fun invoke(resId: Int, vararg formatArgs: Any) {
invoke(context.getString(resId, *formatArgs))
}
}
@@ -0,0 +1,307 @@
package com.rtbishop.look4sat.core.data.aprs
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Socket-level tests against a stand-in APRS-IS server.
*
* These exist because the pure-logic tests could not catch the failures that matter here. A draft
* of [AprsIsClient.sendPacket] once wrote the packet with no line terminator at all: every send
* reported success, the server received a single unterminated stream, and nothing anywhere went
* red. APRS-IS is a line protocol and does not acknowledge position reports, so silence is the
* normal case - which means only a test that reads the bytes off a real socket can tell a
* delivered packet from a lost one.
*/
class AprsIsClientSocketTest {
/**
* A minimal APRS-IS server. Greets, answers the login as instructed, then records whatever
* lines arrive without acknowledging them, which is what the real network does.
*/
private class FakeServer(
private val greeting: String? = "# aprsc 2.1.19-g730c5c0",
private val loginResponse: String? = "# logresp TEST verified, server FAKE",
private val chatter: List<String> = emptyList(),
/** Sent right after the first packet arrives, to exercise the ack read. */
private val afterPacket: String? = null
) : AutoCloseable {
private val server = ServerSocket(0)
private val ready = CountDownLatch(1)
/**
* The accepted connection. Held because closing the ServerSocket only stops it listening
* - an established connection survives, so a test that wants a dead peer has to close
* this one. Getting that wrong made a correct implementation look broken.
*/
@Volatile
private var peer: Socket? = null
val port: Int get() = server.localPort
/** Every complete line the client sent after logging in. */
val received = mutableListOf<String>()
/** Raw bytes of the client's traffic, so a missing terminator is visible. */
val rawAfterLogin = StringBuilder()
@Volatile
var loginLine: String? = null
fun start() {
thread(isDaemon = true) {
runCatching {
server.accept().use { client ->
peer = client
val out = PrintWriter(client.getOutputStream(), true)
val input = BufferedReader(InputStreamReader(client.getInputStream()))
greeting?.let { out.print(it + "\r\n"); out.flush() }
loginLine = input.readLine()
chatter.forEach { out.print(it + "\r\n"); out.flush() }
loginResponse?.let { out.print(it + "\r\n"); out.flush() }
ready.countDown()
// Read lines but never acknowledge, exactly as APRS-IS treats positions.
var firstPacket = true
while (true) {
val line = input.readLine() ?: break
synchronized(received) {
received += line
rawAfterLogin.append(line)
}
if (firstPacket) {
firstPacket = false
afterPacket?.let { out.print(it + "\r\n"); out.flush() }
}
}
}
}
ready.countDown()
}
}
fun awaitLogin(): Boolean = ready.await(5, TimeUnit.SECONDS)
fun lines(): List<String> = synchronized(received) { received.toList() }
/** Close the established connection, so the client is talking to a dead peer. */
fun dropClient() {
runCatching { peer?.close() }
}
override fun close() {
runCatching { server.close() }
}
}
private fun client(port: Int, passcode: Int = 12345) = AprsIsClient(
host = "127.0.0.1",
port = port,
callsign = "TEST",
ssid = "",
passcode = passcode,
softwareName = "Look4Sat",
version = "test"
)
/**
* The regression that motivated this file. Two packets must arrive as two lines; without a
* terminator they concatenate into one stream the server can never parse, while both sends
* report success.
*/
@Test
fun `each packet arrives as its own line`() {
FakeServer().use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
val first = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
Thread.sleep(300)
c.disconnect()
assertEquals(true, first?.first)
assertEquals(true, second?.first)
val lines = server.lines()
assertEquals("both packets must reach the server as separate lines", 2, lines.size)
assertTrue(lines[0].endsWith(">one"))
assertTrue(lines[1].endsWith(">two"))
}
}
/** The login line has to be terminated too, or the server never reads it. */
@Test
fun `the server receives a complete login line`() {
FakeServer().use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
c.disconnect()
assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
}
}
/** A verified login is recognised and lets reports count as delivered. */
@Test
fun `a verified login is not reported as refused`() {
FakeServer().use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
assertTrue(c.isVerified)
assertFalse(c.isRefusedByServer)
c.disconnect()
}
}
/**
* The case the rewrite exists for: the server accepts the connection, the write succeeds,
* and every packet is discarded. The client must say so rather than report success.
*/
@Test
fun `an unverified login is flagged while the connection stays up`() {
FakeServer(loginResponse = "# logresp TEST unverified, server FAKE").use { server ->
server.start()
val c = client(server.port, passcode = -1)
c.connect()
assertTrue(server.awaitLogin())
assertFalse("unverified must not read as verified", c.isVerified)
assertTrue("the server explicitly refused", c.isRefusedByServer)
// Not a connection error: a receive-only login is legitimate and stays connected.
assertTrue(c.isConnected)
c.disconnect()
}
}
/**
* A chatty server used to exhaust a fixed line budget, turning an accepted login into
* Unknown and telling the operator their passcode was wrong when it had been accepted.
*/
@Test
fun `keepalive chatter before the verdict does not hide it`() {
// The real keepalive repeats the server identification with a timestamp, captured from
// euro.aprs2.net. A made-up "# keepalive N" would now read as a refusal, correctly - only
// greetings and verdicts are treated as harmless.
val chatter = List(8) { "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:0$it GMT T2UK 1.2.3.4:14580" }
FakeServer(chatter = chatter).use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
assertTrue("the verdict must be found past the comments", c.isVerified)
c.disconnect()
}
}
/**
* A server that sends no greeting is legitimate, and must not cost the full login window on
* every connect - that was eight seconds per attempt.
*/
@Test
fun `a server without a greeting connects promptly`() {
FakeServer(greeting = null).use { server ->
server.start()
val c = client(server.port)
val started = System.currentTimeMillis()
c.connect()
assertTrue(server.awaitLogin())
val elapsed = System.currentTimeMillis() - started
c.disconnect()
assertTrue("connect took ${elapsed}ms, expected well under the login window",
elapsed < 6_000)
}
}
/**
* The failure a live server actually produced, and the one that mattered most.
*
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
*/
@Test
fun `a refused login is not reported as a successful send`() {
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
server.start()
val c = client(server.port)
// An outright refusal throws from connect(), which is the correct outcome.
val threw = runCatching { c.connect() }.exceptionOrNull()
assertTrue(server.awaitLogin())
assertFalse("a refused login must not read as verified", c.isVerified)
val sentOk = runCatching {
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
}.getOrNull()
assertTrue(
"the refusal must surface: threw=$threw sentOk=$sentOk",
threw != null || sentOk != true
)
c.disconnect()
}
}
/**
* A server saying it is about to drop us must not read as a successful send.
*
* The ack read used to treat any leading `#` as harmless chatter, so `# Port full` - which
* means the server is closing the connection - was reported as sent. It now shares the login
* parser's judgement, so only a greeting or keepalive counts as harmless.
*/
@Test
fun `a server refusal after the write is not reported as sent`() {
FakeServer(afterPacket = "# Port full").use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
c.disconnect()
assertEquals("a server refusal must fail the report", false, result?.first)
}
}
/** The real keepalive must still count as sent, since APRS-IS never acknowledges a position. */
@Test
fun `a keepalive after the write still counts as sent`() {
val keepalive = "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580"
FakeServer(afterPacket = keepalive).use { server ->
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
c.disconnect()
assertEquals("a keepalive must not fail the report", true, result?.first)
}
}
/** Sending after the server has gone must report failure, not success. */
@Test
fun `a send after the server closes is reported as failed`() {
val server = FakeServer()
server.start()
val c = client(server.port)
c.connect()
assertTrue(server.awaitLogin())
// Closing the ServerSocket alone would leave this connection alive.
server.dropClient()
Thread.sleep(200)
// The first write may still land in the socket buffer; by the second the loss is certain.
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
c.disconnect()
assertEquals("a send on a dead connection must not report success", false, second?.first)
}
}
@@ -0,0 +1,150 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.cw
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.floor
/**
* How long audio waits before its text can reach the record pane.
*
* The record binds to archived text only. The live decode is rewritten from scratch every
* cycle, so a pane that concatenated it lost characters the operator had already read - the
* decoder appeared to delete its own output while still running. Binding to archived text
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
* pushed out of the live window, then accumulate into a full archive batch.
*
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
* These tests read its real constants rather than copies, so retuning one without
* reconsidering the user-visible delay fails here.
*/
class CwArchiveTimingTest {
/** Sending speed for the character counts, typical for satellite CW. */
private val wpm = 18.0
/** PARIS standard: one word is five characters. */
private val charsPerSecond = wpm * 5 / 60.0
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
private fun archivedSeconds(elapsed: Double): Double {
val evicted = elapsed - window
if (evicted <= 0.0) return 0.0
return floor(evicted / batch) * batch
}
@Test
fun thresholdMatchesTheDeclaredBatchLength() {
assertEquals(
"threshold must be the batch length in samples",
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
CwDeepDecoder.ARCHIVE_THRESHOLD
)
}
@Test
fun archiveBatchIsShorterThanACallSign() {
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
// means the record can stall for longer than the single most important thing being
// sent, which is what made the stall read as deletion.
val callSignSeconds = 7 / charsPerSecond
assertTrue(
"batch $batch s must not exceed a call sign at $wpm WPM " +
"(${"%.1f".format(callSignSeconds)} s)",
batch <= callSignSeconds
)
}
@Test
fun firstTextReachesTheRecordWithinHalfAMinute() {
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
// still completely empty: every decoded character had only ever been in the live line,
// which shows 64 characters and overwrites them.
val firstArchive = window + batch
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
}
@Test
fun aThirtySecondSessionStillProducesARecord() {
val archived = archivedSeconds(30.0)
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
}
@Test
fun theRecordNeverStallsForLongerThanOneBatch() {
var longestStall = 0.0
var lastGrowthAt = window
var previous = 0.0
var t = window
while (t <= 600.0) {
val archived = archivedSeconds(t)
if (archived > previous) {
longestStall = maxOf(longestStall, t - lastGrowthAt)
lastGrowthAt = t
previous = archived
}
t += 0.1
}
assertTrue(
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
longestStall <= batch + 0.11
)
}
@Test
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
// Why flush() exists. 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. Both hold the end of the transmission, where the call sign is.
val worstCase = window + batch
val lostCharacters = worstCase * charsPerSecond
assertTrue(
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
lostCharacters > 20
)
}
@Test
fun archivingStaysRarerThanTheLiveDecode() {
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
// stay rarer than the live redecode or the archive path becomes the dominant cost.
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
assertTrue(
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
batch > liveIntervalSeconds
)
}
@Test
fun aBatchIsLongEnoughToDecode() {
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
// silently dropped by archiveDecode's size guard and its text lost outright.
assertTrue(
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
)
}
}
@@ -0,0 +1,130 @@
package com.rtbishop.look4sat.core.data.cw
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* The gating contract the decoder relies on: shift only when the user opted in AND the
* tone is outside the model window.
*
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed
* here. What these tests do exercise is the real decision function the decoder calls -
* [CwToneShifter.analyse] - rather than a copy of it, so a wrong verdict fails here.
* The decoder's own sample accumulation and throttling are covered by the streaming
* tests in core:domain.
*/
class CwToneShiftGateTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
private fun tone(hz: Double, samples: Int = 1600): FloatArray = FloatArray(samples) { i ->
sin(2.0 * PI * hz * i / sampleRate).toFloat()
}
/**
* The enabled/disabled gate as [CwDeepDecoder.applyToneShift] applies it: when off
* the audio is returned as-is, when on the verdict comes from the real analyser.
*/
private fun gate(audio: FloatArray, enabled: Boolean): FloatArray {
if (!enabled) return audio
val analysis = CwToneShifter.analyse(audio, sampleRate)
if (!analysis.needsShift) return audio
return CwToneShifter.shift(audio, analysis.shiftHz, sampleRate)
}
@Test
fun `disabled leaves every tone untouched`() {
for (hz in listOf(150.0, 300.0, 800.0, 1200.0, 1500.0)) {
val audio = tone(hz)
assertSame(
"$hz Hz must pass through unchanged while the setting is off",
audio, gate(audio, enabled = false)
)
}
}
@Test
fun `enabled still leaves in-window tones untouched`() {
for (hz in listOf(400.0, 600.0, 800.0, 1000.0, 1200.0)) {
val audio = tone(hz)
assertSame(
"$hz Hz is inside the window; enabling the setting must not alter it",
audio, gate(audio, enabled = true)
)
}
}
@Test
fun `enabled shifts only out-of-window tones`() {
for (hz in listOf(200.0, 300.0, 1300.0, 1500.0)) {
val audio = tone(hz)
val result = gate(audio, enabled = true)
assertFalse("$hz Hz should have been shifted", result === audio)
assertEquals("shift must preserve length", audio.size, result.size)
}
}
@Test
fun `window edges count as inside`() {
val analysisLow = CwToneShifter.analyse(tone(CwDeepSpectrogram.MIN_FREQ_HZ), sampleRate)
val analysisHigh = CwToneShifter.analyse(tone(CwDeepSpectrogram.MAX_FREQ_HZ), sampleRate)
assertFalse("400 Hz is the lower edge, inside", analysisLow.needsShift)
assertFalse("1200 Hz is the upper edge, inside", analysisHigh.needsShift)
}
@Test
fun `shift target is inside the window`() {
assertTrue(
"the target must be a pitch the model can see",
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
)
}
/**
* Regression guard for the defect that made the whole feature dead on arrival:
* the decoder gated detection on a single chunk reaching DETECT_MIN_SAMPLES, but
* AudioCapture delivers 4410 samples at 44.1 kHz, which is only 320 after
* resampling to 3200 Hz. Detection could never run.
*
* The decoder now pools chunks, so what matters is that the pooled size is
* reachable: a handful of real-sized chunks must add up to enough audio.
*/
@Test
fun `pooled capture chunks reach the detection threshold`() {
val captureRate = 44100
val captureChunk = captureRate / 10 // AudioCapture's ~100 ms read
val resampledChunk = captureChunk * CwDeepSpectrogram.SAMPLE_RATE / captureRate
assertEquals(
"a capture chunk resamples to 320 samples; if this changes revisit pooling",
320, resampledChunk
)
val threshold = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES
val chunksNeeded = (threshold + resampledChunk - 1) / resampledChunk
assertTrue(
"a single chunk ($resampledChunk) must not be expected to reach $threshold",
resampledChunk < threshold
)
assertTrue(
"pooling must reach the threshold within a second of audio, needs $chunksNeeded chunks",
chunksNeeded in 2..10
)
// And that much audio must actually be enough for the detector to work.
val pooled = tone(1500.0, samples = threshold)
val detected = CwToneShifter.detectToneHz(pooled, sampleRate)
assertEquals(
"the pooled window must be long enough to detect a tone",
1500.0, detected!!.toDouble(), 25.0
)
}
}
@@ -0,0 +1,408 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
/**
* ADVERSARIAL AUDIT SCRATCH FILE - delete when the audit report is written.
* Probes buildStatuses for aliasing, midnight arithmetic and boundary defects.
*/
class AmSatAuditTest {
private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
}
private val repo = AmSatRepository(UnusedSource)
private fun utc(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0, s: Int = 0): Long {
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
c.clear(); c.set(y, mo - 1, d, h, mi, s)
return c.timeInMillis / 1000
}
private fun rep(name: String, at: Long, id: String, status: String = "heard") =
ApiReport(id, name, "T", status, "AA00", at)
private fun labelsAt(now: Long) =
repo.buildStatuses(listOf("X"), emptyList(), now).single().days.map { it.dateLabel }
/** Reference: the label a UTC instant's day should carry. */
private fun expectLabel(y: Int, mo: Int, d: Int): String {
val mn = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec")
return "${mn[mo - 1]} $d"
}
// ---------- 1. midnight arithmetic under hostile inputs ----------
@Test
fun auditMidnightExactlyAtMidnight() {
assertEquals(
listOf(expectLabel(2026, 8, 22), expectLabel(2026, 8, 21), expectLabel(2026, 8, 20)),
labelsAt(utc(2026, 8, 22, 0, 0, 0))
)
}
@Test
fun auditMidnightOneSecondBeforeAndAfter() {
assertEquals(
"23:59:59 on Aug 21 must still be Aug 21",
listOf("Aug 21", "Aug 20", "Aug 19"),
labelsAt(utc(2026, 8, 21, 23, 59, 59))
)
assertEquals(
"00:00:01 on Aug 22 must already be Aug 22",
listOf("Aug 22", "Aug 21", "Aug 20"),
labelsAt(utc(2026, 8, 22, 0, 0, 1))
)
}
@Test
fun auditLeapDay2028() {
assertEquals(
"Feb 29 2028 back to Feb 27",
listOf("Feb 29", "Feb 28", "Feb 27"),
labelsAt(utc(2028, 2, 29, 12))
)
assertEquals(
"Mar 1 2028 must reach back through the leap day",
listOf("Mar 1", "Feb 29", "Feb 28"),
labelsAt(utc(2028, 3, 1, 0, 0, 0))
)
assertEquals(
"Mar 1 2027 (no leap day) must skip straight to Feb 27",
listOf("Mar 1", "Feb 28", "Feb 27"),
labelsAt(utc(2027, 3, 1, 12))
)
}
@Test
fun auditYearBoundary() {
assertEquals(
listOf("Jan 1", "Dec 31", "Dec 30"),
labelsAt(utc(2027, 1, 1, 0, 0, 0))
)
assertEquals(
listOf("Jan 2", "Jan 1", "Dec 31"),
labelsAt(utc(2027, 1, 2, 23, 59, 59))
)
}
@Test
fun auditMonthBoundariesEveryMonth() {
// First of every month in a leap and a non-leap year.
for (year in listOf(2027, 2028)) {
for (mo in 1..12) {
val now = utc(year, mo, 1, 0, 0, 0)
val got = labelsAt(now)
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
ref.timeInMillis = now * 1000
val want = (0 until 3).map {
val c = ref.clone() as Calendar
c.add(Calendar.DAY_OF_MONTH, -it)
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH))
}
assertEquals("$year-$mo-01", want, got)
}
}
}
/**
* The load-bearing claim: subtracting 86400 equals Calendar day arithmetic in UTC.
* Proven exhaustively over 20 years of days rather than argued.
*/
@Test
fun auditSubtracting86400EqualsCalendarDayArithmeticForTwentyYears() {
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
var now = utc(2020, 1, 1, 12)
val end = utc(2040, 1, 1, 12)
var checked = 0
while (now < end) {
val got = labelsAt(now)
ref.timeInMillis = now * 1000
val want = (0 until 3).map {
val c = ref.clone() as Calendar
c.add(Calendar.DAY_OF_MONTH, -it)
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH))
}
assertEquals("at epoch $now", want, got)
now += 86400
checked++
}
assertTrue("must have checked >7000 days, got $checked", checked > 7000)
}
/**
* The device default zone must not reach the computation. Run the whole build under
* hostile default zones including ones with DST and half-hour offsets, and under the
* DST transition instants of those zones.
*/
@Test
fun auditDefaultTimeZoneCannotInfluenceTheGrid() {
val original = TimeZone.getDefault()
try {
val zones = listOf(
"UTC", "America/New_York", "Europe/Berlin", "Australia/Lord_Howe",
"Asia/Kolkata", "Pacific/Kiritimati", "Pacific/Niue", "Pacific/Chatham",
"America/Sao_Paulo", "Asia/Kathmandu"
)
// Instants that are DST transitions in at least one zone above.
val instants = listOf(
utc(2026, 3, 8, 7), utc(2026, 11, 1, 6), utc(2026, 3, 29, 1),
utc(2026, 10, 25, 1), utc(2026, 4, 5, 16), utc(2026, 10, 4, 16),
utc(2026, 8, 22, 0, 0, 0), utc(2026, 8, 22, 23, 59, 59),
utc(2027, 1, 1, 0, 0, 0), utc(2028, 2, 29, 0, 0, 0)
)
val baseline = HashMap<Long, List<String>>()
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
for (i in instants) baseline[i] = labelsAt(i)
for (z in zones) {
TimeZone.setDefault(TimeZone.getTimeZone(z))
for (i in instants) {
assertEquals("zone $z at $i", baseline[i], labelsAt(i))
// and the placement of a report must not move either
val s = repo.buildStatuses(
listOf("X"), listOf(rep("X", i - 3600, "r")), i
).single()
val cell = s.days.withIndex().flatMap { (d, day) ->
day.slots.withIndex().filter { "r" in it.value.reportIds }
.map { d to it.index }
}
assertEquals("zone $z placement at $i", 1, cell.size)
baseline["p$i".hashCode().toLong()]?.let { }
}
}
} finally {
TimeZone.setDefault(original)
}
}
/** Locale can swap the calendar system out from under Calendar.getInstance. */
@Test
fun auditDefaultLocaleCannotInfluenceTheGrid() {
val original = Locale.getDefault()
try {
val want = run {
Locale.setDefault(Locale.US)
labelsAt(utc(2026, 8, 22, 12))
}
for (l in listOf(
Locale("th", "TH", "TH"), Locale("ja", "JP", "JP"),
Locale("ar", "SA"), Locale.forLanguageTag("th-TH-u-ca-buddhist")
)) {
Locale.setDefault(l)
assertEquals("locale $l", want, labelsAt(utc(2026, 8, 22, 12)))
}
} finally {
Locale.setDefault(original)
}
}
// ---------- aliasing / shared Calendar state leak ----------
/**
* The shared Calendar is mutated by the labels loop after todayMidnightSec is read.
* If any later step re-read it, day 0 would inherit day 2's date. Prove day 0's
* slots are anchored on today, not on the last value the Calendar held.
*/
@Test
fun auditSharedCalendarIsNotReReadAfterTheLabelsLoop() {
val now = utc(2026, 8, 22, 12)
// A report at today 12:30 must be in day 0. If the anchor had leaked to Aug 20
// it would fall outside the grid entirely.
val s = repo.buildStatuses(
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 12, 30), "r")), now
).single()
assertEquals("Aug 22", s.days[0].dateLabel)
assertTrue("today's report must be in day 0 slot 5", "r" in s.days[0].slots[5].reportIds)
assertTrue(
"no other day may hold it",
s.days.drop(1).all { d -> d.slots.all { it.count == 0 } }
)
}
/** Two consecutive calls on the same repository must be identical (no instance state). */
@Test
fun auditRepeatedCallsAreIdempotent() {
val now = utc(2026, 8, 22, 12)
val reports = listOf(
rep("X", utc(2026, 8, 22, 1), "a"), rep("X", utc(2026, 8, 21, 23), "b"),
rep("X", utc(2026, 8, 20, 0, 0, 0), "c")
)
fun shape() = repo.buildStatuses(listOf("X"), reports, now).single()
.days.map { d -> d.dateLabel to d.slots.map { it.reportIds } }
val first = shape()
repeat(5) { assertEquals("call must not drift", first, shape()) }
}
// ---------- slot boundary exactness ----------
/** No report may appear in two cells, and none inside the window may vanish. */
@Test
fun auditEveryBoundaryInstantLandsInExactlyOneCell() {
val now = utc(2026, 8, 22, 12)
val mid = utc(2026, 8, 22, 0, 0, 0)
// every slot edge of all three days, and one second either side of each
val probes = ArrayList<Long>()
for (d in 0 until 3) for (s in 0..12) {
val edge = mid - d * 86400L + s * 7200L
probes.add(edge - 1); probes.add(edge); probes.add(edge + 1)
}
for (t in probes.distinct()) {
val s = repo.buildStatuses(listOf("X"), listOf(rep("X", t, "r")), now).single()
val hits = s.days.withIndex().flatMap { (di, day) ->
day.slots.withIndex().filter { "r" in it.value.reportIds }.map { di to it.index }
}
val inWindow = t >= mid - 2 * 86400L && t < mid + 86400L
if (inWindow) {
assertEquals("epoch $t must occupy exactly one cell, got $hits", 1, hits.size)
// and the cell's day must match the report's UTC date
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
c.timeInMillis = t * 1000
val want = expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH))
assertEquals("epoch $t day label", want, s.days[hits[0].first].dateLabel)
// slot index must invert the hour band
assertEquals("epoch $t slot", 11 - c.get(Calendar.HOUR_OF_DAY) / 2, hits[0].second)
} else {
assertEquals("epoch $t is outside the window", 0, hits.size)
}
}
}
/** Counts must sum to the number of in-window reports: nothing dropped, nothing doubled. */
@Test
fun auditCountsConserveReports() {
val now = utc(2026, 8, 22, 12)
val mid = utc(2026, 8, 22, 0, 0, 0)
val reports = ArrayList<ApiReport>()
var i = 0
var t = mid - 2 * 86400L
while (t < mid + 86400L) { reports.add(rep("X", t, "r${i++}")); t += 1801 }
val s = repo.buildStatuses(listOf("X"), reports, now).single()
val total = s.days.sumOf { d -> d.slots.sumOf { it.count } }
val ids = s.days.flatMap { d -> d.slots.flatMap { it.reportIds } }
assertEquals("every in-window report must be counted once", reports.size, total)
assertEquals("no id may repeat", ids.size, ids.toSet().size)
assertEquals("id set must be complete", reports.map { it.id }.toSet(), ids.toSet())
}
// ---------- duplicate catalogue names ----------
@Test
fun auditDuplicateCatalogueNamesProduceDuplicateRows() {
val s = repo.buildStatuses(
listOf("DUP", "DUP", "OTHER"),
listOf(rep("DUP", utc(2026, 8, 22, 11), "r")),
utc(2026, 8, 22, 12)
)
assertEquals("a duplicated catalogue name yields a duplicated row", 3, s.size)
assertEquals(2, s.count { it.name == "DUP" })
// both duplicated rows carry the same report -> the tap dialog double lists it
assertEquals(
listOf(1, 1),
s.filter { it.name == "DUP" }.map { it.days[0].slots[6].count }
)
}
@Test
fun auditReportsForNamesAbsentFromCatalogueAreSilentlyDropped() {
val s = repo.buildStatuses(
listOf("IN-CATALOG"),
listOf(rep("NOT-IN-CATALOG", utc(2026, 8, 22, 11), "ghost")),
utc(2026, 8, 22, 12)
)
assertTrue(
"a report whose satellite is not in the catalogue never renders",
s.single().days.all { d -> d.slots.all { it.count == 0 } }
)
}
// ---------- unparsable timestamps ----------
@Test
fun auditZeroTimestampFromFailedParseIsDroppedNotShownAsEpoch() {
val s = repo.buildStatuses(
listOf("X"), listOf(rep("X", 0L, "unparsable")), utc(2026, 8, 22, 12)
).single()
assertTrue(
"a 0L timestamp (parse failure) must not render",
s.days.all { d -> d.slots.all { it.count == 0 } }
)
}
// ---------- future reports ----------
@Test
fun auditFutureReportsLaterTodayStillRender() {
// Fetched at 07:00; a report stamped 23:00 today lands in slot 0 of today.
val s = repo.buildStatuses(
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 23), "later")), utc(2026, 8, 22, 7)
).single()
assertTrue("today's later bands are pre-drawn", "later" in s.days[0].slots[0].reportIds)
}
// ---------- complexity ----------
/** One pass per slot over the satellite's own reports; not O(all reports x slots). */
@Test
fun auditBuildIsLinearInReportsNotQuadratic() {
fun timeFor(nSats: Int, nReports: Int): Long {
val names = (0 until nSats).map { "S$it" }
val now = utc(2026, 8, 22, 12)
val mid = utc(2026, 8, 22, 0, 0, 0)
val reports = (0 until nReports).map {
rep(names[it % nSats], mid - (it % 172800).toLong(), "r$it")
}
repo.buildStatuses(names, reports, now) // warm
val t0 = System.nanoTime()
repeat(3) { repo.buildStatuses(names, reports, now) }
return System.nanoTime() - t0
}
val small = timeFor(88, 500)
val big = timeFor(88, 5000)
val ratio = big.toDouble() / small
println("AUDIT complexity: 500 reports=${small / 1_000_000}ms 5000=${big / 1_000_000}ms ratio=$ratio")
assertNotNull(ratio)
assertTrue("10x the reports must not cost >40x the time (ratio=$ratio)", ratio < 40)
}
/** toSatReport's YEAR is locale sensitive: proves whether the dialog date corrupts. */
@Test
fun auditReportDialogDateUnderThaiLocale() {
val original = Locale.getDefault()
try {
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
Locale.setDefault(Locale.US)
c.timeInMillis = utc(2026, 8, 22, 11) * 1000
val gregorianYear = c.get(Calendar.YEAR)
Locale.setDefault(Locale("th", "TH", "TH"))
val c2 = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
c2.timeInMillis = utc(2026, 8, 22, 11) * 1000
val thaiYear = c2.get(Calendar.YEAR)
println("AUDIT locale year: gregorian=$gregorianYear thai=$thaiYear class=${c2.javaClass.name}")
assertEquals(
"if these differ, toSatReport prints a Buddhist year in the dialog",
gregorianYear, thaiYear
)
} finally {
Locale.setDefault(original)
}
}
}
@@ -0,0 +1,366 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.util.Calendar
import java.util.TimeZone
/**
* Pins the grid the AMSAT status page draws.
*
* Two contracts matter. The day cell renders one stripe per slot, so "every day has
* exactly 12 slots, newest first" became load-bearing. And the day columns are UTC
* calendar days, so a report must land in the cell whose label matches its UTC date - an
* earlier rolling window anchored on "now" put 17.9 hours of yesterday into the cell
* labelled today, and 73% of a live 1021-report page landed in the wrong column.
*
* This drives [AmSatRepository.buildStatuses] directly rather than `fetchStatus`, because
* the parsing around it uses Android's `JSONObject`, a stub on the JVM: a `fetchStatus`
* test returns null for every input and proves nothing.
*/
class AmSatSlotBuildTest {
private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
}
private val repo = AmSatRepository(UnusedSource)
/** Epoch seconds for a UTC wall-clock instant, so every case reads unambiguously. */
private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.clear()
cal.set(year, month - 1, day, hour, minute, 0)
return cal.timeInMillis / 1000
}
/** Midday, so "today" has hours on both sides of the fetch. */
private val nowSec = utc(2026, 8, 22, 12)
private fun report(name: String, status: String, at: Long, id: String = "r-$name-$at") =
ApiReport(
id = id,
name = name,
callsign = "TEST",
report = status,
gridSquare = "AA00",
reportedTimeUtcSec = at
)
private fun build(names: List<String>, reports: List<ApiReport>) =
repo.buildStatuses(names, reports, nowSec)
@Test
fun `every day carries exactly twelve slots`() {
val statuses = build(
listOf("AO-91", "SO-50", "ISS"),
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11)))
)
assertEquals(3, statuses.size)
for (status in statuses) {
assertEquals("${status.name} must have 3 days", 3, status.days.size)
for (day in status.days) {
assertEquals(
"${status.name} ${day.dateLabel} must have 12 slots for the stripe renderer",
12, day.slots.size
)
}
}
}
@Test
fun `a satellite nobody reported still gets twelve slots per day`() {
// The renderer must never receive an empty list, which would draw nothing at all.
val status = build(listOf("QUIET-1"), emptyList()).single()
assertEquals(3, status.days.size)
status.days.forEach { assertEquals(12, it.slots.size) }
assertTrue(
"a silent satellite must be all no-report slots",
status.days.all { day -> day.slots.all { it.count == 0 } }
)
}
@Test
fun `days are labelled with UTC calendar dates`() {
val status = build(listOf("AO-91"), emptyList()).single()
assertEquals("today", "Aug 22", status.days[0].dateLabel)
assertEquals("yesterday", "Aug 21", status.days[1].dateLabel)
assertEquals("the day before", "Aug 20", status.days[2].dateLabel)
}
@Test
fun `the label does not drift with the time of day`() {
// The old rolling window relabelled the same data depending on when it was
// fetched. A calendar day must not care.
for (hour in listOf(0, 6, 12, 18, 23)) {
val labels = repo.buildStatuses(listOf("AO-91"), emptyList(), utc(2026, 8, 22, hour))
.single().days.map { it.dateLabel }
assertEquals("fetched at ${hour}:00 UTC", listOf("Aug 22", "Aug 21", "Aug 20"), labels)
}
}
@Test
fun `slots cover fixed UTC bands, newest first`() {
// Slot 0 is 22:00-24:00 and slot 11 is 00:00-02:00, matching amsat.org.
val status = build(
listOf("AO-91"),
listOf(
report("AO-91", "heard", utc(2026, 8, 22, 23), id = "lateToday"),
report("AO-91", "not heard", utc(2026, 8, 22, 1), id = "earlyToday")
)
).single()
val today = status.days[0]
assertTrue(
"23:00 belongs in slot 0, the day's last band",
"lateToday" in today.slots[0].reportIds
)
assertTrue(
"01:00 belongs in slot 11, the day's first band",
"earlyToday" in today.slots[11].reportIds
)
}
@Test
fun `a report lands in the day matching its UTC date`() {
val status = build(
listOf("AO-91"),
listOf(
report("AO-91", "heard", utc(2026, 8, 22, 11), id = "today"),
report("AO-91", "heard", utc(2026, 8, 21, 15), id = "yesterday"),
report("AO-91", "heard", utc(2026, 8, 20, 5), id = "dayBefore")
)
).single()
// Positions computed from the UTC bands: 11:00 -> slot 6, 15:00 -> slot 4,
// 05:00 -> slot 9.
assertTrue("today's report", "today" in status.days[0].slots[6].reportIds)
assertTrue("yesterday's report", "yesterday" in status.days[1].slots[4].reportIds)
assertTrue("the day before", "dayBefore" in status.days[2].slots[9].reportIds)
}
@Test
fun `a report just after midnight stays in the new day`() {
// The boundary the rolling window got wrong: 00:30 today must not appear as
// yesterday.
val status = build(
listOf("AO-91"),
listOf(report("AO-91", "heard", utc(2026, 8, 22, 0, 30), id = "justAfterMidnight"))
).single()
assertTrue(
"00:30 belongs to today's first band",
"justAfterMidnight" in status.days[0].slots[11].reportIds
)
assertTrue(
"yesterday must stay empty",
status.days[1].slots.all { it.count == 0 }
)
}
@Test
fun `each status maps to its own colour`() {
// The stripes are now the only carrier of status, so distinct states must stay
// distinct all the way out of the repository.
val at = utc(2026, 8, 22, 11)
val statuses = build(
listOf("A", "B", "C", "D"),
listOf(
report("A", "heard", at),
report("B", "telemetry only", at),
report("C", "not heard", at),
report("D", "something the api invented", at)
)
)
val colours = statuses.map { status -> status.days[0].slots[6].statusColor }
assertTrue("no state may be colourless", colours.none { it == 0L })
assertEquals(
"heard, telemetry and not heard must be visually distinct",
3, colours.take(3).toSet().size
)
}
@Test
fun `a slot keeps every report it contains`() {
// The tap dialog lists reports from the slots, so none may be dropped when several
// land in the same two-hour window. 10:00-12:00 is slot 6.
val status = build(
listOf("AO-91"),
listOf(
report("AO-91", "heard", utc(2026, 8, 22, 10, 15), id = "a"),
report("AO-91", "heard", utc(2026, 8, 22, 11, 0), id = "b"),
report("AO-91", "not heard", utc(2026, 8, 22, 11, 45), id = "c")
)
).single()
val slot = status.days[0].slots[6]
assertEquals("all three reports fall in the same band", 3, slot.count)
assertEquals(setOf("a", "b", "c"), slot.reportIds.toSet())
}
@Test
fun `a slot shows the newest status when reports disagree`() {
// Within one band the most recent observation wins; anything else would keep
// showing a failure after the satellite recovered.
fun colourFor(firstStatus: String, secondStatus: String): Long = build(
listOf("AO-91"),
listOf(
report("AO-91", firstStatus, utc(2026, 8, 22, 10, 15), id = "older"),
report("AO-91", secondStatus, utc(2026, 8, 22, 11, 45), id = "newer")
)
).single().days[0].slots[6].statusColor
assertTrue(
"the slot colour must follow the newest report, not the first",
colourFor("not heard", "heard") != colourFor("heard", "not heard")
)
}
@Test
fun `reports outside the three-day window are ignored`() {
val status = build(
listOf("AO-91"),
listOf(
report("AO-91", "heard", utc(2026, 8, 18, 12), id = "tooOld"),
report("AO-91", "heard", utc(2026, 8, 23, 12), id = "future")
)
).single()
assertTrue(
"nothing outside the window may appear",
status.days.all { day -> day.slots.all { it.count == 0 } }
)
}
@Test
fun `reports for other satellites do not leak between rows`() {
val statuses = build(
listOf("AO-91", "SO-50"),
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11), id = "onlyAo91"))
)
val ao91 = statuses.first { it.name == "AO-91" }
val so50 = statuses.first { it.name == "SO-50" }
assertEquals("AO-91 has its report", 1, ao91.days[0].slots[6].count)
assertTrue(
"SO-50 must stay empty",
so50.days.all { day -> day.slots.all { it.count == 0 } }
)
}
@Test
fun `an empty catalog yields no rows rather than a malformed grid`() {
assertTrue(build(emptyList(), emptyList()).isEmpty())
}
/**
* Slots older than the data we received must not claim nobody was listening.
*
* The API caps at 500 records however many hours are asked for. Measured live, a
* 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours
* of the third day had no data at all - 352 of 3168 cells were painting "nobody heard
* it" over "we never looked".
*/
@Test
fun `slots before the data starts are marked no-data, not no-report`() {
// The only report is midday yesterday, so nothing older than that was covered.
val oldestReport = utc(2026, 8, 21, 12)
val status = build(
listOf("AO-91"),
listOf(report("AO-91", "heard", oldestReport, id = "only"))
).single()
val noReport = 0xFFC0C0C0
val noData = 0xFFE8E8E8
// The day before yesterday is entirely before the data begins.
assertTrue(
"every slot older than the data must read as no-data",
status.days[2].slots.all { it.statusColor == noData }
)
// Yesterday straddles it: bands after midday are covered, bands before are not.
val yesterday = status.days[1]
assertEquals("the report's own band", 1, yesterday.slots[5].count)
assertTrue(
"bands after the oldest report are covered, so silence there is real",
yesterday.slots.take(6).all { it.statusColor != noData }
)
assertTrue(
"the earliest band of yesterday is before any data",
yesterday.slots[11].statusColor == noData
)
// Today is entirely after the data starts, so its silence is genuine.
assertTrue(
"today's empty slots mean nobody reported",
status.days[0].slots.all { it.statusColor == noReport }
)
}
@Test
fun `coverage is judged from all reports, not one satellite's`() {
// A satellite nobody reported must not show as no-data for the whole grid: the
// slots were covered, that satellite simply was not heard.
val statuses = build(
listOf("LOUD", "QUIET"),
listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early"))
)
val quiet = statuses.first { it.name == "QUIET" }
val noData = 0xFFE8E8E8
assertTrue(
"coverage reaches back to the earliest report of any satellite",
quiet.days.all { day -> day.slots.none { it.statusColor == noData } }
)
}
/**
* A report whose timestamp failed to parse must not disable the distinction.
*
* parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the
* minimum timestamp in the response - so one such record would put the coverage
* boundary in 1970 and mark every slot as reported-on. Measured on a grid that should
* have had 18 no-data cells, a single zero timestamp took it to none.
*/
@Test
fun `a report with an unparseable timestamp does not disable the no-data marking`() {
val noData = 0xFFE8E8E8
val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real")
val brokenTimestamp = ApiReport(
id = "broken",
name = "AO-91",
callsign = "TEST",
report = "heard",
gridSquare = "AA00",
reportedTimeUtcSec = 0L
)
val withoutBroken = build(listOf("AO-91"), listOf(realReport))
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp))
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0)
assertEquals(
"a zero timestamp must not change what counts as covered",
withoutBroken, withBroken
)
}
@Test
fun `an empty response marks nothing as covered`() {
// With no reports at all there is no evidence about any slot.
val status = build(listOf("AO-91"), emptyList()).single()
val noData = 0xFFE8E8E8
assertTrue(
"yesterday and earlier cannot be claimed as silent",
status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } }
)
}
}
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
@@ -102,8 +103,96 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
// A custom URL replaces the built-in TLE sources: none of them is requested. The
// transceivers group is separate and its own switch is off here, so it still fetches.
val builtInTle = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
assertTrue(
"no built-in TLE source may be fetched, got " + remoteSource.requestedUrls,
builtInTle.none { it in remoteSource.requestedUrls }
)
assertTrue(
"the operator's URL must be fetched",
customCsvUrl in remoteSource.requestedUrls
)
// Indexed under "Custom". It used to go under "All", where setSatelliteTypeIds
// early-returns, so the type filter never saw these satellites and the old assertion here
// was checking a no-op.
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Custom"])
assertEquals(null, settingsRepo.satelliteTypeIdsByType["All"])
// NOT "Other": that key belongs to manual file import, and setSatelliteTypeIds overwrites
// rather than merges, so sharing it would have each source wipe the other's index.
assertEquals(null, settingsRepo.satelliteTypeIdsByType["Other"])
}
/** The switch-off path must be untouched: all built-in sources, exactly as before. */
@Test
fun `without a custom source every built-in source is fetched`() = runTest(dispatcher) {
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
// Every built-in TLE source has to answer, or updateFromRemote throws because all of
// them failed, which would mask what this test checks. The transceivers group is left
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
// parse a radio payload on the JVM anyway.
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { validCsvStream() } }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
useCustomTLE = false,
useCustomTransceivers = false,
tleUrl = "https://example.com/ignored.csv",
transceiversUrl = ""
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val expected = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
assertTrue(
"expected all built-in sources, got " + remoteSource.requestedUrls.size,
expected.all { it in remoteSource.requestedUrls }
)
assertTrue(
"the custom URL must not be fetched when the switch is off",
"https://example.com/ignored.csv" !in remoteSource.requestedUrls
)
}
/**
* A dead custom URL must fail the update even when the transceivers source answers.
*
* The counts used to be added together, so one transceivers success covered a total orbital
* failure: no exception, and a fresh "updated successfully" timestamp for an update that
* refreshed nothing. Replacing the built-in sources shrank the denominator from 28 to 2 and
* made that easy to hit.
*/
@Test
fun `a dead custom url fails the update even if transceivers succeed`() = runTest(dispatcher) {
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { "[]".byteInputStream() } }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
useCustomTLE = true,
useCustomTransceivers = false,
tleUrl = "https://example.com/dead.csv",
transceiversUrl = ""
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
var threw = false
try {
repository.updateFromRemote()
} catch (_: java.io.IOException) {
threw = true
}
assertTrue("a total orbital failure must raise", threw)
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
}
private fun validCsvStream(): InputStream = """
@@ -122,13 +211,21 @@ private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
val requestedUrls = mutableListOf<String>()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? {
requestedUrls += url
return networkStreams[url]?.invoke()
}
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
}
private class FakeLocalSource : ILocalSource {
@@ -0,0 +1,156 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.aprs
/**
* Builds the position line this station puts on APRS-IS, or refuses to.
*
* Separated from the reporter so the packet can be tested without a socket. Every rule here
* comes from aprs-is.net/connecting.aspx, and each of them was being broken:
*
* - the path must be exactly `TCPIP*`, and was absent entirely
* - the line must not exceed 512 bytes including CRLF, and had no cap
* - the comment must not contain a line break, or it injects a second packet
* - a station with no position must not transmit, where 0.0 was substituted so 0 degrees north,
* 0 degrees east - a point in the Gulf of Guinea - went out under the operator's callsign
*/
object AprsBeacon {
/** The only path a client-originated packet may carry. */
const val PATH = "TCPIP*"
/** Destination for a position report with no addressee. */
const val DESTINATION = "APRS"
/** Maximum line length including the CRLF the caller appends. */
const val MAX_LINE_BYTES = 512
/** Comment limit for this position format, per the APRS specification. */
const val MAX_COMMENT = 43
/** Why a beacon could not be built. */
sealed interface Refusal {
/** No position was available. Transmitting 0,0 would claim the Gulf of Guinea. */
data object NoPosition : Refusal
/** The callsign is missing, so the packet would have no valid source. */
data object NoCallsign : Refusal
/** Latitude or longitude outside the possible range. */
data class ImpossiblePosition(val latitude: Double, val longitude: Double) : Refusal
}
/** Either a line ready to send, or the reason there is none. */
sealed interface Result {
data class Line(val text: String) : Result
data class Blocked(val refusal: Refusal) : Result
}
/**
* Build the position line.
*
* Returns [Result.Blocked] rather than a placeholder: a beacon is a claim about where the
* operator is, and there is no honest default for "nowhere".
*/
fun build(
callsign: String,
ssid: String,
latitude: Double?,
longitude: Double?,
symbolTable: String,
symbolCode: String,
comment: String
): Result {
if (callsign.isBlank()) return Result.Blocked(Refusal.NoCallsign)
if (latitude == null || longitude == null) return Result.Blocked(Refusal.NoPosition)
if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
return Result.Blocked(Refusal.ImpossiblePosition(latitude, longitude))
}
val source = AprsPacket.formatCallSsid(callsign.trim().uppercase(), ssid.trim())
val position = AprsPosition(
latitude = latitude,
longitude = longitude,
symbolTable = tableOf(symbolTable),
symbolCode = codeOf(symbolCode)
)
val header = "$source>$DESTINATION,$PATH:="
val body = position.toUncompressedString()
val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size
return Result.Line(header + body + sanitiseComment(comment, room))
}
/**
* Strip anything that would break the line, then trim to fit.
*
* A newline typed into the comment field used to end the packet early and start a second one
* from the remaining text - an injection the operator could trigger by accident.
*/
fun sanitiseComment(comment: String, room: Int = MAX_COMMENT): String {
if (room <= 0) return ""
val cleaned = comment.asSequence()
// Printable ASCII only: line breaks split the packet, and control characters have no
// meaning in a comment while being able to confuse a parser.
.filter { it.code in 0x20..0x7E }
.joinToString("")
.trim()
val limit = minOf(MAX_COMMENT, room)
return if (cleaned.length <= limit) cleaned else cleaned.take(limit)
}
/**
* The symbol table byte, defaulting to the primary table.
*
* Must be `/`, `\` or an overlay character. It was previously whatever the operator typed
* first - any character at all, including one that breaks the fixed-width parse. aprs.fi
* names symbol misconfiguration as the most common reason a station never appears on the map.
*/
fun tableOf(entry: String): Char {
val candidate = entry.trim().firstOrNull() ?: return TABLE_PRIMARY
return when {
candidate == TABLE_PRIMARY || candidate == TABLE_ALTERNATE -> candidate
candidate.isDigit() -> candidate
candidate in 'A'..'Z' -> candidate
else -> TABLE_PRIMARY
}
}
/** The symbol byte. Any printable character is a valid symbol; anything else is not. */
fun codeOf(entry: String): Char {
val candidate = entry.trim().firstOrNull() ?: return DEFAULT_SYMBOL
return if (candidate.code in 0x21..0x7E) candidate else DEFAULT_SYMBOL
}
private const val TABLE_PRIMARY = '/'
private const val TABLE_ALTERNATE = '\\'
/** Bytes the caller adds after the line. */
private const val CRLF_BYTES = 2
/** Fallback symbol. `>` is a car on the primary table - a reasonable stand-in for a phone. */
/**
* Substituted when the stored code is unusable.
*
* A house, not a car. The old default was '>' (CAR) with a comment conceding it was "a
* reasonable stand-in for a phone" - but a station beaconing from a handset showed up as a
* vehicle for every operator who was not driving, and aprs.fi names transmit-side symbol
* misconfiguration among the first things to check when a station looks wrong. A house is
* correct for most users and obviously wrong rather than misleading for the rest.
*/
private const val DEFAULT_SYMBOL = '-'
}
@@ -0,0 +1,140 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.aprs
/**
* The APRS-IS login line and the verdict the server returns for it.
*
* Kept apart from the socket so it can be tested: whether a login was verified decides whether
* anything this app sends reaches the network, and that distinction used to be made by a
* substring check inside a runCatching whose result was discarded, so it could not fail loudly.
*
* Format per aprs-is.net/connecting.aspx:
* `user mycall[-ss] pass passcode [vers softwarename softwarevers [filter ...]]`
*/
object AprsLogin {
/** What the server decided about a login attempt. */
sealed interface Outcome {
/** The passcode matched the callsign. Packets from this client are accepted. */
data class Verified(val callsign: String) : Outcome
/**
* The server accepted the connection but did not verify the login.
*
* Not an error at the socket level, which is exactly why it needs surfacing: writes keep
* succeeding while the server discards every packet. A receive-only login (passcode -1)
* lands here legitimately.
*/
data class Unverified(val callsign: String) : Outcome
/** The server refused the login outright. */
data class Rejected(val detail: String) : Outcome
/** Nothing recognisable arrived. The connection may still work; we simply do not know. */
data class Unknown(val detail: String) : Outcome
}
/** Any run of whitespace, collapsed to a hyphen inside a single token. */
private val WHITESPACE = Regex("""\s+""")
/** Passcode value that asks for a receive-only connection. */
const val RECEIVE_ONLY_PASSCODE = -1
/**
* Build the login line.
*
* `vers` takes TWO tokens - a software name and a version, separated by a space. An earlier
* version of this replaced that space with a hyphen, reading the rule "softwarename must not
* contain a space" as "the field must be a single token". Live aprsc 2.1.21 rejects the result:
*
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
* got: # Invalid login: software name and version are not separated by a space
*
* So name and version stay apart, and whitespace is collapsed WITHIN each of them instead.
*/
fun line(
callsign: String,
ssid: String,
passcode: Int,
name: String,
version: String,
filter: String = ""
): String {
val callSsid = AprsPacket.formatCallSsid(callsign, ssid)
val safeName = name.trim().replace(WHITESPACE, "-").ifEmpty { "Look4Sat" }
val safeVersion = version.trim().replace(WHITESPACE, "-").ifEmpty { "0" }
val base = "user $callSsid pass $passcode vers $safeName $safeVersion"
val trimmedFilter = filter.trim()
return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter"
}
/**
* Interpret one line of server output, or null when it carries no verdict.
*
* Classified by what is KNOWN HARMLESS rather than by a list of known refusals, because that
* list was incomplete and the failure is silent. aprsc refuses with `# Invalid login: ...` but
* also `# Login by user not allowed` - observed live on rotate.aprs2.net - and `# Port full`
* and `# Server full`. Each was skipped as chatter, 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.
*
* So identification and keepalive comments return null, a logresp is parsed, and anything else
* the server bothers to say during login counts as it objecting.
*
* Note that "unverified" contains "verified", so the negative is tested first - a naive
* contains("verified") reports every refusal as acceptance.
*/
fun parse(line: String): Outcome? {
val trimmed = line.trim()
if (trimmed.isEmpty()) return null
if (!trimmed.startsWith("#")) {
// A non-comment line during login is the server objecting in plain text.
return Outcome.Rejected(trimmed)
}
val lower = trimmed.lowercase()
if (lower.contains("logresp")) {
val callsign = callsignFrom(trimmed)
return when {
lower.contains("unverified") -> Outcome.Unverified(callsign)
lower.contains("verified") -> Outcome.Verified(callsign)
else -> Outcome.Unknown(trimmed)
}
}
// Identification and keepalives are the only comments that mean "keep reading".
if (HARMLESS.any { lower.startsWith(it) }) return null
return Outcome.Rejected(trimmed.removePrefix("#").trim())
}
/**
* Comment prefixes that carry no verdict.
*
* Matching a prefix rather than searching for refusal words means a refusal nobody anticipated
* is treated as a refusal instead of being ignored.
*/
private val HARMLESS = listOf("# aprsc", "# javaprssrvr", "# aprsis", "# filter")
/** The callsign token in `# logresp CALL verified, ...`, or empty when absent. */
private fun callsignFrom(response: String): String {
val tokens = response.removePrefix("#").trim().split(Regex("\\s+"))
val index = tokens.indexOfFirst { it.equals("logresp", ignoreCase = true) }
if (index < 0) return ""
return tokens.getOrNull(index + 1)?.trimEnd(',') ?: ""
}
}
@@ -22,12 +22,6 @@ object AprsPacket {
return hash and 0x7FFF
}
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
@@ -0,0 +1,101 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.aprs
/**
* Decides what passcode to present to APRS-IS, and whether the operator's entry is usable.
*
* The app must not derive a transmit passcode for the operator. APRS-IS states that supplying
* the correct passcode to a user is the software author's responsibility, and the passcode
* exists as a licence check - deriving it in-app and shipping the algorithm defeats the point.
* APRSdroid has the same algorithm in the same file and deliberately does not use it for this
* reason, validating the operator's entry instead and linking out to request one.
*
* So this validates. [AprsPacket.passcode] stays, because checking an entry means recomputing
* the expected value, but nothing here substitutes a derived code for a missing one.
*/
object AprsPasscode {
/** Value that asks APRS-IS for a receive-only connection. Always legitimate. */
const val RECEIVE_ONLY = -1
/** What the operator's passcode entry amounts to. */
sealed interface Entry {
/** A passcode that matches the callsign. Reports will be forwarded. */
data class Transmit(val passcode: Int) : Entry
/**
* An explicit -1, or a blank entry.
*
* A blank entry lands here rather than being filled in with a derived code: connecting
* receive-only is honest about what an operator without a passcode can do, where a
* derived code silently claims a licence check that was never performed.
*/
data object ReceiveOnly : Entry
/** Something was typed but it is not this callsign's passcode. */
data class Mismatch(val expectedFor: String) : Entry
/** Something was typed that is not a number at all. */
data object NotANumber : Entry
}
/**
* Classify what the operator typed.
*
* A mismatch is reported rather than corrected, so the UI can refuse to save and say why.
* Silently swapping in a derived code is how an operator ends up believing they are
* transmitting under a passcode they never obtained.
*/
fun classify(callsign: String, entry: String): Entry {
val trimmed = entry.trim()
if (trimmed.isEmpty()) return Entry.ReceiveOnly
val value = trimmed.toIntOrNull() ?: return Entry.NotANumber
// Checked before the callsign comparison: -1 is the documented receive-only value and
// is never anyone's passcode, so comparing it would report a deliberate choice as a typo.
if (value == RECEIVE_ONLY) return Entry.ReceiveOnly
val call = callsign.trim()
if (call.isEmpty()) return Entry.Mismatch("")
return if (value == AprsPacket.passcode(call)) {
Entry.Transmit(value)
} else {
Entry.Mismatch(call.uppercase())
}
}
/**
* The number to send in the login line for this entry.
*
* Anything not usable becomes [RECEIVE_ONLY]: the connection still works, the operator is
* told separately that their reports are not being forwarded, and no packet goes out under
* a passcode the app invented. The previous code sent a derived transmit passcode here,
* and `takeIf { it >= 0 }` additionally made an explicit -1 impossible to use - which also
* blocked the one safe way to test a setup, since a receive-only login is how you confirm
* the connection works without putting anything on the network.
*/
fun loginValue(callsign: String, entry: String): Int =
when (val classified = classify(callsign, entry)) {
is Entry.Transmit -> classified.passcode
else -> RECEIVE_ONLY
}
/** True when this entry lets the operator's reports reach the network. */
fun canTransmit(callsign: String, entry: String): Boolean =
classify(callsign, entry) is Entry.Transmit
}
@@ -0,0 +1,81 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.aprs
/**
* One APRS symbol an operator might plausibly want.
*
* [table] and [code] are the two characters that go into the packet. [descriptionKey] names a
* string resource rather than holding text, because core:domain has no access to resources and
* hardcoding English here would put wording outside the locale files.
*/
data class AprsSymbol(val table: Char, val code: Char, val descriptionKey: String)
/**
* A short list of symbols worth offering, instead of two free-text fields.
*
* The fields accepted anything and used only the first character, so typing "satellite" into the
* table field persisted the whole word and beaconed as `/` - the field lied about what it did.
* aprs.fi's own troubleshooting guidance puts transmit-side symbol misconfiguration among the first
* things to check when a station does not appear as expected.
*
* The strongest single argument for a list: `\S` is Satellite/Pacsat but `/S` is SHUTTLE. One
* keystroke apart, and both look correct to someone typing from memory.
*
* Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, 25 Nov 2015). The list is deliberately
* short - it covers fixed, portable, vehicle and satellite postures, not all 400-odd symbols.
*/
object AprsSymbols {
/** Fixed home station. Correct for most users, and wrong in an obvious way for the rest. */
val HOUSE = AprsSymbol('/', '-', "aprs_symbol_house")
val curated = listOf(
HOUSE,
AprsSymbol('\\', '-', "aprs_symbol_house_alt"),
AprsSymbol('/', '[', "aprs_symbol_person"),
AprsSymbol('/', 'y', "aprs_symbol_yagi"),
// Alternate table. /S is SHUTTLE, which is not what anyone means here.
AprsSymbol('\\', 'S', "aprs_symbol_satellite"),
AprsSymbol('/', ';', "aprs_symbol_portable"),
AprsSymbol('/', '$', "aprs_symbol_phone"),
AprsSymbol('/', 'I', "aprs_symbol_tcpip"),
AprsSymbol('\\', 'K', "aprs_symbol_ht"),
AprsSymbol('/', '>', "aprs_symbol_car"),
AprsSymbol('/', 'k', "aprs_symbol_truck"),
AprsSymbol('/', 'v', "aprs_symbol_van"),
AprsSymbol('/', 'R', "aprs_symbol_rv"),
AprsSymbol('/', 'b', "aprs_symbol_bike")
)
/**
* Find the curated entry matching a stored pair, or null when it is not on the list.
*
* Null matters: an operator may have set a symbol this list does not offer, and the picker must
* show it as-is rather than silently substituting the nearest entry.
*/
fun find(table: Char, code: Char): AprsSymbol? =
curated.firstOrNull { it.table == table && it.code == code }
/** Same, from whatever strings the settings screen holds. Blank means the shipped default. */
fun find(table: String, code: String): AprsSymbol? {
val t = table.firstOrNull() ?: HOUSE.table
val c = code.firstOrNull() ?: HOUSE.code
return find(t, c)
}
}
@@ -0,0 +1,195 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Low-pass filter applied before decimating to the model's sample rate.
*
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
* interpolating between samples, with nothing removing the content above the new
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
* of hiss folds down on top of the signal and lifts the noise floor across the entire
* display.
*
* The resampler itself is deliberately left alone: its comment notes it matches the
* reference implementation the model was trained against, so changing its arithmetic
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
* aliasing without touching that contract.
*
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
* smear the keying envelope the way a high-order IIR would.
*/
object CwAntiAlias {
/**
* Cut-off as a fraction of the target Nyquist.
*
* Below 1.0 so the transition band lands inside the discarded region rather than
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
* model's 1200 Hz window and the shifter's detection range with room to spare.
*/
private const val CUTOFF_FRACTION = 0.92
/**
* Filter length. Odd so the group delay is a whole number of samples.
*
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
* be sharper and slower; this runs on a phone during a pass.
*/
private const val TAPS = 127
/** Delay introduced by [TAPS], for callers that need to align another path. */
const val GROUP_DELAY_SAMPLES = TAPS / 2
/**
* Filter [audio] so that decimating to [targetRate] cannot alias.
*
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
*/
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
if (audio.isEmpty() || sourceRate <= targetRate) return audio
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
}
/**
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
*
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
* survives.
*/
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
val normalised = cutoffHz / sampleRate
val half = TAPS / 2
val raw = DoubleArray(TAPS) { i ->
val n = i - half
val sinc = if (n == 0) {
2.0 * normalised
} else {
sin(2.0 * PI * normalised * n) / (PI * n)
}
val window = 0.42 -
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
0.08 * cos(4.0 * PI * i / (TAPS - 1))
sinc * window
}
val sum = raw.sum()
// Unity DC gain, so filtering does not change the level the model was trained on.
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
}
/**
* Convolve, compensating for the filter's own delay so the output lines up with
* the input. Edge taps that fall outside the buffer see zeros, which costs the
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
*/
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
val out = FloatArray(audio.size)
for (i in audio.indices) {
var sum = 0f
for (k in kernel.indices) {
val j = i - k + GROUP_DELAY_SAMPLES
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
}
out[i] = sum
}
return out
}
/**
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
*
* Two things are needed for concatenated chunks to match a whole-buffer filter.
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
* outputs need the tail of the one before it.
*
* The second is less obvious and was measured rather than reasoned about. A
* linear-phase FIR is centred, so output sample `i` needs input up to
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
* last 44 samples of every chunk, which is a click at each boundary rather than a
* rounding difference.
*
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
* against a 20 WPM dot of roughly 60 ms.
*
* Not thread-safe: driven from the single capture coroutine.
*/
class Streaming(sourceRate: Int, targetRate: Int) {
private val kernel: FloatArray? =
if (sourceRate <= targetRate) {
null
} else {
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
}
/** Samples not yet emitted: filter history plus the lookahead still owed. */
private var pending = FloatArray(0)
/** Filter one chunk, continuing from the previous call. */
fun process(chunk: FloatArray): FloatArray {
val k = kernel ?: return chunk
if (chunk.isEmpty()) return chunk
val combined = FloatArray(pending.size + chunk.size)
pending.copyInto(combined)
chunk.copyInto(combined, pending.size)
// Only samples with a full window on both sides are ready. Everything from
// here on still needs input that has not arrived.
val ready = combined.size - TAPS + 1
if (ready <= 0) {
pending = combined
return FloatArray(0)
}
val out = FloatArray(ready)
for (i in 0 until ready) {
var sum = 0f
for (t in k.indices) {
sum += k[t] * combined[i + TAPS - 1 - t]
}
out[i] = sum
}
// Carry the tail that the next chunk will complete.
pending = combined.copyOfRange(ready, combined.size)
return out
}
/** Clear pending state, e.g. after a decoder reset. */
fun reset() {
pending = FloatArray(0)
}
}
}
@@ -49,12 +49,33 @@ object CwDeepSpectrogram {
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
const val HOP_LENGTH = 48
private const val MIN_FREQ_HZ = 400.0
private const val MAX_FREQ_HZ = 1200.0
/**
* Lower edge of the model's analysis window. Public so [CwToneShifter] can
* decide whether a detected tone falls outside it; the value is fixed by the
* trained model and must not be changed without retraining.
*/
const val MIN_FREQ_HZ = 400.0
/** Upper edge of the model's analysis window; see [MIN_FREQ_HZ]. */
const val MAX_FREQ_HZ = 1200.0
/** Number of frequency bins the model expects. */
const val FREQUENCY_BINS = 65
/**
* Widest span worth displaying: DC to Nyquist.
*
* The model reads [MIN_FREQ_HZ]..[MAX_FREQ_HZ], but a tone outside that range leaves
* no trace inside it - measured on keyed audio, the brightest column in the narrow
* view swings 1.01x between key-down and key-up, against 13.76x for a tone the model
* can see. So the narrow view cannot even show that a signal exists, and the display
* spans the whole band instead. Nothing above Nyquist can be shown at all: it aliases.
*/
const val DISPLAY_MIN_FREQ_HZ = 0.0
/** Upper end of the display span; see [DISPLAY_MIN_FREQ_HZ]. */
const val DISPLAY_MAX_FREQ_HZ = SAMPLE_RATE / 2.0
/** Milliseconds of audio represented by one output frame. */
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
@@ -104,17 +125,30 @@ object CwDeepSpectrogram {
*
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
*/
fun compute(audio: FloatArray): Array<FloatArray> {
fun compute(
audio: FloatArray,
minHz: Double = MIN_FREQ_HZ,
maxHz: Double = MAX_FREQ_HZ
): Array<FloatArray> {
require(audio.size >= FFT_LENGTH) {
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
}
val (startBin, stopBin) = frequencyBinRange(
SAMPLE_RATE, FFT_LENGTH, MIN_FREQ_HZ, MAX_FREQ_HZ
)
val (startBin, stopBin) = frequencyBinRange(SAMPLE_RATE, FFT_LENGTH, minHz, maxHz)
val bins = stopBin - startBin
require(bins == FREQUENCY_BINS) {
"expected $FREQUENCY_BINS bins, computed $bins"
require(bins > 0) { "empty bin range for $minHz..${maxHz}Hz" }
// The model's range must yield exactly the bin count it was trained on. Written as
// an implication rather than a disjunction of all three terms: `a != x || b != y ||
// bins == n` is satisfied by any custom range regardless of the bin count, which
// would leave the invariant unenforced for the caller most likely to break it.
val isModelRange = minHz == MIN_FREQ_HZ && maxHz == MAX_FREQ_HZ
require(!isModelRange || bins == FREQUENCY_BINS) {
"expected $FREQUENCY_BINS bins for the model range, computed $bins"
}
// Nothing may run off the end of the FFT output: a real signal has FFT_LENGTH / 2
// + 1 distinct bins, and asking beyond Nyquist would index past them.
require(stopBin <= FFT_LENGTH / 2 + 1) {
"maxHz ${maxHz}Hz is above Nyquist ${SAMPLE_RATE / 2}Hz"
}
val padded = reflectPad(audio, FFT_LENGTH / 2)
@@ -0,0 +1,88 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Pools capture chunks until enough audio is available for tone detection.
*
* [CwToneShifter.detectToneHz] scans bin by bin, so it needs a few hundred
* milliseconds to resolve a pitch. A capture chunk is only 320 samples once
* resampled to [CwDeepSpectrogram.SAMPLE_RATE], hence the pooling: without it a
* per-chunk size check can never be satisfied and detection silently never runs.
*
* A ring buffer rather than a sliding array. Detection is throttled to a couple of
* seconds while the pool fills in a few hundred milliseconds, so most chunks arrive
* at a full buffer; shifting the array down one slot per sample cost 320 copies of
* 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio on
* the capture thread. Writing to a ring index is O(1).
*
* Not thread-safe: the decoder drives it from a single capture coroutine.
*
* @param capacity samples retained; also the size [drain] returns once full.
*/
class CwDetectionPool(val capacity: Int) {
init {
require(capacity > 0) { "capacity must be positive, was $capacity" }
}
private val samples = FloatArray(capacity)
private var writeIndex = 0
/** Samples currently pooled, never above [capacity]. */
var size: Int = 0
private set
/** True once [capacity] samples are pooled and detection can run. */
val isReady: Boolean get() = size >= capacity
/** Add a chunk, overwriting the oldest samples once full. */
fun add(chunk: FloatArray) {
if (chunk.isEmpty()) return
// A chunk longer than the pool can only contribute its tail.
val start = maxOf(0, chunk.size - capacity)
for (i in start until chunk.size) {
samples[writeIndex] = chunk[i]
writeIndex = (writeIndex + 1) % capacity
if (size < capacity) size++
}
}
/**
* Hand over the pooled audio in chronological order and empty the pool.
*
* Oldest sample first: the detector measures a waveform, so returning the ring in
* storage order would splice it at the wrap point and corrupt every estimate.
*/
fun drain(): FloatArray {
val out = FloatArray(size)
// Once full the oldest sample sits at the write cursor; before that at index 0.
val oldest = if (size == capacity) writeIndex else 0
for (i in 0 until size) {
out[i] = samples[(oldest + i) % capacity]
}
clear()
return out
}
/** Discard everything pooled so far. */
fun clear() {
size = 0
writeIndex = 0
}
}
@@ -0,0 +1,115 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.abs
/**
* Decides what shift to apply from a sequence of tone estimates.
*
* Kept out of the decoder so the rule can be exercised directly. The decoder needs an
* Android Context and a loaded ONNX session, so a rule living inside it can only be
* tested by restating it - and a restated rule cannot fail when the real one is wrong.
* Mutation testing proved that: four defects injected into an in-decoder version of this
* logic left the whole suite green.
*
* @param hysteresisHz how far the tone must move before the shift is revised.
*/
class CwShiftDecider(private val hysteresisHz: Float = DEFAULT_HYSTERESIS_HZ) {
companion object {
/**
* Default margin before re-shifting, in Hz.
*
* Detection resolves to 12.5 Hz and a real tone wanders, so a couple of scan bins
* of jitter must not count as a retune: revising the shift costs the whole 20 s
* decode window, which is worth far more than perfect centring.
*/
const val DEFAULT_HYSTERESIS_HZ = 40f
}
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
var shiftHz: Float = 0f
private set
/**
* Tone that produced [shiftHz]. Hysteresis compares against this rather than against
* the previous shift, because a shift of 0 is a real state: at the window edge one
* 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large
* shift), and a shift-space comparison lapses exactly where the jump is largest.
*/
var anchorToneHz: Float? = null
private set
/** What [accept] decided, for logging. */
enum class Outcome {
/** No tone in the window; the existing shift was retained. */
NO_TONE,
/** The tone moved less than the margin; the existing shift was retained. */
WITHIN_HYSTERESIS,
/** The tone is inside the model window, so no shift is needed. */
NO_SHIFT_NEEDED,
/** The shift was updated to move an out-of-window tone into range. */
SHIFTED
}
/** Result of feeding one detection to the decider. */
data class Decision(
val outcome: Outcome,
/** Shift in force after the decision. */
val shiftHz: Float,
/** True when [shiftHz] differs from the value before this decision. */
val changed: Boolean,
/** Tone the decision was based on, null when none was detected. */
val toneHz: Float?
)
/**
* Feed one tone analysis and get the shift to apply.
*
* Silence retains the current shift rather than clearing it: CW is keyed, so a
* detection window landing in a gap carries no information about the pitch. Treating
* it as an authoritative "no shift" collapsed established shifts - measured over
* 180 s of keyed audio at 1400 Hz, 11 of 90 windows saw no tone, and each one left
* the following audio unshifted and therefore invisible to the model.
*/
fun accept(analysis: CwToneShifter.Analysis): Decision {
val previousShift = shiftHz
val toneHz = analysis.toneHz
?: return Decision(Outcome.NO_TONE, previousShift, changed = false, toneHz = null)
val anchor = anchorToneHz
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) {
return Decision(Outcome.WITHIN_HYSTERESIS, previousShift, changed = false, toneHz = toneHz)
}
shiftHz = analysis.shiftHz
anchorToneHz = toneHz
val outcome = if (analysis.needsShift) Outcome.SHIFTED else Outcome.NO_SHIFT_NEEDED
return Decision(outcome, shiftHz, changed = shiftHz != previousShift, toneHz = toneHz)
}
/** Forget the current shift and anchor, e.g. when the feature is toggled or reset. */
fun reset() {
shiftHz = 0f
anchorToneHz = null
}
}
@@ -0,0 +1,323 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/**
* Moves an out-of-range CW tone into the model's analysis window.
*
* The DeepCW model only sees [CwDeepSpectrogram.MIN_FREQ_HZ]..[CwDeepSpectrogram.MAX_FREQ_HZ];
* its input tensor width is fixed, so the window itself cannot be widened without
* retraining. Instead a tone that sits outside the window is frequency-shifted to
* [TARGET_HZ] before the spectrogram is built, which extends the usable pitch range
* to roughly 100 Hz..Nyquist without touching the model.
*
* ### Why single-sideband mixing
* Plain real mixing (`x * cos(2*pi*delta*t)`) produces both `tone+delta` and
* `tone-delta`. Measured on a 1500 Hz tone shifted to 800 Hz, the unwanted image
* folded back to 1000 Hz at 0.999 of the wanted amplitude — inside the window and
* as loud as the signal. Upsampling first only moves the problem: shifting a 300 Hz
* tone up produced a 200 Hz image at 0.996.
*
* A Hilbert transformer removes the negative-frequency half first, so mixing the
* resulting analytic signal yields one sideband only. Across nine probe tones
* (150..1550 Hz) that leaves a single spectral peak at the target with no component
* above 0.3 relative amplitude.
*
* All functions are pure; the caller decides whether shifting is wanted.
*/
object CwToneShifter {
/**
* Where an out-of-window tone is moved to: the centre of the analysis window,
* so the keying sidebands have equal headroom on both sides.
*/
const val TARGET_HZ = 800.0
/**
* Tones below this are treated as absent rather than shifted. Mains hum and DC
* drift live down here, and a real CW note that low is unusable anyway.
*/
const val MIN_DETECTABLE_HZ = 100.0
/**
* A detected peak must exceed the spectrum mean by this factor to count as a tone.
*
* Chosen from measurements on 1280-sample (400 ms) windows of keyed CW in noise.
* Pure noise peaks at 2.2-3.4 times its own spectral mean, so 3.0 admitted roughly
* one noise window in five. Raising it as far as 8.0 then rejected comfortably
* copyable signals: keyed CW measures 7.6-9.0 at 0 dB SNR and only 5.2-6.7 at -3 dB.
*
* 4.5 gives zero false positives across 40 noise windows while keeping the weaker
* end of usable signals. The asymmetry is deliberate: a false tone is worse than a
* missed one, because it moves a perfectly good signal out of the model's range,
* whereas a miss just leaves the audio alone until a stronger window arrives.
*
* Windows dominated by keying gaps (a slow fist, under ~25% tone) sit at 2.4 and are
* indistinguishable from noise at any threshold; those are skipped, not guessed at.
*/
const val MIN_PROMINENCE = 4.5
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
private const val HILBERT_TAPS = 63
/** Frequency resolution of [detectToneHz], in Hz. */
private const val DETECT_STEP_HZ = 12.5
/** Windowed Hilbert transformer: h[n] = 2/(pi*n) for odd n, 0 otherwise. */
private val hilbertKernel: FloatArray = FloatArray(HILBERT_TAPS) { i ->
val n = i - HILBERT_TAPS / 2
val ideal = if (n == 0 || n % 2 == 0) 0.0 else 2.0 / (PI * n)
// Hamming window; without it the truncated kernel ripples badly.
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (HILBERT_TAPS - 1))
(ideal * window).toFloat()
}
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
private const val HILBERT_DELAY = HILBERT_TAPS / 2
/** Outcome of inspecting a chunk of audio. */
data class Analysis(
/** Detected tone in Hz, or null when the audio is noise. */
val toneHz: Float?,
/** True when [toneHz] sits outside the model's window and can be shifted. */
val needsShift: Boolean,
/** Hz the tone would be moved by; 0 when no shift applies. */
val shiftHz: Float
)
/**
* Estimate the dominant tone by scanning [MIN_DETECTABLE_HZ]..Nyquist with a
* Goertzel-style single-bin DFT.
*
* Deliberately not reusing [CwDeepSpectrogram]: that clips to the model window,
* which is exactly the region an out-of-range tone is *not* in.
*
* @return the peak frequency, or null when nothing stands out from the noise.
*/
fun detectToneHz(audio: FloatArray, sampleRate: Int): Float? {
if (audio.size < 64) return null
val nyquist = sampleRate / 2.0
// A Hann window stops the scan from smearing energy across neighbours.
val window = FloatArray(audio.size) { i ->
(0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))).toFloat()
}
var bestHz = 0.0
var bestMagnitude = 0.0
var total = 0.0
var bins = 0
var hz = MIN_DETECTABLE_HZ
while (hz <= nyquist) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * PI * hz / sampleRate
for (i in audio.indices) {
val value = audio[i] * window[i]
real += value * cos(omega * i)
imag -= value * sin(omega * i)
}
val magnitude = hypot(real, imag) / audio.size
total += magnitude
bins++
if (magnitude > bestMagnitude) {
bestMagnitude = magnitude
bestHz = hz
}
hz += DETECT_STEP_HZ
}
if (bins == 0 || bestMagnitude <= 0.0) return null
val mean = total / bins
// Pure noise has a flat spectrum, so the peak barely beats the mean.
if (mean <= 0.0 || bestMagnitude < mean * MIN_PROMINENCE) return null
return bestHz.toFloat()
}
/**
* Decide whether [audio] needs shifting, without modifying it.
*
* A tone already inside the window is left alone: shifting it would add filter
* ringing and rounding for no benefit, and the model handles it natively.
*/
fun analyse(audio: FloatArray, sampleRate: Int): Analysis {
val tone = detectToneHz(audio, sampleRate)
?: return Analysis(toneHz = null, needsShift = false, shiftHz = 0f)
val inWindow = tone >= CwDeepSpectrogram.MIN_FREQ_HZ && tone <= CwDeepSpectrogram.MAX_FREQ_HZ
if (inWindow) return Analysis(toneHz = tone, needsShift = false, shiftHz = 0f)
return Analysis(
toneHz = tone,
needsShift = true,
shiftHz = (TARGET_HZ - tone).toFloat()
)
}
/**
* Shift [audio] by [shiftHz] using single-sideband mixing.
*
* The Hilbert transformer suppresses the negative-frequency half, so only the
* wanted sideband survives; see the class docs for the measured alternative.
* Returns a new array; [audio] is not modified.
*
* Stateless: [audio] is treated as an isolated signal, so the first and last
* [HILBERT_DELAY] samples convolve against zeros instead of the neighbouring
* audio. Fine for a whole buffer, but it corrupts 62 of every 320 samples when
* called per capture chunk, so streaming callers must use [Streaming].
*/
fun shift(audio: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
if (shiftHz == 0f || audio.isEmpty()) return audio
// Quadrature path: audio convolved with the Hilbert kernel.
val quadrature = FloatArray(audio.size)
for (i in audio.indices) {
var sum = 0f
for (k in hilbertKernel.indices) {
val j = i - k + HILBERT_DELAY
if (j >= 0 && j < audio.size) sum += hilbertKernel[k] * audio[j]
}
quadrature[i] = sum
}
// Re{(inPhase + j*quadrature) * e^(j*2*pi*shift*t)}
val out = FloatArray(audio.size)
val step = 2.0 * PI * shiftHz / sampleRate
for (i in audio.indices) {
val phase = step * i
out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase))
}
return out
}
/**
* Chunk-by-chunk shifter that carries the state [shift] cannot.
*
* Two things must survive across calls for concatenated chunks to form a clean
* signal:
*
* 1. **Filter history.** The Hilbert FIR spans [HILBERT_TAPS] samples, so the
* first outputs of a chunk need the previous chunk's tail. Without it those
* samples convolve against zeros; measured on 320-sample chunks that distorts
* 62 of them (19%) and inflates envelope ripple to 8.7x the whole-buffer
* baseline.
* 2. **Mixer phase.** Restarting the local oscillator at zero every chunk puts a
* phase step at every boundary.
*
* One difference from [shift] remains and is unavoidable: output sample `i` ideally
* needs input up to `i + HILBERT_DELAY`, which for the last samples of a chunk has
* not been captured yet. Those trailing taps therefore see zeros. Measured against
* a whole-buffer shift the divergence is confined to the final 3 samples of each
* 320-sample chunk and disappears immediately after the boundary — under 1% of the
* audio, versus a 20 WPM dot spanning 192 samples. Buffering a chunk to remove it
* would add 10 ms of latency for no decoding benefit.
*
* Not thread-safe: the decoder drives it from a single capture coroutine.
*/
class Streaming {
private val history = FloatArray(HILBERT_TAPS - 1)
private var phase = 0.0
/** Shift one chunk, continuing the filter and oscillator state. */
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
if (shiftHz == 0f || chunk.isEmpty()) {
// Still advance the history, so enabling a shift later starts from real
// audio rather than the silence left over from before.
pushHistory(chunk)
return chunk
}
// Convolve over [history || chunk] so every output sees real samples.
val combined = FloatArray(history.size + chunk.size)
history.copyInto(combined)
chunk.copyInto(combined, history.size)
val out = FloatArray(chunk.size)
val step = 2.0 * PI * shiftHz / sampleRate
for (i in chunk.indices) {
val centre = history.size + i
var quadrature = 0f
for (k in hilbertKernel.indices) {
val j = centre - k + HILBERT_DELAY
if (j >= 0 && j < combined.size) quadrature += hilbertKernel[k] * combined[j]
}
val currentPhase = phase + step * i
val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase)
out[i] = clampToUnit(mixed)
}
// Keep the phase bounded; letting it grow loses float precision.
phase = (phase + step * chunk.size) % (2.0 * PI)
pushHistory(chunk)
return out
}
/** Clear filter history and phase, e.g. after a decoder reset. */
fun reset() {
history.fill(0f)
phase = 0.0
}
/** Keep the most recent [history] samples of the stream. */
private fun pushHistory(chunk: FloatArray) {
if (chunk.isEmpty()) return
if (chunk.size >= history.size) {
chunk.copyInto(history, 0, chunk.size - history.size, chunk.size)
} else {
history.copyInto(history, 0, chunk.size, history.size)
chunk.copyInto(history, history.size - chunk.size)
}
}
}
/**
* Convenience wrapper: analyse [audio] and shift it only when the tone is
* outside the model window.
*
* @return the audio to feed the model (the original array when no shift was
* needed) paired with the [Analysis] that produced the decision, so callers
* can log what happened.
*/
fun shiftIfOutsideWindow(audio: FloatArray, sampleRate: Int): Pair<FloatArray, Analysis> {
val analysis = analyse(audio, sampleRate)
if (!analysis.needsShift) return audio to analysis
return shift(audio, analysis.shiftHz, sampleRate) to analysis
}
/**
* Keep a mixed sample inside the +/-1.0 range the spectrogram assumes.
*
* The Hilbert kernel has an L1 gain of 2.51, so summing the in-phase and quadrature
* paths can exceed unity even for a full-scale sine (measured 1.05 at 1500 Hz, 2.35
* for a square wave). The spectrogram takes log1p of the magnitude, so an overshoot
* is not fatal, but it shifts the level the model was trained on.
*/
private fun clampToUnit(value: Double): Float = when {
value > 1.0 -> 1f
value < -1.0 -> -1f
else -> value.toFloat()
}
/** True when [toneHz] lies inside the model's analysis window. */
fun isInsideWindow(toneHz: Float): Boolean =
toneHz >= CwDeepSpectrogram.MIN_FREQ_HZ && toneHz <= CwDeepSpectrogram.MAX_FREQ_HZ
}
@@ -37,15 +37,36 @@ interface ICwDecoder {
val decodedText: StateFlow<String>
/**
* Permanent transcript of everything that has scrolled out of the live
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
* what the user reads back after a signal has passed.
* Transcript of audio that has been archived, and will not be revised.
*
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
* redecode, so a pane that concatenates it loses text the operator has already read -
* which a paper log does not do. This is the flow such a pane must bind to.
*/
val historyText: StateFlow<String>
/** Detected tone frequency in Hz, or null before a tone is found. */
/**
* Pitch of the tone the model is decoding, in Hz, or null before one is found.
*
* Derived from the spectrogram, so it can only ever report a frequency inside the
* model's analysis window. For the pitch of a tone the model cannot see, use
* [detectedToneHz].
*/
val estimatedPitch: StateFlow<Float?>
/**
* Pitch of the loudest tone in the raw audio, in Hz, or null when none stands out.
*
* Unlike [estimatedPitch] this is measured before any shifting and over the full
* audio bandwidth, so it can report a tone the model's window excludes — which is
* the only way to tell the operator that nothing is being decoded because their tone
* is out of range.
*/
val detectedToneHz: StateFlow<Float?>
/** Current shift applied to bring the tone into the model's window, 0f when idle. */
val activeShiftHz: StateFlow<Float>
/** Relative signal strength in 0..1 for level meters. */
val signalStrength: StateFlow<Float>
@@ -55,6 +76,17 @@ interface ICwDecoder {
/** Non-null when the decoder cannot run, for example the model failed to load. */
val errorMessage: StateFlow<String?>
/**
* Decode whatever audio is still held in the pipeline into [historyText].
*
* Nothing reaches [historyText] until audio has been pushed out of the live window and
* then accumulated into a full archive batch, so the last stretch of a session is always
* still in flight when capture stops - and neither holding place drains on its own. That
* stretch is the end of the transmission, the part with the call sign in it. Call on
* pause and before [close].
*/
suspend fun flush()
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
@@ -23,10 +23,11 @@ data class SatDay(
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 5 days of state */
/** One satellite, 3 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 5 天(新→旧)
val days: List<SatDay>, // 3 天(新→旧)
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
)
/** Overall page parse result */
@@ -76,7 +76,22 @@ data class OtherSettings(
val wavelogAutoUpload: Boolean = false,
// Upstream radar compass offset (merged from rt-bishop)
val radarCompassOffset: Float = 0f,
val radarCompassOffsetElev: Float = 0f
val radarCompassOffsetElev: Float = 0f,
/**
* Shift a CW tone that sits outside the model's 400-1200 Hz analysis window into
* it before decoding. Off by default: when disabled the audio path is unchanged,
* and a tone already inside the window is never touched either way.
*/
val cwToneShiftEnabled: Boolean = false,
/**
* Draw each AMSAT day as twelve two-hour stripes rather than one colour.
*
* On by default: a single colour is taken from the first slot with a report, so a
* satellite that worked all morning and failed all afternoon looks identical to one
* that worked once. Some operators prefer the older, simpler tile, hence the switch.
*/
val amsatDayStripes: Boolean = true
)
data class DataSourcesSettings(
@@ -0,0 +1,45 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.qrz
/**
* Looks up a station's grid square on QRZ.
*
* An interface so the log screen can ask for a grid without reaching into core:data, and without
* reading the stored cookie itself - a composable was fetching it straight out of
* SharedPreferences through LocalContext, which put disk access in composition and bypassed the
* repository layer entirely.
*/
interface IQrzGridLookup {
/**
* Look up [callsign].
*
* Returns [QrzGrid.SignedOut] when no cookie is stored, since the operator's remedy is the
* same either way: put a valid cookie in settings.
*/
suspend fun lookup(callsign: String): QrzGrid
/**
* Check the stored cookie by asking QRZ whose account it belongs to.
*
* Returns the callsign QRZ reports, or null when the cookie is absent or no longer valid. Lets
* settings tell the operator which account they pasted rather than only claiming success.
*/
suspend fun signedInAs(): String?
}
@@ -0,0 +1,107 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.qrz
/**
* Outcome of a QRZ grid lookup.
*
* Four outcomes rather than a nullable string, because the previous null meant any of "the
* station has no grid on file", "the cookie expired", "the request timed out" and "QRZ changed
* its markup" - and the operator saw the same blank either way, with no way to tell that
* re-pasting the cookie would fix it.
*/
sealed interface QrzGrid {
/** The station's Maidenhead locator, as QRZ has it. */
data class Found(val locator: String) : QrzGrid
/** The page was read and the station has no locator published. Not an error. */
data object NotOnFile : QrzGrid
/** The detail table was absent, which is what QRZ serves when the cookie is not valid. */
data object SignedOut : QrzGrid
/** The request never completed. [attempts] is how many tries were made before giving up. */
data class Unreachable(val attempts: Int) : QrzGrid
}
/**
* Parsing of QRZ's callsign page, separate from the fetch so it can be tested without a
* network. Pure string work over already-downloaded markup.
*/
object QrzGridParser {
/** The detail row QRZ renders for a station that published a locator. */
private val gridRow = Regex("""<td class="dh">Grid Square</td>\s*<td class="di">([^<]+)</td>""")
/**
* QRZ's own words on a callsign page served to a visitor who is not signed in.
*
* Classified on this positive notice rather than on the detail table being absent: measured
* against live responses, a callsign QRZ has never heard of also returns HTTP 200 with zero
* detail rows, because QRZ serves its search form instead of a callsign page. Keying on
* absence therefore reported a mistyped callsign as an expired cookie, and would have sent
* the operator off to re-paste a cookie that was never broken.
*/
private val signedOutNotice = Regex("""Login is required for additional detail""")
/** The account menu on a signed-in page, used to read back whose cookie this is. */
private val accountCallsign = Regex("""<li class="leaf last"[^>]*>\s*([A-Z0-9/]+)\s*<ul""")
/** A cookie name=value pair inside a browser extension's JSON export. */
private val jsonCookie = Regex(""""name"\s*:\s*"([^"]+)"\s*,\s*"value"\s*:\s*"([^"]*)"""")
/**
* Interpret a callsign page.
*
* Only QRZ explicitly saying that a login is required counts as signed out, so an absent
* locator degrades to the harmless [QrzGrid.NotOnFile] and only a genuinely stale cookie
* sends the operator back to settings. Getting this wrong in either direction misdirects
* them: the old client returned null for everything, and keying on the detail table being
* absent would have blamed the cookie for a mistyped callsign.
*/
fun parseGrid(html: String): QrzGrid {
val locator = gridRow.find(html)?.groupValues?.get(1)?.trim()
if (!locator.isNullOrBlank()) return QrzGrid.Found(locator)
if (signedOutNotice.containsMatchIn(html)) return QrzGrid.SignedOut
return QrzGrid.NotOnFile
}
/** The callsign this cookie is signed in as, or null when it is not signed in. */
fun parseOwnCallsign(html: String): String? =
accountCallsign.find(html)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
/**
* Normalise the pasted cookie into a Cookie header value.
*
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
* since the operator pastes whatever their browser handed them. Parsed by regex rather
* than a JSON library because org.json is compileOnly here - it is supplied by Android at
* runtime and absent from unit tests, so a JSON path could not be tested.
*/
fun cookieHeader(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
if (!text.startsWith("[")) return text
val pairs = jsonCookie.findAll(text)
.map { it.groupValues[1] to it.groupValues[2] }
.filter { it.first.isNotBlank() }
.toList()
return if (pairs.isEmpty()) text else pairs.joinToString("; ") { "${it.first}=${it.second}" }
}
}
@@ -1,71 +0,0 @@
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object QrzGridClient {
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
if (text.startsWith("[")) {
return try {
val arr = org.json.JSONArray(text)
val parts = mutableListOf<String>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val name = o.optString("name")
val value = o.optString("value")
if (name.isNotBlank()) parts.add("$name=$value")
}
parts.joinToString("; ")
} catch (_: Exception) { text }
}
return text
}
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Detail table Grid Square row (verified format)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) { null }
}
}
@@ -50,6 +50,9 @@ interface IMainContainer {
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
/** QRZ grid lookup, so the log screen never touches the stored cookie itself. */
fun provideQrzGridLookup(): com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
}
data class MutualPassData(
@@ -28,4 +28,9 @@ interface IRemoteSource {
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
suspend fun getAmSatReports(hours: Int, limit: Int): String?
/** Fetch AMSAT API summary for the past N hours (JSON string; null on failure).
* Used to compare against the global reports response and flag satellites whose data
* was crowded out of the 500-record cap. */
suspend fun getAmSatSummary(hours: Int): String?
}
@@ -22,4 +22,12 @@ interface IShowToast {
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
/**
* Show a resource with format arguments, so a count can appear in a localised message.
*
* The alternative is building the string in a view model, which puts wording outside the
* resource files and hardcodes one language.
*/
operator fun invoke(resId: Int, vararg formatArgs: Any)
}
@@ -0,0 +1,125 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.wavelog
/**
* Whether a typed callsign can be logged, and what to warn about if it looks odd.
*
* Deliberately permissive. A survey of real callsigns against a typical strict pattern rejected
* 16 of 28 valid ones - `W1AW/4`, `2E0ABC`, `9A1CCY`, `SV2ASP/A` among them - while a pattern
* loose enough to accept those also accepts a Maidenhead locator as a callsign. There is no
* regex that catches typos without discarding legitimate calls, so anything plausible is
* accepted and doubt is reported rather than enforced.
*
* The previous behaviour was `if (call.length < 3) return`, which discarded the entry with no
* message: the operator pressed done during a pass and nothing happened, with no way to tell
* that the app had decided against them. None of the logging software surveyed - N1MM+, DXLog,
* PoLo, HAMRS - silently drops a submission.
*/
object CallsignEntry {
/** Shortest real callsign. Two characters occur in special event calls. */
private const val MIN_LENGTH = 2
/** Longest plausible entry, allowing a portable suffix such as `OH/W1AW/MM`. */
private const val MAX_LENGTH = 16
/** Characters a callsign may contain. */
private val allowed = Regex("^[A-Z0-9/-]+$")
/** The outcome of checking an entry. */
sealed interface Verdict {
/** Log it. [warning] is non-null when the entry is unusual but still plausible. */
data class Acceptable(val callsign: String, val warning: Warning? = null) : Verdict
/** Do not log it, and say why. */
data class Rejected(val reason: Reason) : Verdict
}
/** Why an entry cannot be logged at all. */
enum class Reason {
/** Nothing was typed. */
EMPTY,
/** Too short to be any callsign. */
TOO_SHORT,
/** Longer than any real callsign with a portable suffix. */
TOO_LONG,
/** Contains something a callsign cannot: punctuation, spaces, non-ASCII. */
ILLEGAL_CHARACTERS,
/** Digits only, or letters only - no callsign is either. */
NOT_A_CALLSIGN
}
/** Something worth mentioning without blocking the entry. */
enum class Warning {
/** Looks like a Maidenhead locator rather than a callsign, e.g. `GG77DH`. */
LOOKS_LIKE_A_GRID,
/** Already logged in this session - fine on a later pass, likely a slip on this one. */
ALREADY_WORKED
}
/**
* Check an entry, optionally against calls already logged in this pass.
*
* A repeat is a warning rather than a rejection: the same station on a later pass is a
* legitimate new contact, and contest loggers default to allowing duplicates - DXLog
* describes refusing them as an outdated habit.
*/
fun check(entry: String, workedThisSession: Set<String> = emptySet()): Verdict {
val call = entry.trim().uppercase()
if (call.isEmpty()) return Verdict.Rejected(Reason.EMPTY)
if (call.length < MIN_LENGTH) return Verdict.Rejected(Reason.TOO_SHORT)
if (call.length > MAX_LENGTH) return Verdict.Rejected(Reason.TOO_LONG)
if (!allowed.matches(call)) return Verdict.Rejected(Reason.ILLEGAL_CHARACTERS)
// Any segment may be the callsign, not just the first. A portable call can be written
// prefix-first - DL/W1AW, ZL/JA1ABC, OH/W1AW/MM - where the leading token is a country
// prefix with no digit in it. Testing only the first segment rejected all of those, which
// the old length-only check had accepted.
if (call.split('/', '-').none(::looksLikeCallsign)) {
return Verdict.Rejected(Reason.NOT_A_CALLSIGN)
}
val warning = when {
call in workedThisSession -> Warning.ALREADY_WORKED
looksLikeGrid(call) -> Warning.LOOKS_LIKE_A_GRID
else -> null
}
return Verdict.Acceptable(call, warning)
}
/** A segment that could be a callsign: contains both a letter and a digit. */
private fun looksLikeCallsign(segment: String): Boolean =
segment.any { it.isDigit() } && segment.any { it.isLetter() }
/**
* Whether this looks like a Maidenhead locator typed into the wrong field.
*
* Six characters of letter-letter-digit-digit-letter-letter. Worth mentioning because grid
* and callsign are exchanged together on FM satellites and the fields sit side by side.
*/
private fun looksLikeGrid(call: String): Boolean =
call.length == 6 &&
call[0].isLetter() && call[1].isLetter() &&
call[2].isDigit() && call[3].isDigit() &&
call[4].isLetter() && call[5].isLetter()
}
@@ -0,0 +1,143 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.wavelog
/**
* Checks a typed counterpart grid before it reaches the log.
*
* Separate from qthToPosition's validator, which requires six characters and is private. Plenty of
* satellite operators exchange only the four-character square, so requiring six would reject
* perfectly good entries - and this follows the same rule the callsign field settled on: refuse only
* what is certainly wrong, warn about the rest, never silently discard.
*
* The reason to check at all is that an unchecked value goes into the ADIF GRIDSQUARE field, and a
* malformed one is stored by Wavelog as-is. It then pollutes grid statistics and VUCC award
* tracking, where a wrong square is worse than a missing one.
*/
object GridEntry {
/** What a typed grid amounts to. */
sealed interface Verdict {
/** Usable. [normalised] is what should be logged - upper case for the pair, lower for the subsquare. */
data class Acceptable(val normalised: String, val warning: Warning? = null) : Verdict
/** Certainly not a grid. [reason] says which rule it broke. */
data class Unusable(val reason: Reason) : Verdict
/** Nothing typed. The QRZ lookup should run instead. */
data object Empty : Verdict
}
/** Worth mentioning but not worth refusing. */
enum class Warning {
/**
* Two characters. Legal per ADIF, but a field is 20 by 10 degrees - close to useless for a
* satellite contact, so it is worth saying rather than refusing.
*/
FIELD_ONLY,
/** Four characters, so the location is only accurate to about 100km. */
SQUARE_ONLY
}
/** Why an entry cannot be a grid. */
enum class Reason {
/** Not 4, 6 or 8 characters. Maidenhead has no other lengths. */
WRONG_LENGTH,
/** First pair outside A-R. S-X would decode past the poles. */
FIELD_OUT_OF_RANGE,
/** Second pair is not two digits. */
SQUARE_NOT_DIGITS,
/** Third pair outside A-X. */
SUBSQUARE_OUT_OF_RANGE
}
/**
* Judge a typed entry.
*
* Case is normalised on the way out rather than demanded on the way in - an operator typing
* one-handed outdoors should not have to care, and the conventional rendering is upper case for
* the field, digits, then lower case for the subsquare.
*/
fun check(entry: String): Verdict {
val text = entry.trim()
if (text.isEmpty()) return Verdict.Empty
if (text.length !in VALID_LENGTHS) return Verdict.Unusable(Reason.WRONG_LENGTH)
val upper = text.uppercase()
if (upper[0] !in FIELD_RANGE || upper[1] !in FIELD_RANGE) {
return Verdict.Unusable(Reason.FIELD_OUT_OF_RANGE)
}
// ASCII digits only. Char.isDigit() is Unicode-aware and covers the whole Nd category, so
// it accepted Arabic-Indic, Devanagari and fullwidth digits - which a localised keypad can
// produce without the operator seeing any difference. ADIF 3.1.7 defines Digit as "an ASCII
// character whose code lies in the range of 48 through 57", and Wavelog stores GRIDSQUARE
// verbatim, so such a value would never match a real grid in any statistics query.
// Guarded on length: a 2-character locator has no square pair, and reading index 2 of it
// would throw.
if (text.length >= SQUARE_LENGTH && (!upper[2].isAsciiDigit() || !upper[3].isAsciiDigit())) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
if (text.length >= SUBSQUARE_LENGTH) {
if (upper[4] !in SUBSQUARE_RANGE || upper[5] !in SUBSQUARE_RANGE) {
return Verdict.Unusable(Reason.SUBSQUARE_OUT_OF_RANGE)
}
}
if (text.length == EXTENDED_LENGTH && (!upper[6].isAsciiDigit() || !upper[7].isAsciiDigit())) {
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
}
return Verdict.Acceptable(
normalised = normalise(upper),
warning = when (text.length) {
FIELD_LENGTH -> Warning.FIELD_ONLY
SQUARE_LENGTH -> Warning.SQUARE_ONLY
else -> null
}
)
}
/** `OL72ap` - upper case field, digits, lower case subsquare, as the convention renders it. */
private fun normalise(upper: String): String = buildString {
append(upper.take(minOf(upper.length, SQUARE_LENGTH)))
if (upper.length >= SUBSQUARE_LENGTH) append(upper.substring(4, 6).lowercase())
if (upper.length == EXTENDED_LENGTH) append(upper.substring(6, 8))
}
/** ADIF 3.1.7 defines Digit as ASCII 48-57. Kotlin's isDigit() is far wider. */
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
private const val FIELD_LENGTH = 2
private const val SQUARE_LENGTH = 4
private const val SUBSQUARE_LENGTH = 6
private const val EXTENDED_LENGTH = 8
/**
* ADIF 3.1.7: GRIDSQUARE takes "2-character, 4-character, 6-character, or 8-character" locators.
* A 10 or 12 character locator stores its first 8 here and the rest in GRIDSQUARE_EXT, which
* neither WavelogQso nor Wavelog's own field list carries - so the extra pair has nowhere to go
* and the UI clips at 8, which produces the spec-correct GRIDSQUARE value.
*/
private val VALID_LENGTHS = setOf(FIELD_LENGTH, SQUARE_LENGTH, SUBSQUARE_LENGTH, EXTENDED_LENGTH)
private val FIELD_RANGE = 'A'..'R'
private val SUBSQUARE_RANGE = 'A'..'X'
}
@@ -0,0 +1,90 @@
/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name.
*
* Why the catalogue number and not the name: the same satellite carries different names in
* different TLE sources, so a name-keyed table misses whenever the user switches source.
* Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in
* both are named differently - NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the
* other, 43700 is "ES'HAIL 2" against "QO-100". The catalogue number is identical in every
* source, so it is the only stable key.
*
* LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list
* (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7
* instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling
* held in LotwSatellites rather than to whatever the TLE happens to say.
*
* Every number here was read out of live TLE data, never typed from memory. Entries cover the
* satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in
* the LoTW list; the rest of that list is satellites no source still carries, so no user can
* track them and no mapping is needed for them.
*/
package com.rtbishop.look4sat.core.domain.wavelog
object LotwSatelliteIds {
/**
* NORAD catalogue number to the LoTW spelling. The trailing comment is one name the
* satellite goes by in the sources, kept so a reader can recognise the entry.
*
* Three numbers had to be decided rather than derived, because one name matched several
* catalogued objects. Each was settled by which object the amateur-specific sources carry:
* - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY),
* (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work.
* - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls
* it ROBUSTA 1F, which is a different satellite.
* - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears
* only in the general catalogue.
*/
private val idToName: Map<Int, String> = mapOf(
7530 to "AO-7", // AO-07
14129 to "AO-10", // PHASE 3B (AO-10)
20439 to "AO-16", // OSCAR 16 (PACSAT)
20442 to "LO-19", // LO-19
22825 to "AO-27", // AO-27
23439 to "RS-15", // RADIO ROSTO (RS-15)
24278 to "FO-29", // FO-29
25544 to "ARISS", // ISS (ZARYA)
26609 to "AO-40", // PHASE 3D (AO-40)
26931 to "NO-44", // NO-44
27607 to "SO-50", // SAUDISAT 1C (SO-50)
28650 to "VO-52", // HAMSAT (VO-52)
39444 to "AO-73", // AO-73
40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1
40074 to "UKUBE1", // UKUBE-1
40908 to "CAS-3H", // LILACSAT-2
40931 to "IO-86", // IO-86
40967 to "AO-85", // FOX-1A (AO-85)
41847 to "CAS-2T", // CAS-2T
43017 to "AO-91", // AO-91
43678 to "PO-101", // DIWATA-2B
43700 to "QO-100", // ES'HAIL 2
43803 to "JO-97", // JO-97
44530 to "TAURUS", // TAURUS-1
44881 to "TO-108", // CAS-6 (TO-108)
44909 to "RS-44", // DOSAAF-85 (RS-44)
50466 to "HO-113", // CAMSAT XW-3 (CAS-9)
53109 to "IO-117", // GREENCUBE (IO-117)
61781 to "AO-123", // AO-123
// The TEVEL-2 constellation. Every source writes these TEVEL2-N while LoTW has TEV2-N,
// and no amount of separator-stripping bridges that - TEVEL21 is not TEV21 - so without
// these nine rows their QSOs upload under a name LoTW refuses. Note the numbering is not
// sequential: 63217 is TEVEL2-1 and 63213 is TEVEL2-4.
63213 to "TEV2-4",
63214 to "TEV2-5",
63215 to "TEV2-6",
63217 to "TEV2-1",
63218 to "TEV2-3",
63219 to "TEV2-2",
63237 to "TEV2-9",
63238 to "TEV2-7",
63239 to "TEV2-8"
)
/** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */
fun nameFor(catnum: Int): String? = idToName[catnum]
/** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */
fun isKnown(catnum: Int): Boolean = catnum in idToName
/** Entry count, so a test can catch the table being emptied by a bad edit. */
val size: Int get() = idToName.size
}
@@ -0,0 +1,122 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.wavelog
/**
* Works out what time a contact should carry.
*
* The logging screen used to stamp System.currentTimeMillis() and offer no way to change it. That
* assumes contacts are typed as they happen, and serious satellite operators do not work that way:
* the documented practice 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. A fixed clock makes
* every transcribed contact wrong by however long the transcription took.
*
* Two ways to say when: an absolute UTC time of day, or an offset from now. Both are typed into the
* same field, because a separate widget for each is more to reach for than an operator wants while
* holding an antenna.
*/
object PassClock {
/** What a typed token meant. */
sealed interface Command {
/** Log at this UTC time of day. [minuteOfDay] is minutes since 00:00 UTC. */
data class At(val minuteOfDay: Int) : Command
/** Log this many minutes from now. Negative counts backwards. */
data class Shift(val minutes: Int) : Command
/** Back to the current time. */
data object Live : Command
/** Not a time instruction. The caller should leave the clock alone. */
data object Unrecognised : Command
}
/**
* Interpret a typed token.
*
* Deliberately narrow. Anything that is not clearly a time is [Command.Unrecognised] rather than
* a guess, because a mis-parsed time silently backdates a contact and nothing downstream would
* catch it.
*
* Accepted: `14:55` or `1455` for a UTC time of day; `+3` or `-2` for a shift in minutes, with
* an optional `m`; empty or `now` to return to live time.
*/
fun parse(entry: String): Command {
val text = entry.trim().lowercase()
if (text.isEmpty() || text == "now") return Command.Live
if (text.startsWith("+") || text.startsWith("-")) return parseShift(text)
return parseTimeOfDay(text)
}
/** `+3`, `-2m`, `+15`. */
private fun parseShift(text: String): Command {
val negative = text.startsWith("-")
val digits = text.drop(1).removeSuffix("m")
val minutes = digits.toIntOrNull() ?: return Command.Unrecognised
if (minutes > MAX_SHIFT_MINUTES) return Command.Unrecognised
return Command.Shift(if (negative) -minutes else minutes)
}
/** `14:55` or `1455`. */
private fun parseTimeOfDay(text: String): Command {
val digits = text.replace(":", "")
if (digits.length != TIME_DIGITS || digits.any { !it.isDigit() }) return Command.Unrecognised
val hours = digits.take(2).toInt()
val minutes = digits.drop(2).toInt()
if (hours > MAX_HOUR || minutes > MAX_MINUTE) return Command.Unrecognised
return Command.At(hours * MINUTES_PER_HOUR + minutes)
}
/**
* Apply a command, returning the timestamp a contact should carry.
*
* [now] is the current UTC time in milliseconds and [dayStart] is midnight UTC of the day [now]
* falls in - passed in rather than computed, because core:domain holds no calendar and the
* caller already knows which day it is working with.
*
* An absolute time later than [now] is read as belonging to the previous day: transcription
* happens after the pass, so a pass that ran across midnight UTC is the common case, not an
* error. Without this a contact logged at 23:58 while transcribing at 00:05 would land a full
* day in the future.
*/
fun resolve(command: Command, now: Long, dayStart: Long): Long = when (command) {
is Command.At -> {
val candidate = dayStart + command.minuteOfDay * MILLIS_PER_MINUTE
if (candidate > now) candidate - MILLIS_PER_DAY else candidate
}
is Command.Shift -> now + command.minutes * MILLIS_PER_MINUTE
Command.Live, Command.Unrecognised -> now
}
/** Whether a command moves the clock off live time, so the UI can show that it has. */
fun isHolding(command: Command): Boolean =
command is Command.At || (command is Command.Shift && command.minutes != 0)
private const val TIME_DIGITS = 4
private const val MAX_HOUR = 23
private const val MAX_MINUTE = 59
private const val MINUTES_PER_HOUR = 60
/** A pass lasts minutes. Anything larger is a typo, not an intention. */
private const val MAX_SHIFT_MINUTES = 720
private const val MILLIS_PER_MINUTE = 60_000L
private const val MILLIS_PER_DAY = 86_400_000L
}
@@ -151,27 +151,62 @@ object WaveLogApi {
return if (up == down) "" else "$up/$down"
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
val commonName = mapOf(
"ZARYA" to "ARISS",
"ARISS" to "ARISS",
"FUNCUBE-1" to "AO-73",
"DIWATA-2B" to "PO-101",
"SAUDISAT-1C" to "SO-50",
"SAUDISAT 1C" to "SO-50",
"DIWATA-2A" to "PO-101"
)
val candidate = commonName[main] ?: main
return if (candidate in LotwSatellites.names) candidate else main
/**
* The name LoTW accepts for this satellite, resolved from its catalogue number when known.
*
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help
* page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or
* nothing useful at all.
*
* [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and
* of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
* differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is
* "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96
* satellites to something LoTW accepts, because the descriptive part of a TLE name is never
* the OSCAR designator.
*
* The name path remains as a fallback for QSOs logged before the catalogue number was
* recorded. It tries the whole name, then either side of the parentheses, since which side
* carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not.
*/
fun normalizeSatName(raw: String, catnum: Int? = null): String {
catnum?.let { LotwSatelliteIds.nameFor(it) }?.let { return it }
val trimmed = raw.trim()
for (candidate in nameCandidates(trimmed)) {
LotwSatellites.names.firstOrNull { it.equals(candidate, ignoreCase = true) }
?.let { return it }
}
// Tolerate a missing or extra hyphen: sources write RS15 where LoTW has RS-15.
for (candidate in nameCandidates(trimmed)) {
val squashed = candidate.squashSeparators()
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
?.let { return it }
}
return trimmed.uppercase(Locale.ENGLISH)
}
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
fun isLotwSatellite(name: String, catnum: Int? = null): Boolean {
val resolved = normalizeSatName(name, catnum)
return LotwSatellites.names.any { it.equals(resolved, ignoreCase = true) }
}
/** The whole name plus either side of the parentheses, longest first. */
private fun nameCandidates(raw: String): List<String> {
if (raw.isEmpty()) return emptyList()
val parts = mutableListOf(raw)
val open = raw.indexOf('(')
val close = raw.lastIndexOf(')')
if (open in 0..<close) {
parts += raw.substring(open + 1, close).trim()
parts += raw.substring(0, open).trim()
}
// Formation launches are catalogued as "RS-44 & BREEZE-KM R/B".
if ('&' in raw) parts += raw.substringBefore('&').trim()
return parts.filter { it.isNotEmpty() }.distinct()
}
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH)
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
@@ -184,7 +219,7 @@ object WaveLogApi {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
@@ -203,9 +238,19 @@ object WaveLogApi {
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
// The body decides, not the status code: Wavelog validates after responding, so a rejected
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
// and dropped it from the queue.
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
val v2Verdict = WavelogResponse.verdict(code, resp)
when (v2Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
// from uploading at all. The v1 attempt below is the one that can speak for them.
else -> Unit
}
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
@@ -215,13 +260,39 @@ object WaveLogApi {
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
val v1Verdict = WavelogResponse.verdict(code1, resp1)
when (v1Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
// Also falls through: a server with different rewrite rules answers this path with a
// 404 page, which is a rejection but says nothing about whether the QSO can be stored.
else -> Unit
}
// v1 without index.php
// v1 without index.php, for a server whose rewrite rules differ
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Rejected ->
return@withContext WavelogResult.Failure(verdict.reason)
is WavelogResponse.Verdict.Unreadable -> Unit
}
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
// Every endpoint answered something we could not read. Keeping the QSO queued is the only
// honest outcome: it may have been stored, and dropping it would lose the contact.
// No endpoint accepted it. The v2 reason is preferred when it explained itself, since a 401
// invalid_token is the most actionable thing an operator can be told; otherwise all three
// status codes go out, because the third was previously dropped from this message.
val reasons = listOfNotNull(
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
).filter { it.isNotBlank() }
WavelogResult.Failure(
reasons.firstOrNull()
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
" - " + shortError(resp1.ifBlank { resp1b }))
)
}
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
@@ -25,6 +25,14 @@ data class WavelogQso(
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
/**
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
*
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
* this field existed; those fall back to resolving from the name.
*/
val catnum: Int = 0,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
@@ -48,6 +56,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
catnum = o.optInt("catnum", 0),
sessionId = o.optString("sessionId"),
gridsquare = o.optString("gridsquare"),
uploaded = o.optBoolean("uploaded", false)
@@ -99,6 +108,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("catnum", q.catnum)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
@@ -0,0 +1,161 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.wavelog
/**
* Decides whether Wavelog actually accepted a QSO.
*
* The status code is not the answer. Wavelog validates after responding 200 and reports the
* outcome in the body, so a rejected QSO arrives as HTTP 200 with `{"status":"failed"}`. Trusting
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as
* the APRS reporter claiming a send succeeded when nothing had left the phone.
*
* Parsed as text rather than with JSONObject on purpose: org.json is compileOnly in core:domain,
* so a JVM unit test gets the stub and every assertion against it would be vacuous.
*/
object WavelogResponse {
/** What the server said about one QSO. */
sealed interface Verdict {
/** Stored. Safe to drop from the queue. */
data class Accepted(val detail: String) : Verdict
/** Wavelog already has it. Also safe to drop - the log is correct either way. */
data object Duplicate : Verdict
/** Rejected. [reason] carries the server's own wording when it gave one. */
data class Rejected(val reason: String) : Verdict
/** Unreadable, so keep the QSO queued rather than guess in either direction. */
data class Unreadable(val detail: String) : Verdict
}
/**
* Interpret a response.
*
* A body that reports failure beats a success code, because that is exactly the case the code
* alone gets wrong. An empty body with a success code counts as accepted, since the v1
* endpoint answers that way.
*/
fun verdict(statusCode: Int, body: String): Verdict {
val text = body.trim()
// Whitespace around separators is collapsed before matching, rather than listing every
// spacing a server might use: `{ "status" : "failed" }` is as valid as
// `{"status":"failed"}` and enumerating the combinations is endless.
val lower = text.lowercase().replace(AROUND_SEPARATORS, "")
// Duplicate reported three ways: a 409, the word in a message, or Wavelog's own
// `{"status":"dupe"}` - which arrives with HTTP 200 and is documented, not guessed.
// An HTML body is a proxy or a PHP fatal, never a verdict. Checked first because such a
// page carries no status token and would otherwise read as an acceptance, so a
// misconfigured reverse proxy answering 200 would eat contacts.
if (lower.startsWith("<")) return Verdict.Unreadable(text.take(MAX_DETAIL))
// Duplicate only when the STATUS says so, or on a 409. The word alone is not enough: the
// server's own rejection text is "Duplicate for <call>", and Api_v2 surfaces that inside
// validation_error bodies - so matching the bare substring classified a hard rejection as
// a duplicate, which maps to success and drops the QSO from the queue. That is the very
// defect this class was written to prevent, reached through a different door.
if (statusCode == DUPLICATE_CODE || lower.contains(DUPE_STATUS)) return Verdict.Duplicate
if (statusCode !in SUCCESS_CODES) {
return Verdict.Rejected(reasonFrom(text).ifBlank { "HTTP $statusCode" })
}
if (FAILURE_MARKERS.any { lower.contains(it) }) {
return Verdict.Rejected(reasonFrom(text).ifBlank { "server reported failure" })
}
// The v2 endpoint reports errors in an envelope with no status key at all.
if (lower.contains("\"error\":")) {
return Verdict.Rejected(reasonFrom(text).ifBlank { "server reported an error" })
}
// A status field we cannot read is not a success. Saying so keeps the QSO queued.
if (lower.contains("\"status\"") && SUCCESS_MARKERS.none { lower.contains(it) }) {
return Verdict.Unreadable(text.take(MAX_DETAIL))
}
// A bulk reply that stored nothing is not an acceptance, whatever its status says. Look4Sat
// posts one QSO per request so this is latent today, but `imported:0` reading as success
// would silently clear the queue if that ever changes.
if (importedZero(lower)) return Verdict.Rejected("server imported nothing")
return Verdict.Accepted(text.take(MAX_DETAIL))
}
/** Whether a bulk reply reports that no record was stored. */
private fun importedZero(lower: String): Boolean =
IMPORT_COUNT_KEYS.any { lower.contains("\"" + it + "\":0") }
/**
* The server's own explanation, when it gave one.
*
* Reads `reason`, `message` or `error` out of the JSON by hand. Crude, but it only has to work
* well enough to show the operator something more useful than a status code.
*/
private fun reasonFrom(body: String): String {
for (key in REASON_KEYS) {
Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"").find(body)
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
?.let { return it }
}
// An array of messages, which the v1 endpoint returns for a malformed ADIF record.
Regex("\"messages\"\\s*:\\s*\\[\\s*\"([^\"]*)\"").find(body)
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
?.let { return it }
return ""
}
private val SUCCESS_CODES = 200..299
private const val DUPLICATE_CODE = 409
private const val MAX_DETAIL = 200
/** Matched against the whitespace-collapsed body, so one spelling of each suffices. */
private val FAILURE_MARKERS = listOf(
"\"status\":\"failed\"",
"\"status\":\"error\"",
"\"result\":\"failed\""
)
/**
* Wavelog's documented success wordings, plus the ones its other endpoints use.
*
* `successful` matters as much as `success`: the API reference uses both, and treating one as
* unrecognised would leave a stored QSO queued forever.
*/
/**
* Success wordings, taken from the Wavelog server source rather than its documentation.
*
* The QSO endpoint answers `created`; `success` and `successful` come from other endpoints and
* are kept because a future version may use them. `abort` is NOT here - v1 uses it when any
* record in a batch failed, and it arrives with a 400.
*/
private val SUCCESS_MARKERS = listOf(
"\"status\":\"created\"",
"\"status\":\"success\"",
"\"status\":\"successful\"",
"\"status\":\"ok\""
)
/** `dupe` is Wavelog's own wording and arrives with a 200. */
/** Wavelog's own duplicate status. The bare word "duplicate" is deliberately NOT a marker. */
private const val DUPE_STATUS = "\"status\":\"dupe\""
/** Whitespace next to a colon or comma, which JSON allows and servers use inconsistently. */
private val AROUND_SEPARATORS = Regex("""\s*(?=[:,])|(?<=[:,])\s*""")
/** Count fields a bulk reply uses to say how many records it stored. */
private val IMPORT_COUNT_KEYS = listOf("imported", "adif_count")
private val REASON_KEYS = listOf("reason", "message", "error")
}
@@ -13,12 +13,32 @@ import org.json.JSONObject
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
/**
* The upload finished. [failedCount] entries stay in the queue.
*
* [reason] is machine-readable so the UI can pick its own wording; [firstError] carries the
* server's own explanation for the first failure, which is worth showing verbatim because it
* is the only thing that says WHY Wavelog refused a QSO.
*/
data class Done(
val successCount: Int,
val failedCount: Int,
val message: String,
val reason: Reason = Reason.COMPLETED,
val firstError: String = ""
) : UploadOutcome()
/** Why an upload ended, for the UI to phrase. */
enum class Reason {
/** Ran to completion. Check the counts. */
COMPLETED,
/** No server, key or station id configured. */
NOT_CONFIGURED,
/** The station profile could not be read - wrong id, or a key without permission. */
NO_STATION_INFO
}
}
class WavelogUploader(
@@ -36,13 +56,13 @@ class WavelogUploader(
val apiKey = settings.wavelogApiKey
val stationId = settings.wavelogStationId
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NOT_CONFIGURED)
}
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NO_STATION_INFO)
}
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
@@ -56,22 +76,23 @@ class WavelogUploader(
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
val entries = queue.all()
var ok = 0
var fail = 0
var uploaded = 0
var failed = 0
var firstError = ""
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
// Entries already confirmed by the server are skipped, and NOT counted: adding them to
// the total made a re-run report "N uploaded" for QSOs that went up days ago.
if (qso.uploaded) continue
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
if (result is WavelogResult.Success) {
ok++
uploaded++
queue.markUploaded(qso.id)
} else {
fail++
failed++
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
}
}
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message, firstError)
return UploadOutcome.Done(uploaded, failed, UploadOutcome.Reason.COMPLETED, firstError)
}
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
@@ -0,0 +1,174 @@
package com.rtbishop.look4sat.core.domain.aprs
import java.util.Locale
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
* broken by the previous one-line string template.
*/
class AprsBeaconTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private fun line(
latitude: Double? = 51.5,
longitude: Double? = -0.12,
callsign: String = "BG7NTA",
ssid: String = "5",
table: String = "/",
code: String = "[",
comment: String = "Look4Sat"
): String {
val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
assertTrue("expected a line, got $result", result is AprsBeacon.Result.Line)
return (result as AprsBeacon.Result.Line).text
}
/**
* The specification is explicit: a client-originated packet carries TCPIP* in the path,
* "nothing more or less". The old template emitted `CALL>APRS:=...` with no path at all.
*/
@Test
fun `the path is exactly TCPIP star`() {
val text = line()
assertTrue(text, text.startsWith("BG7NTA-5>APRS,TCPIP*:="))
assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
}
/**
* No position means no beacon. Substituting 0.0 put the station at 0N 0E - the Gulf of
* Guinea - on the global network, under the operator's own callsign.
*/
@Test
fun `a missing position refuses to build rather than claiming zero`() {
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
AprsBeacon.build("BG7NTA", "5", null, null, "/", "[", "x")
)
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
AprsBeacon.build("BG7NTA", "5", 51.5, null, "/", "[", "x")
)
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
AprsBeacon.build("BG7NTA", "5", null, -0.12, "/", "[", "x")
)
}
/** A genuine 0,0 fix is legal and must still be sent - the refusal is about absence. */
@Test
fun `an actual zero position is still a position`() {
val result = AprsBeacon.build("BG7NTA", "5", 0.0, 0.0, "/", "[", "x")
assertTrue(result is AprsBeacon.Result.Line)
}
@Test
fun `an impossible position is refused`() {
val result = AprsBeacon.build("BG7NTA", "5", 91.0, 0.0, "/", "[", "x")
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.ImpossiblePosition(91.0, 0.0)),
result
)
}
@Test
fun `no callsign means no packet`() {
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
AprsBeacon.build("", "5", 51.5, -0.12, "/", "[", "x")
)
assertEquals(
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
AprsBeacon.build(" ", "5", 51.5, -0.12, "/", "[", "x")
)
}
/**
* A newline in the comment ended the packet early and started a second one from the rest.
* The operator could trigger that by pressing return in a text field.
*/
@Test
fun `a line break in the comment cannot split the packet`() {
val text = line(comment = "hello\r\nCALL>APRS,TCPIP*:=injected")
// Only the line break matters. The text of a second packet surviving inside the comment
// is harmless - without a terminator the server reads one line, and a comment is free to
// contain any printable characters the operator likes.
assertFalse(text, text.contains('\n'))
assertFalse(text, text.contains('\r'))
assertEquals("must remain a single line", 1, text.lines().size)
}
@Test
fun `control characters are stripped from the comment`() {
val text = line(comment = "a\tb\u0000c")
assertTrue(text, text.endsWith("abc"))
}
/** The line must fit in 512 bytes including the CRLF the client appends. */
@Test
fun `an over-long comment is trimmed to keep the line legal`() {
val text = line(comment = "x".repeat(600))
assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512)
}
/** The comment limit for this format is 43 characters. */
@Test
fun `the comment respects the format's own limit`() {
assertEquals(AprsBeacon.MAX_COMMENT, AprsBeacon.sanitiseComment("y".repeat(100)).length)
}
/**
* Symbol misconfiguration is, per aprs.fi, the most common reason a station never appears.
* The table byte was previously the first character of whatever was typed.
*/
@Test
fun `the symbol table falls back to the primary table when invalid`() {
assertEquals('/', AprsBeacon.tableOf(""))
assertEquals('/', AprsBeacon.tableOf("!"))
assertEquals('/', AprsBeacon.tableOf(" "))
assertEquals('/', AprsBeacon.tableOf("/"))
assertEquals('\\', AprsBeacon.tableOf("\\"))
// Overlays are legal: a digit or an upper-case letter selects the alternate table.
assertEquals('7', AprsBeacon.tableOf("7"))
assertEquals('S', AprsBeacon.tableOf("S"))
}
@Test
fun `the symbol code falls back when unprintable`() {
// A house, not a car. The old fallback was '>' (CAR), which showed a phone-based beacon as
// a vehicle for every operator who was not driving.
assertEquals('-', AprsBeacon.codeOf(""))
assertEquals('-', AprsBeacon.codeOf(" "))
assertEquals('[', AprsBeacon.codeOf("["))
}
/**
* Coordinates are fixed-width digits. A locale that formats decimals with a comma would
* corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
*/
@Test
fun `a comma-decimal locale does not corrupt the coordinates`() {
val reference = line()
for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
Locale.setDefault(Locale.forLanguageTag(tag))
assertEquals("locale $tag changed the packet", reference, line())
}
}
/** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
@Test
fun `the line is pure ascii`() {
val text = line(comment = "café 北京")
assertTrue(text, text.all { it.code in 0x20..0x7E })
}
}
@@ -0,0 +1,209 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The verified/unverified distinction is the point of these tests: an unverified client stays
* connected and its writes keep succeeding while the server discards every packet, so getting this
* wrong means the operator watches reports "succeed" for hours with nothing landing.
*
* Several are transcriptions of live aprsc 2.1.21 traffic rather than guesses, because an earlier
* version of this file froze a wrong login format as correct and that defect survived nine commits
* and two audits.
*/
class AprsLoginTest {
@Test
fun `builds the line the specification asks for`() {
assertEquals(
"user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0",
AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0")
)
}
/**
* The format a live server rejects, and the reason this file was rewritten.
*
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
* got: # Invalid login: software name and version are not separated by a space
*
* `vers` is two tokens. The previous code hyphenated the space between them to satisfy the rule
* that the software NAME contains no space, and the test asserted that as correct.
*/
@Test
fun `the software name and version stay separate tokens`() {
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
assertEquals(7, line.split(" ").size)
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4"))
}
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */
@Test
fun `omits the ssid when there is none`() {
assertEquals(
"user BG7NTA pass 12345 vers Look4Sat 4.6.0",
AprsLogin.line("BG7NTA", "", 12345, "Look4Sat", "4.6.0")
)
}
/** Whitespace inside either token would shift every field after it, so it is collapsed. */
@Test
fun `whitespace within a token is collapsed rather than passed through`() {
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look 4 Sat", "4.6.0 beta")
assertEquals("user BG7NTA-5 pass 12345 vers Look-4-Sat 4.6.0-beta", line)
assertEquals(7, line.split(" ").size)
}
@Test
fun `appends a filter when one is given`() {
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0", "r/33.25/-96.5/50")
assertEquals("user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0 r/33.25/-96.5/50", line)
}
@Test
fun `receive-only logins use the documented passcode`() {
val line = AprsLogin.line("BG7NTA", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.6.0")
assertEquals("user BG7NTA pass -1 vers Look4Sat 4.6.0", line)
}
/** Empty tokens would produce a malformed line, so each has a fallback. */
@Test
fun `empty name or version does not produce a malformed line`() {
val line = AprsLogin.line("BG7NTA", "", 12345, "", "")
assertEquals("user BG7NTA pass 12345 vers Look4Sat 0", line)
assertEquals(7, line.split(" ").size)
}
/**
* The refusal that used to be invisible. It is a comment but not a logresp, so it was skipped
* as chatter; the login then timed out into Unknown, which is treated as "may be working", and
* every send afterwards reported success against a server that had refused the login.
*/
@Test
fun `an invalid login comment is a refusal, not chatter`() {
val outcome = AprsLogin.parse(
"# Invalid login: software name and version are not separated by a space"
)
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected)
assertEquals(
"Invalid login: software name and version are not separated by a space",
(outcome as AprsLogin.Outcome.Rejected).detail
)
}
@Test
fun `a login denied comment is also a refusal`() {
assertTrue(AprsLogin.parse("# Login denied") is AprsLogin.Outcome.Rejected)
}
/**
* Every refusal, not only the anticipated ones. A first attempt listed the wordings it knew and
* skipped everything else as chatter, which missed three - including `# Login by user not
* allowed`, observed live on rotate.aprs2.net, the most commonly used rotating hostname.
*/
@Test
fun `refusals nobody anticipated are still refusals`() {
val refusals = listOf(
"# Login by user not allowed",
"# Port full",
"# Server full",
"# Invalid login: software name and version are not separated by a space",
"# Some wording nobody has seen yet"
)
for (line in refusals) {
assertTrue(
"must be a refusal: $line",
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected
)
}
}
/** The greetings and keepalives that must NOT read as refusals under that rule. */
@Test
fun `greetings and keepalives are still skipped`() {
val harmless = listOf(
"# aprsc 2.1.21-gbfc2090",
"# aprsc 2.1.19-g730c5c0",
"# javAPRSSrvr 4.4.3b19",
"# filter myfilter active"
)
for (line in harmless) {
assertNull("must be skipped so the caller keeps reading: $line", AprsLogin.parse(line))
}
}
/**
* "unverified" contains "verified", so the negative has to be tested first. A naive
* contains("verified") reports every rejected login as accepted.
*/
@Test
fun `unverified is not mistaken for verified`() {
val outcome = AprsLogin.parse("# logresp BG7NTA unverified, server T2SYDNEY")
assertEquals(AprsLogin.Outcome.Unverified("BG7NTA"), outcome)
}
@Test
fun `a verified login is recognised with its callsign`() {
val outcome = AprsLogin.parse("# logresp BG7NTA-5 verified, server T2SYDNEY")
assertEquals(AprsLogin.Outcome.Verified("BG7NTA-5"), outcome)
}
/**
* Identification and keepalives must return null so the caller keeps reading. The greeting here
* is verbatim from aprsc 2.1.21.
*/
@Test
fun `comments without a verdict are skipped`() {
assertNull(AprsLogin.parse("# aprsc 2.1.21-gbfc2090"))
// The real keepalive, captured from euro.aprs2.net: it repeats the server identification
// with a timestamp rather than sending a bare date, so it carries the same prefix.
assertNull(
AprsLogin.parse("# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580")
)
assertNull(AprsLogin.parse(""))
assertNull(AprsLogin.parse(" "))
}
/** A non-comment line during login is the server objecting in plain text. */
@Test
fun `a plain text line during login is a rejection`() {
val outcome = AprsLogin.parse("Invalid callsign format")
assertTrue(outcome is AprsLogin.Outcome.Rejected)
assertEquals("Invalid callsign format", (outcome as AprsLogin.Outcome.Rejected).detail)
}
/** A logresp with no recognisable verdict is Unknown, not silently Verified. */
@Test
fun `an unrecognised logresp is not treated as success`() {
assertTrue(
AprsLogin.parse("# logresp BG7NTA something-new, server X")
is AprsLogin.Outcome.Unknown
)
}
@Test
fun `case does not change the verdict`() {
assertEquals(
AprsLogin.Outcome.Unverified("BG7NTA"),
AprsLogin.parse("# LOGRESP BG7NTA UNVERIFIED, server X")
)
assertEquals(
AprsLogin.Outcome.Verified("BG7NTA"),
AprsLogin.parse("# LogResp BG7NTA Verified, server X")
)
assertTrue(AprsLogin.parse("# INVALID LOGIN: nope") is AprsLogin.Outcome.Rejected)
}
/** The callsign is read back so the UI can show whose login the server acknowledged. */
@Test
fun `reads the callsign even when the line is oddly spaced`() {
assertEquals(
AprsLogin.Outcome.Verified("BG7NTA-9"),
AprsLogin.parse("# logresp BG7NTA-9 verified, server X")
)
}
}
@@ -0,0 +1,129 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule these pin down: the app never invents a transmit passcode.
*
* APRS-IS treats the passcode as a licence check and says supplying it to a user is the software
* author's job. The previous code derived one from the callsign whenever the entry was blank or
* unparseable, so an operator who had never obtained a passcode transmitted anyway - under a
* check that was never performed.
*/
class AprsPasscodeTest {
/** Recomputed rather than hardcoded: the point is agreement with the shipped algorithm. */
private val callsign = "BG7NTA"
private val correct = AprsPacket.passcode(callsign)
@Test
fun `the callsign's own passcode allows transmitting`() {
assertEquals(
AprsPasscode.Entry.Transmit(correct),
AprsPasscode.classify(callsign, correct.toString())
)
assertTrue(AprsPasscode.canTransmit(callsign, correct.toString()))
assertEquals(correct, AprsPasscode.loginValue(callsign, correct.toString()))
}
/**
* A blank entry becomes receive-only rather than a derived code. This is the defect: the old
* line was `cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(callsign)`,
* so leaving the field empty transmitted under a computed passcode.
*/
@Test
fun `a blank entry connects receive-only instead of deriving one`() {
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, ""))
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, " "))
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, ""))
assertFalse(AprsPasscode.canTransmit(callsign, ""))
}
/**
* -1 is the documented receive-only value and has to survive. `takeIf { it >= 0 }` used to
* discard it and substitute a transmit passcode, which removed the only safe way to test a
* setup - connecting receive-only puts nothing on the network.
*/
@Test
fun `an explicit -1 stays receive-only`() {
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, "-1"))
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, "-1"))
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, " -1 "))
}
/** A wrong passcode is reported, not quietly corrected to the right one. */
@Test
fun `a mismatched passcode is reported rather than fixed`() {
val wrong = (correct + 1).toString()
assertEquals(AprsPasscode.Entry.Mismatch("BG7NTA"), AprsPasscode.classify(callsign, wrong))
assertFalse(AprsPasscode.canTransmit(callsign, wrong))
// And it must not be sent: a mismatch logs in receive-only, never as the derived value.
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, wrong))
}
/**
* The three-way distinction the reporter depends on, and why it must not use canTransmit for
* it: that collapses a deliberate receive-only choice and a typo into one boolean. Telling an
* operator who mistyped their passcode that they are in receive-only mode is the same
* mis-diagnosis as telling a deliberate receive-only user their passcode is wrong, pointing
* the other way.
*/
@Test
fun `a deliberate receive-only entry is distinguishable from a typo`() {
val deliberate = AprsPasscode.classify(callsign, "-1")
val blank = AprsPasscode.classify(callsign, "")
val typo = AprsPasscode.classify(callsign, (correct + 1).toString())
val garbage = AprsPasscode.classify(callsign, "abcde")
assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly)
assertTrue(blank is AprsPasscode.Entry.ReceiveOnly)
assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly)
assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly)
// All four are equally unable to transmit, which is why the boolean is not enough.
for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) {
assertFalse(AprsPasscode.canTransmit(callsign, entry))
}
}
@Test
fun `something that is not a number is its own case`() {
assertEquals(AprsPasscode.Entry.NotANumber, AprsPasscode.classify(callsign, "abcde"))
assertEquals(AprsPasscode.Entry.NotANumber, AprsPasscode.classify(callsign, "12a45"))
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, "abcde"))
}
/** Surrounding whitespace from a paste must not turn a valid passcode into a mismatch. */
@Test
fun `whitespace around a valid passcode is tolerated`() {
assertTrue(AprsPasscode.canTransmit(callsign, " $correct "))
}
/** The passcode depends on the callsign, so it must not validate against a different one. */
@Test
fun `a passcode for one callsign does not validate another`() {
assertFalse(AprsPasscode.canTransmit("W1AW", correct.toString()))
assertEquals(
AprsPasscode.Entry.Mismatch("W1AW"),
AprsPasscode.classify("W1AW", correct.toString())
)
}
/** Case must not matter: the algorithm upper-cases before hashing. */
@Test
fun `a lower case callsign validates the same passcode`() {
assertTrue(AprsPasscode.canTransmit("bg7nta", correct.toString()))
}
/** The SSID is not part of the hash, so the base callsign's code is the right one. */
@Test
fun `no callsign cannot transmit`() {
assertEquals(AprsPasscode.Entry.Mismatch(""), AprsPasscode.classify("", "12345"))
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue("", "12345"))
// A blank entry with a blank callsign is still just receive-only, not a mismatch.
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify("", ""))
}
}
@@ -0,0 +1,102 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The list exists because two free-text fields accepted anything and used only the first character,
* so an operator could type a word, watch it persist, and beacon as something else entirely.
*/
class AprsSymbolsTest {
/**
* The pair that justifies the whole feature. `\S` is Satellite/Pacsat; `/S` is SHUTTLE. One
* keystroke apart, and both look right to someone typing from memory.
*/
@Test
fun `the satellite symbol uses the alternate table`() {
val satellite = AprsSymbols.curated.single { it.descriptionKey == "aprs_symbol_satellite" }
assertEquals('\\', satellite.table)
assertEquals('S', satellite.code)
}
/** Every entry must survive the sanitiser that runs at packet-build time. */
@Test
fun `every curated symbol passes the transmit sanitiser`() {
for (symbol in AprsSymbols.curated) {
assertEquals(
symbol.descriptionKey + " table must survive tableOf",
symbol.table,
AprsBeacon.tableOf(symbol.table.toString())
)
assertEquals(
symbol.descriptionKey + " code must survive codeOf",
symbol.code,
AprsBeacon.codeOf(symbol.code.toString())
)
}
}
/** Two entries rendering the same pair would be a UI ambiguity. */
@Test
fun `no two curated symbols are the same pair`() {
val pairs = AprsSymbols.curated.map { it.table to it.code }
assertEquals("pairs must be unique", pairs.size, pairs.toSet().size)
}
/** Each needs its own description, or the list reads as duplicates. */
@Test
fun `every curated symbol has a distinct description key`() {
val keys = AprsSymbols.curated.map { it.descriptionKey }
assertEquals("description keys must be unique", keys.size, keys.toSet().size)
assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") })
}
@Test
fun `a stored pair on the list is found`() {
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find('/', '-'))
assertNotNull(AprsSymbols.find('\\', 'S'))
}
/**
* A pair the list does not offer must report as absent rather than resolving to something near
* it. The picker relies on this to show an existing setting untouched.
*/
@Test
fun `a stored pair off the list is not silently substituted`() {
assertNull(AprsSymbols.find('/', 'S'))
assertNull(AprsSymbols.find('/', '!'))
assertNull(AprsSymbols.find('\\', 'y'))
}
/** The settings screen holds strings, and only the first character counts. */
@Test
fun `the string overload reads the first character`() {
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/", "-"))
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/junk", "-junk"))
}
/** Blank fields mean the shipped default, which is what the save path substitutes. */
@Test
fun `blank strings resolve to the default symbol`() {
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("", ""))
}
/** The default has to be on the list, or the picker opens showing nothing selected. */
@Test
fun `the default symbol is on the curated list`() {
assertTrue(AprsSymbols.HOUSE in AprsSymbols.curated)
}
/** Short enough to scan in a bottom sheet during setup. */
@Test
fun `the list stays short`() {
assertTrue(
"a curated list of ${AprsSymbols.curated.size} defeats the point",
AprsSymbols.curated.size in 8..20
)
}
}
@@ -0,0 +1,140 @@
package com.rtbishop.look4sat.core.domain.aprs
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* Guards the one defect no other test in this project could catch.
*
* The APRS service passes a foreground service type to startForeground, and AOSP requires it to be
* a subset of the type declared in the manifest, throwing IllegalArgumentException otherwise -
* which the service's own catch turns into a silent stopSelf(). A commit changed the manifest from
* dataSync to location and left the code passing dataSync, so APRS started, died and reported
* nothing on every device running Android 10 or later, while the settings switch stayed on. Two
* auditors found it by reading constants against AOSP; the unit suite could not see it at all,
* because the service is untestable on the JVM and the app module has no test source set.
*
* So this reads both files as text from core:domain, which does have test infrastructure. Crude,
* and it says nothing about whether the service works - but it fails the moment those two files
* disagree, which is exactly the failure that shipped.
*/
class ForegroundServiceTypeTest {
/** Manifest attribute value to the ServiceInfo constant the code must pass. */
private val expectedConstant = mapOf(
"dataSync" to "FOREGROUND_SERVICE_TYPE_DATA_SYNC",
"location" to "FOREGROUND_SERVICE_TYPE_LOCATION",
"connectedDevice" to "FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE",
"mediaPlayback" to "FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK",
"shortService" to "FOREGROUND_SERVICE_TYPE_SHORT_SERVICE"
)
/** Manifest attribute value to the permission that must accompany it. */
private val requiredPermission = mapOf(
"dataSync" to "FOREGROUND_SERVICE_DATA_SYNC",
"location" to "FOREGROUND_SERVICE_LOCATION",
"connectedDevice" to "FOREGROUND_SERVICE_CONNECTED_DEVICE",
"mediaPlayback" to "FOREGROUND_SERVICE_MEDIA_PLAYBACK"
)
/**
* Walk up from the working directory to the repository root.
*
* A unit test's working directory is the module directory, but that is not guaranteed across
* Gradle versions, so the root is found by looking for settings.gradle.kts rather than assumed.
*/
private val repoRoot: File? by lazy {
var dir: File? = File("").absoluteFile
while (dir != null && !File(dir, "settings.gradle.kts").isFile) dir = dir.parentFile
dir
}
private fun read(relative: String): String? =
repoRoot?.let { File(it, relative) }?.takeIf { it.isFile }?.readText()
private val manifest: String? by lazy { read("app/src/main/AndroidManifest.xml") }
private val service: String? by lazy {
read("app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt")
}
/** The type declared for the APRS service in the manifest. */
private fun declaredType(text: String): String {
val match = Regex("""android:foregroundServiceType="([^"]+)"""").find(text)
assertTrue("no foregroundServiceType found in the manifest", match != null)
return match!!.groupValues[1]
}
@Test
fun `the service passes the type its manifest declares`() {
val manifestText = manifest
val serviceText = service
// Skipped rather than failed if the layout moves: a broken locator must not read as a
// broken app, and the assertion below is worthless without both files anyway.
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
val declared = declaredType(manifestText!!)
val constant = expectedConstant[declared]
assertTrue(
"unrecognised foregroundServiceType '$declared' - add it to this test's map",
constant != null
)
assertTrue(
"manifest declares $declared, so startForeground must pass ServiceInfo.$constant",
serviceText!!.contains("ServiceInfo.$constant")
)
}
/** Passing any type the manifest does not declare is what AOSP rejects. */
@Test
fun `the service passes no other foreground service type`() {
val manifestText = manifest
val serviceText = service
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
val passed = Regex("""ServiceInfo\.(FOREGROUND_SERVICE_TYPE_\w+)""")
.findAll(serviceText!!)
.map { it.groupValues[1] }
.toSet()
assertEquals(
"exactly one type may be passed, and it must match the manifest",
setOf(expectedConstant.getValue(declaredType(manifestText!!))),
passed
)
}
/** The matching permission must be declared, or startForeground throws on API 34 and up. */
@Test
fun `the manifest declares the permission its service type requires`() {
val manifestText = manifest
assumeTrue("manifest not found", manifestText != null)
val declared = declaredType(manifestText!!)
val permission = requiredPermission[declared]
assertTrue("no permission mapped for '$declared'", permission != null)
assertTrue(
"foregroundServiceType $declared requires android.permission.$permission",
manifestText.contains("android.permission.$permission")
)
}
/**
* A location-typed service additionally demands an already-granted location permission before
* startForeground. The settings card requests only notifications, so declaring location would
* fail silently for any operator who declined location access - the same class of defect this
* whole test exists to prevent.
*/
@Test
fun `a location typed service would need a runtime permission this app does not request`() {
val manifestText = manifest
assumeTrue("manifest not found", manifestText != null)
assumeTrue("only applies to a location-typed service", declaredType(manifestText!!) == "location")
val card = read(
"feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt"
)
assumeTrue("settings card source not found", card != null)
assertTrue(
"declaring location requires requesting ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION",
card!!.contains("ACCESS_COARSE_LOCATION") || card.contains("ACCESS_FINE_LOCATION")
)
}
}
@@ -0,0 +1,198 @@
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.hypot
import kotlin.math.sin
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, which reads as a real signal, and the whole band above
* 1600 Hz folded down and lifted the noise floor across the display.
*/
class CwAntiAliasTest {
private val captureRate = 44100
private val targetRate = CwDeepSpectrogram.SAMPLE_RATE
private val nyquist = targetRate / 2.0
/** Magnitude at one frequency, Hann-windowed so neighbours do not smear in. */
private fun magnitudeAt(signal: FloatArray, hz: Double, rate: Int): Double {
val n = signal.size
var real = 0.0
var imag = 0.0
val omega = 2.0 * PI * hz / rate
for (i in 0 until n) {
val window = 0.5 - 0.5 * kotlin.math.cos(2.0 * PI * i / (n - 1))
val value = signal[i] * window
real += value * kotlin.math.cos(omega * i)
imag -= value * sin(omega * i)
}
return hypot(real, imag) / n
}
private fun tone(hz: Double, rate: Int, seconds: Double = 0.4): FloatArray {
val n = (rate * seconds).toInt()
return FloatArray(n) { i -> sin(2.0 * PI * hz * i / rate).toFloat() }
}
/** A tone the model needs must survive the filter. */
@Test
fun `tones inside the window pass through`() {
for (hz in listOf(300.0, 700.0, 1000.0, 1200.0)) {
val clean = tone(hz, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(clean, captureRate, targetRate)
val before = magnitudeAt(clean, hz, captureRate)
val after = magnitudeAt(filtered, hz, captureRate)
assertTrue(
"$hz Hz lost too much: $before -> $after",
after > before * 0.7
)
}
}
/**
* The defect. 3000 Hz used to fold to 200 Hz and look like a strong signal; the filter has to
* remove it before the resampler can alias it.
*/
@Test
fun `tones that would alias are suppressed`() {
// Limits are the measured response, not aspirations. 1800 Hz sits just past the 1472 Hz
// cut-off, and 127 taps cannot give a steeper transition than that - a longer filter would
// be sharper and slower, and this runs on a phone during a pass. What matters is that the
// wideband hiss well above the window, which is what smears across the whole display, is
// gone: 2400 Hz and up measure below a thousandth.
val cases = listOf(
Triple(1800.0, 1400.0, 0.20),
Triple(2400.0, 800.0, 0.01),
Triple(3000.0, 200.0, 0.01),
Triple(5000.0, 1400.0, 0.01)
)
for ((source, foldedTo, limit) in cases) {
val raw = tone(source, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(raw, captureRate, targetRate)
val aliasedRaw = CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate)
val aliasedFiltered = CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate)
val ghostBefore = magnitudeAt(aliasedRaw, foldedTo, targetRate)
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
assertTrue(
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter",
ghostAfter < ghostBefore * limit
)
}
}
/** Nothing above the target Nyquist means nothing to do. */
@Test
fun `no filtering when the rate is already low enough`() {
val audio = tone(700.0, targetRate)
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, targetRate, targetRate))
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, 2000, targetRate))
}
@Test
fun `an empty buffer is returned unchanged`() {
val empty = FloatArray(0)
assertSame(empty, CwAntiAlias.prepareForDecimation(empty, captureRate, targetRate))
}
/** Unity DC gain: filtering must not move the level the model was trained on. */
@Test
fun `a steady level is preserved`() {
val flat = FloatArray(4410) { 0.5f }
val filtered = CwAntiAlias.prepareForDecimation(flat, captureRate, targetRate)
// Skip the edges, where taps fall outside the buffer.
val middle = filtered.copyOfRange(
CwAntiAlias.GROUP_DELAY_SAMPLES + 1,
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
)
for (v in middle) {
assertTrue("level drifted to $v", kotlin.math.abs(v - 0.5f) < 0.02f)
}
}
/**
* Chunked filtering has to match whole-buffer filtering, or every chunk boundary becomes a
* click - the same failure the tone shifter's streaming path exists to prevent.
*/
/**
* Chunked filtering must equal whole-buffer filtering exactly, or every chunk boundary is a
* click. A first attempt let the lookahead taps read zeros at the end of each chunk and
* diverged by 0.134 across the last 44 samples of every one; holding output back by the group
* delay makes the two identical.
*/
@Test
fun `streaming matches whole-buffer filtering`() {
val audio = tone(700.0, captureRate, seconds = 0.3)
val whole = CwAntiAlias.prepareForDecimation(audio, captureRate, targetRate)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < audio.size) {
val end = minOf(offset + chunkSize, audio.size)
pieces += streaming.process(audio.copyOfRange(offset, end)).toList()
offset = end
}
val streamed = pieces.toFloatArray()
// Output is delayed by the group delay, so streamed[i] corresponds to whole[i + delay].
var worst = 0f
for (i in streamed.indices) {
val j = i + CwAntiAlias.GROUP_DELAY_SAMPLES
if (j >= whole.size) break
val diff = kotlin.math.abs(streamed[i] - whole[j])
if (diff > worst) worst = diff
}
assertTrue("streaming diverges by $worst", worst < 1e-5f)
}
/** Streaming must not lift the noise floor either. */
@Test
fun `streaming suppresses an aliasing tone too`() {
val raw = tone(3000.0, captureRate, seconds = 0.3)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < raw.size) {
val end = minOf(offset + chunkSize, raw.size)
pieces += streaming.process(raw.copyOfRange(offset, end)).toList()
offset = end
}
val filtered = pieces.toFloatArray()
val ghostBefore = magnitudeAt(
CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate), 200.0, targetRate
)
val ghostAfter = magnitudeAt(
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
)
assertTrue("ghost survived: $ghostBefore -> $ghostAfter", ghostAfter < ghostBefore * 0.01)
}
/** Reset has to clear history, or the next session starts with the last one's tail. */
@Test
fun `reset clears the history`() {
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val loud = FloatArray(4410) { 0.9f }
streaming.process(loud)
streaming.reset()
val silence = FloatArray(4410)
val after = streaming.process(silence)
for (v in after) {
assertTrue("history leaked into silence: $v", kotlin.math.abs(v) < 0.01f)
}
// And a second silent chunk, now that the pipeline is primed.
for (v in streaming.process(FloatArray(4410))) {
assertTrue("history still leaking: $v", kotlin.math.abs(v) < 0.01f)
}
}
}
@@ -17,6 +17,7 @@
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -61,6 +62,46 @@ class CwDeepSpectrogramTest {
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
}
/**
* The waterfall asks for the whole band so that a tone the model cannot read is still
* in the picture. Inside the model's window such a tone leaves nothing to see: the
* brightest column there is noise, and it does not even follow the keying.
*/
@Test
fun compute_wholeBandPlacesAnOutOfWindowTone() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 1500.0 * it / 3200.0)).toFloat() }
val display = CwDeepSpectrogram.compute(
audio,
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
)
// DC to Nyquist inclusive: 0..1600 Hz in 12.5 Hz steps.
assertEquals(129, display[0].size)
val middle = display[display.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz)
}
/** The model's own call must keep its exact shape, whatever the display asks for. */
@Test
fun compute_defaultsToTheModelWindow() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val model = CwDeepSpectrogram.compute(audio)
val explicit = CwDeepSpectrogram.compute(
audio, CwDeepSpectrogram.MIN_FREQ_HZ, CwDeepSpectrogram.MAX_FREQ_HZ
)
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
assertEquals(model.size, explicit.size)
for (frame in model.indices) {
assertArrayEquals(
"explicit model range must equal the default",
model[frame], explicit[frame], 0f
)
}
}
@Test
fun compute_appliesLog1pSoValuesAreNonNegative() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
@@ -0,0 +1,182 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
* samples it hands over must be the most recent audio in chronological order. Getting
* the ring wrap wrong would splice the waveform and corrupt every pitch estimate
* silently - no downstream assertion would notice, which is why these tests drive the
* real class rather than restating its logic.
*/
class CwDetectionPoolTest {
private val capacity = 1280
/** Chunk of a monotonic ramp, so any reordering is visible. */
private fun ramp(from: Int, count: Int) = FloatArray(count) { (from + it).toFloat() }
private fun assertAscending(values: FloatArray) {
for (i in 1 until values.size) {
assertEquals(
"sample $i breaks the ramp, so the ring wrap is wrong",
values[i - 1] + 1f, values[i], 0f
)
}
}
@Test
fun `reports readiness only once capacity is reached`() {
val pool = CwDetectionPool(capacity)
assertFalse("an empty pool is not ready", pool.isReady)
assertEquals(0, pool.size)
// Three 320-sample chunks are 960 samples: still short.
repeat(3) { pool.add(ramp(it * 320, 320)) }
assertEquals(960, pool.size)
assertFalse("960 of $capacity samples is not ready", pool.isReady)
pool.add(ramp(960, 320))
assertEquals(capacity, pool.size)
assertTrue("a full pool must report ready", pool.isReady)
}
@Test
fun `drains a partial fill without stale slots`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(500, 320))
val drained = pool.drain()
assertEquals("only what was added may come back", 320, drained.size)
assertEquals(500f, drained.first(), 0f)
assertEquals(819f, drained.last(), 0f)
assertAscending(drained)
assertEquals("draining empties the pool", 0, pool.size)
}
@Test
fun `drains exactly the most recent samples once wrapped`() {
val pool = CwDetectionPool(capacity)
// 10 chunks of 320 = 3200 samples through a 1280-sample pool.
repeat(10) { pool.add(ramp(it * 320, 320)) }
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f)
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `keeps only the tail of an oversized chunk`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 5000))
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(4999f, drained.last(), 0f)
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `handles single-sample chunks`() {
val pool = CwDetectionPool(capacity)
// Far more single-sample adds than the capacity, exercising every wrap position.
repeat(2000) { pool.add(floatArrayOf(it.toFloat())) }
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(1999f, drained.last(), 0f)
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f)
assertAscending(drained)
}
@Test
fun `is reusable after draining`() {
val pool = CwDetectionPool(capacity)
repeat(5) { pool.add(ramp(it * 320, 320)) }
pool.drain()
// A second pass must not inherit anything from the first.
pool.add(ramp(9000, 320))
val drained = pool.drain()
assertEquals(320, drained.size)
assertEquals(9000f, drained.first(), 0f)
assertEquals(9319f, drained.last(), 0f)
assertAscending(drained)
}
@Test
fun `clear discards pooled audio`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 640))
pool.clear()
assertEquals(0, pool.size)
assertFalse(pool.isReady)
pool.add(ramp(7000, 320))
val drained = pool.drain()
assertEquals("cleared samples must not reappear", 320, drained.size)
assertEquals(7000f, drained.first(), 0f)
}
@Test
fun `empty chunks are ignored`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 320))
pool.add(FloatArray(0))
assertEquals("an empty chunk must not change the pool", 320, pool.size)
assertAscending(pool.drain())
}
@Test
fun `chunk exactly the size of the pool is kept whole`() {
val pool = CwDetectionPool(capacity)
pool.add(ramp(100, capacity))
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(100f, drained.first(), 0f)
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f)
assertAscending(drained)
}
@Test
fun `pooled audio is long enough for the detector to resolve a pitch`() {
// The pool exists to make detection possible at all; prove the pooled length
// actually works rather than only that the plumbing moves samples around.
val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
val pool = CwDetectionPool(capacity)
var phase = 0
repeat(4) {
pool.add(FloatArray(320) { i ->
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
})
phase += 320
}
assertTrue(pool.isReady)
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
assertEquals(
"four pooled capture chunks must be enough to detect a 1500 Hz tone",
1500.0, detected!!.toDouble(), 25.0
)
}
@Test
fun `rejects a non-positive capacity`() {
for (bad in listOf(0, -1, -1280)) {
try {
CwDetectionPool(bad)
throw AssertionError("capacity $bad should have been rejected")
} catch (expected: IllegalArgumentException) {
// The decoder derives capacity from a constant; a zero would otherwise
// fail later as a division by zero in the ring arithmetic.
}
}
}
}
@@ -0,0 +1,271 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse].
*
* This suite exists because an earlier version of the same rule lived inside the decoder,
* where tests could only restate it. Mutation testing then showed four injected defects -
* removing the silence guard, comparing shifts instead of tones, never setting the anchor,
* and inverting the hysteresis comparison - all left the suite green. Every test below
* targets one of those, so each is now a real tripwire.
*/
class CwShiftDeciderTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
private val hysteresisHz = CwShiftDecider.DEFAULT_HYSTERESIS_HZ
private fun steadyTone(hz: Double, samples: Int = 1280): FloatArray =
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
private fun noise(samples: Int = 1280, seed: Int = 1, level: Double = 0.02): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
private fun analyse(audio: FloatArray) = CwToneShifter.analyse(audio, sampleRate)
private fun feed(decider: CwShiftDecider, audio: FloatArray) = decider.accept(analyse(audio))
// --- Mutant (a): the silence guard ---------------------------------------------
@Test
fun `silence retains an established shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome)
assertTrue("a 1400 Hz tone must produce a shift", established.shiftHz != 0f)
val silent = feed(decider, noise())
assertEquals(
"silence must be reported as no tone, not as a zero shift",
CwShiftDecider.Outcome.NO_TONE, silent.outcome
)
assertEquals(
"silence must not change the shift",
established.shiftHz, silent.shiftHz, 0f
)
assertFalse("a silent window is not a change", silent.changed)
assertEquals(
"the decider's state must still hold the shift",
established.shiftHz, decider.shiftHz, 0f
)
}
@Test
fun `a run of silence does not erode the shift`() {
val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0)).shiftHz
repeat(8) { i ->
val decision = feed(decider, noise(seed = i + 2))
assertEquals(
"silent window $i changed the shift",
established, decision.shiftHz, 0f
)
}
assertEquals(established, decider.shiftHz, 0f)
assertNotNull("the anchor must survive silence", decider.anchorToneHz)
}
// --- Mutants (b) and (c): hysteresis anchored on the tone ----------------------
@Test
fun `an estimate hopping across the window edge does not re-shift`() {
// 1200.0 Hz is inside the window (shift 0); 1212.5 Hz, one scan bin away, is
// outside (a large shift). A shift-space comparison lapses here because one side
// is zero, which is exactly where the jump is largest.
val decider = CwShiftDecider()
val first = feed(decider, steadyTone(1212.5))
assertEquals(CwShiftDecider.Outcome.SHIFTED, first.outcome)
val hop = feed(decider, steadyTone(1200.0))
assertEquals(
"a one-bin hop back across the edge must be absorbed",
CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome
)
assertEquals("the shift must not move", first.shiftHz, hop.shiftHz, 0f)
assertFalse(hop.changed)
}
@Test
fun `the anchor is set from the tone that produced the shift`() {
val decider = CwShiftDecider()
assertNull("no anchor before the first detection", decider.anchorToneHz)
feed(decider, steadyTone(1400.0))
assertEquals(
"the anchor must be the detected tone",
1400.0, decider.anchorToneHz!!.toDouble(), 25.0
)
// An in-window tone must anchor too, otherwise a tone drifting from inside the
// window to outside would be measured against a stale reference.
feed(decider, steadyTone(700.0))
assertEquals(
"an in-window tone must also become the anchor",
700.0, decider.anchorToneHz!!.toDouble(), 25.0
)
assertEquals("an in-window tone needs no shift", 0f, decider.shiftHz, 0f)
}
@Test
fun `hysteresis is measured against the anchor, not the previous estimate`() {
// Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison
// against the previous estimate would never fire. Anchored, the shift updates
// once the accumulated move clears the margin.
val decider = CwShiftDecider()
feed(decider, steadyTone(1300.0))
val anchorAtStart = decider.anchorToneHz!!
var tone = 1325.0
var updates = 0
while (tone <= 1450.0) {
if (feed(decider, steadyTone(tone)).changed) updates++
tone += 25.0
}
assertTrue(
"accumulated drift must eventually re-shift; anchor started at $anchorAtStart " +
"and the shift updated $updates times",
updates >= 1
)
}
// --- Mutant (d): the comparison direction --------------------------------------
@Test
fun `a large retune is followed while small moves are absorbed`() {
val decider = CwShiftDecider()
val before = feed(decider, steadyTone(1400.0)).shiftHz
// Well inside the margin: must be absorbed.
val small = feed(decider, steadyTone(1412.5))
assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome)
assertEquals(before, small.shiftHz, 0f)
// Well beyond it: must be followed. An inverted comparison would absorb this and
// react to the small move instead.
val large = feed(decider, steadyTone(1000.0))
assertTrue(
"a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})",
large.changed
)
assertEquals(
"a 1000 Hz tone is inside the window, so no shift is needed",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome
)
assertEquals(0f, large.shiftHz, 0f)
}
@Test
fun `an edge tone settles instead of thrashing`() {
val decider = CwShiftDecider()
var changes = 0
// Estimates hopping around the 1200 Hz edge, the worst case for a shift-space rule.
val hops = listOf(1200.0, 1212.5, 1200.0, 1187.5, 1212.5, 1200.0, 1225.0, 1200.0)
repeat(4) {
for (hz in hops) {
if (feed(decider, steadyTone(hz)).changed) changes++
}
}
assertTrue(
"an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections",
changes <= 3
)
}
// --- Drift and state consistency ----------------------------------------------
@Test
fun `slow drift keeps the shifted tone inside the model window`() {
val decider = CwShiftDecider()
var tone = 1300.0
var worstOffset = 0.0
while (tone <= 1550.0) {
val decision = feed(decider, steadyTone(tone))
val landed = tone + decision.shiftHz
worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ))
assertTrue(
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window",
CwToneShifter.isInsideWindow(landed.toFloat())
)
tone += 12.5
}
assertTrue(
"staleness must stay near the margin, worst offset was $worstOffset Hz",
worstOffset <= hysteresisHz + 12.5
)
}
@Test
fun `reset clears both the shift and the anchor together`() {
val decider = CwShiftDecider()
feed(decider, steadyTone(1400.0))
assertTrue(decider.shiftHz != 0f)
assertNotNull(decider.anchorToneHz)
decider.reset()
assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f)
assertNull("reset must clear the anchor", decider.anchorToneHz)
// After a reset the next tone must be acted on rather than absorbed.
val decision = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, decision.outcome)
assertTrue(decision.changed)
}
@Test
fun `a non-zero shift always has an anchor`() {
// An inconsistent pair would make hysteresis behave differently depending on how
// the state was reached, so pin the invariant across a mixed sequence.
val decider = CwShiftDecider()
val sequence = listOf(
steadyTone(1400.0), noise(), steadyTone(1412.5), steadyTone(300.0),
noise(seed = 5), steadyTone(700.0), steadyTone(1500.0), noise(seed = 9)
)
for ((index, audio) in sequence.withIndex()) {
feed(decider, audio)
if (decider.shiftHz != 0f) {
assertNotNull(
"step $index left a shift of ${decider.shiftHz}Hz with no anchor",
decider.anchorToneHz
)
}
}
}
@Test
fun `shift always lands the tone on the target`() {
for (hz in listOf(150.0, 250.0, 300.0, 1250.0, 1400.0, 1500.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone must be shifted to the window centre",
CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0
)
}
}
@Test
fun `in-window tones are never shifted`() {
for (hz in listOf(400.0, 500.0, 800.0, 1100.0, 1200.0)) {
val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz))
assertEquals(
"a ${hz}Hz tone is inside the window and must not be shifted",
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome
)
assertEquals(0f, decision.shiftHz, 0f)
}
}
}
@@ -0,0 +1,167 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
/**
* Signal-level properties of the shifter: the range the spectrogram expects, the
* detector's threshold trade-off, and behaviour on inputs a phone mic can really produce.
*
* The decision rule that consumes these estimates is covered by [CwShiftDeciderTest].
*/
class CwToneShiftSignalTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
/** Keyed CW: gated tone with noise, 60 ms on / 30 ms off, roughly 20 WPM. */
private fun keyedTone(hz: Double, samples: Int = 1280, seed: Int = 1, noise: Double = 0.02): FloatArray {
val random = Random(seed)
val period = sampleRate * 90 / 1000
return FloatArray(samples) { i ->
val gate = if (i % period < sampleRate * 60 / 1000) 1.0 else 0.0
(gate * sin(2.0 * PI * hz * i / sampleRate) +
(random.nextDouble() - 0.5) * 2 * noise).toFloat()
}
}
private fun noiseOnly(samples: Int = 1280, seed: Int = 2, level: Double = 1.0): FloatArray {
val random = Random(seed)
return FloatArray(samples) { ((random.nextDouble() - 0.5) * 2 * level).toFloat() }
}
/**
* The prominence threshold sits between two measured populations and both sides
* matter. Too low and noise is mistaken for a tone, which moves a good signal out of
* the model's range; too high and copyable weak signals are never shifted, which is
* the very failure the feature exists to prevent.
*/
@Test
fun `prominence threshold rejects noise without rejecting weak signals`() {
var falsePositives = 0
repeat(20) { seed ->
if (CwToneShifter.detectToneHz(noiseOnly(seed = seed + 500), sampleRate) != null) {
falsePositives++
}
}
assertEquals("noise must never be reported as a tone", 0, falsePositives)
// Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible,
// decodable, and the region an over-tight threshold silently discards.
for (hz in listOf(300.0, 800.0, 1400.0)) {
val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate)
assertEquals(
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise",
hz, detected!!.toDouble(), 25.0
)
}
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " +
"ceiling of ~3.4",
CwToneShifter.MIN_PROMINENCE > 3.4
)
assertTrue(
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " +
"keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB",
CwToneShifter.MIN_PROMINENCE < 5.2
)
}
/**
* The Hilbert kernel's L1 gain is 2.51, so summing the in-phase and quadrature paths
* overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The
* spectrogram takes log1p of the magnitude, so an overshoot is not fatal, but it
* moves the level away from what the model was trained on.
*/
@Test
fun `shifted output stays within the range the spectrogram expects`() {
val shifter = CwToneShifter.Streaming()
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
assertTrue(
"a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}",
shiftedSquare.all { abs(it) <= 1f }
)
shifter.reset()
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
assertTrue(
"a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}",
shiftedSine.all { abs(it) <= 1f }
)
// Limiting must not flatten the signal away: the tone still has to be there.
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
assertEquals(
"limiting must preserve the shifted tone",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
)
}
@Test
fun `stateless shift also stays in range`() {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shifted = CwToneShifter.shift(square, -700f, sampleRate)
assertTrue(
"peak was ${shifted.maxOf { abs(it) }}",
shifted.all { abs(it) <= 1f }
)
}
@Test
fun `detector tolerates pathological input`() {
// A wrong shift moves a perfectly good tone out of range, so a bogus estimate is
// worse than none: these inputs must produce the right tone or nothing at all.
for (offset in listOf(0.5, 1.0, 5.0, 50.0)) {
val biased = FloatArray(1280) { i ->
(offset + sin(2.0 * PI * 800.0 * i / sampleRate)).toFloat()
}
val detected = CwToneShifter.detectToneHz(biased, sampleRate)
assertEquals(
"a DC offset of $offset must not hide the tone",
800.0, detected!!.toDouble(), 25.0
)
}
assertNull(
"all zeros must not report a tone",
CwToneShifter.detectToneHz(FloatArray(1280), sampleRate)
)
for (size in listOf(0, 1, 2, 63)) {
assertNull(
"a $size-sample buffer is too short to detect from",
CwToneShifter.detectToneHz(FloatArray(size), sampleRate)
)
}
val withNan = FloatArray(1280) { i ->
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
}
assertNull(
"a NaN sample must yield no tone rather than a garbage shift",
CwToneShifter.detectToneHz(withNan, sampleRate)
)
// Clipping must not let a harmonic outrank the fundamental.
for (drive in listOf(1.0, 4.0, 20.0, 200.0)) {
val clipped = FloatArray(1280) { i ->
(drive * sin(2.0 * PI * 500.0 * i / sampleRate)).coerceIn(-1.0, 1.0).toFloat()
}
val detected = CwToneShifter.detectToneHz(clipped, sampleRate)
assertEquals(
"at ${drive}x drive the fundamental must still win",
500.0, detected!!.toDouble(), 25.0
)
}
}
}
@@ -0,0 +1,240 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.sqrt
/**
* [CwToneShifter.Streaming] exists because the decoder shifts one ~320-sample chunk at
* a time. Shifting each chunk in isolation makes the Hilbert FIR convolve against zeros
* at both edges, which distorted 62 of every 320 samples and inflated envelope ripple
* to 8.7x the whole-buffer baseline. These tests fail if that state handling regresses.
*/
class CwToneShifterStreamingTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
/** ~100 ms of audio once resampled to 3200 Hz, matching what the decoder receives. */
private val chunkSize = 320
private fun continuousTone(hz: Double, samples: Int): FloatArray =
FloatArray(samples) { i -> sin(2.0 * PI * hz * i / sampleRate).toFloat() }
/** RMS envelope; a steady tone must produce a flat one. */
private fun envelope(audio: FloatArray, window: Int = 48): List<Double> {
val out = mutableListOf<Double>()
var i = 0
while (i + window <= audio.size) {
var sum = 0.0
for (j in i until i + window) sum += audio[j].toDouble() * audio[j]
out += sqrt(sum / window)
i += window / 2
}
return out
}
/** Coefficient of variation of the envelope, as a percentage. */
private fun ripple(audio: FloatArray, skip: Int = 0): Double {
val env = envelope(audio.copyOfRange(skip, audio.size))
val mean = env.average()
if (mean == 0.0) return 0.0
val variance = env.sumOf { (it - mean) * (it - mean) } / env.size
return sqrt(variance) / mean * 100.0
}
private fun processInChunks(audio: FloatArray, shiftHz: Float): FloatArray {
val shifter = CwToneShifter.Streaming()
val out = FloatArray(audio.size)
var offset = 0
while (offset < audio.size) {
val end = minOf(offset + chunkSize, audio.size)
val chunk = audio.copyOfRange(offset, end)
shifter.process(chunk, shiftHz, sampleRate).copyInto(out, offset)
offset = end
}
return out
}
@Test
fun `chunked streaming keeps a steady tone flat`() {
val audio = continuousTone(1500.0, chunkSize * 20)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val streamed = processInChunks(audio, shiftHz)
// Skip the filter's start-up transient: with no history the first taps are cold.
val skip = 128
val streamedRipple = ripple(streamed, skip)
// Absolute, not relative to the whole-buffer figure: clamping pins a full-scale
// tone at exactly 1.0, so the whole-buffer ripple collapses to ~0.001% and any
// ratio against it explodes. What matters is the absolute number - a 20 WPM dot
// spans 192 samples, so sub-2% envelope ripple cannot move a keying decision.
// Measured 0.79% with state carried across chunks; dropping the filter history
// takes it to several percent, and dropping the phase far higher.
assertTrue(
"streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " +
"filter state or mixer phase is not being carried",
streamedRipple < 2.0
)
}
/**
* Streaming must match whole-buffer shifting everywhere except the last
* [lookahead] samples of each chunk.
*
* That exception is causal, not a defect: producing output sample `i` needs input
* up to `i + HILBERT_DELAY`, which for the tail of a chunk has not been captured
* yet. A whole-buffer call sees those samples; a live stream cannot. Measured, the
* divergence is confined to the final 3 samples of each 320-sample chunk (under 1%
* of the audio) and vanishes immediately after the boundary, which is why the
* decoder accepts it rather than delaying output by 10 ms.
*/
@Test
fun `chunked output matches whole-buffer output except the causal tail`() {
val audio = continuousTone(1500.0, chunkSize * 12)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val whole = CwToneShifter.shift(audio, shiftHz, sampleRate)
val streamed = processInChunks(audio, shiftHz)
val lookahead = 32 // HILBERT_TAPS / 2, rounded up
val skip = 128 // filter start-up transient
var worstInterior = 0.0
var worstTail = 0.0
for (i in skip until audio.size) {
val distanceToBoundary = chunkSize - (i % chunkSize)
val delta = abs(whole[i] - streamed[i]).toDouble()
if (distanceToBoundary <= lookahead) {
worstTail = maxOf(worstTail, delta)
} else {
worstInterior = maxOf(worstInterior, delta)
}
}
assertTrue(
"away from chunk tails the two must agree; worst divergence was " +
"$worstInterior, so filter history or mixer phase is not being carried",
worstInterior < 0.01
)
// The tail is allowed to differ, but not wildly: a broken implementation would
// diverge by the full signal amplitude rather than a fraction of it.
assertTrue(
"chunk-tail divergence $worstTail exceeds the causal lookahead budget",
worstTail < 0.5
)
}
@Test
fun `shifted chunks land on the target frequency`() {
val audio = continuousTone(1500.0, chunkSize * 16)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val streamed = processInChunks(audio, shiftHz)
val detected = CwToneShifter.detectToneHz(streamed, sampleRate)
assertEquals(
"streamed audio must end up at the target pitch",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0
)
}
@Test
fun `zero shift passes chunks through untouched`() {
val shifter = CwToneShifter.Streaming()
val chunk = continuousTone(800.0, chunkSize)
assertSame(
"a zero shift must not copy or alter the chunk",
chunk, shifter.process(chunk, 0f, sampleRate)
)
}
@Test
fun `history survives a run of zero-shift chunks`() {
// Feeding audio while disabled must still fill the history, so that enabling
// the shift mid-stream does not convolve against leftover silence.
val shifter = CwToneShifter.Streaming()
val audio = continuousTone(1500.0, chunkSize * 6)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
// First three chunks with no shift, then start shifting.
var offset = 0
repeat(3) {
shifter.process(audio.copyOfRange(offset, offset + chunkSize), 0f, sampleRate)
offset += chunkSize
}
val firstShifted = shifter.process(
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
)
// With history primed the very first shifted chunk should already be clean;
// a cold filter would show a large amplitude dip at its start.
val head = envelope(firstShifted.copyOfRange(0, 96)).average()
val tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average()
assertTrue(
"first shifted chunk starts at $head but settles at $tail; history was not kept",
head > tail * 0.7
)
}
@Test
fun `reset clears state so the next chunk starts cold`() {
val shifter = CwToneShifter.Streaming()
val audio = continuousTone(1500.0, chunkSize * 4)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
var offset = 0
repeat(3) {
shifter.process(audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate)
offset += chunkSize
}
shifter.reset()
val afterReset = shifter.process(
audio.copyOfRange(offset, offset + chunkSize), shiftHz, sampleRate
)
// Cold filter: the leading samples are attenuated relative to the settled tail.
val head = envelope(afterReset.copyOfRange(0, 64)).average()
val tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average()
assertTrue(
"reset must clear history, so the head ($head) should be quieter than " +
"the settled tail ($tail)",
head < tail
)
}
@Test
fun `handles chunks larger than the history window`() {
val shifter = CwToneShifter.Streaming()
val big = continuousTone(1500.0, 5000)
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val out = shifter.process(big, shiftHz, sampleRate)
assertEquals(big.size, out.size)
assertTrue("output must be finite", out.all { it.isFinite() })
}
@Test
fun `handles chunks smaller than the history window`() {
val shifter = CwToneShifter.Streaming()
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
// 16-sample chunks are far below the 62-sample history; the ring must still work.
val audio = continuousTone(1500.0, 16 * 40)
var offset = 0
val collected = FloatArray(audio.size)
while (offset < audio.size) {
val chunk = audio.copyOfRange(offset, offset + 16)
shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset)
offset += 16
}
assertTrue("output must be finite", collected.all { it.isFinite() })
val detected = CwToneShifter.detectToneHz(collected, sampleRate)
assertEquals(
"even tiny chunks must end up at the target pitch",
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 40.0
)
}
}
@@ -0,0 +1,209 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
import kotlin.random.Random
/**
* The shifter exists so pitches outside the model's 400-1200 Hz window can still be
* decoded. These tests pin the two properties that make it safe to enable:
* in-window audio is returned untouched, and shifted audio contains one clean tone.
*/
class CwToneShifterTest {
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
/** Keyed CW-like tone: a gated sine with smooth edges, plus noise. */
private fun cwTone(hz: Double, samples: Int = 1600, noise: Double = 0.02): FloatArray {
val random = Random(42)
return FloatArray(samples) { i ->
// Gate on for 60 ms, off for 30 ms, repeating - roughly 20 WPM keying.
val cyclePos = (i % (sampleRate * 90 / 1000))
val gate = if (cyclePos < sampleRate * 60 / 1000) 1.0 else 0.0
val value = gate * sin(2.0 * PI * hz * i / sampleRate)
(value + (random.nextDouble() - 0.5) * 2 * noise).toFloat()
}
}
/** Relative magnitude at [hz] using a single-bin DFT with a Hann window. */
private fun magnitudeAt(audio: FloatArray, hz: Double): Double {
var real = 0.0
var imag = 0.0
val omega = 2.0 * PI * hz / sampleRate
for (i in audio.indices) {
val window = 0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))
val value = audio[i] * window
real += value * cos(omega * i)
imag -= value * sin(omega * i)
}
return hypot(real, imag) / audio.size
}
/** Scan 100 Hz..Nyquist and return the strongest bin plus everything above a ratio. */
private fun peaks(audio: FloatArray, minRatio: Double = 0.3): Pair<Double, List<Double>> {
val magnitudes = mutableListOf<Pair<Double, Double>>()
var hz = 100.0
while (hz <= sampleRate / 2.0) {
magnitudes += hz to magnitudeAt(audio, hz)
hz += 12.5
}
val strongest = magnitudes.maxByOrNull { it.second }!!
val others = magnitudes
.filter { it.first != strongest.first && it.second >= strongest.second * minRatio }
// Collapse adjacent bins of the same lobe; only distinct tones matter.
.filter { abs(it.first - strongest.first) > 50.0 }
.map { it.first }
return strongest.first to others
}
@Test
fun `detects tones across the audible range`() {
for (tone in listOf(150.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1300.0, 1500.0)) {
val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate)
assertNotNull("no tone detected at $tone Hz", detected)
assertEquals("detected pitch off at $tone Hz", tone, detected!!.toDouble(), 25.0)
}
}
@Test
fun `reports no tone for noise`() {
val random = Random(7)
val noise = FloatArray(1600) { ((random.nextDouble() - 0.5) * 2).toFloat() }
assertNull("noise must not be mistaken for a tone", CwToneShifter.detectToneHz(noise, sampleRate))
}
@Test
fun `in-window tones are returned untouched`() {
for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) {
val audio = cwTone(tone)
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
assertFalse("$tone Hz is inside the window, must not shift", analysis.needsShift)
assertEquals("no shift expected at $tone Hz", 0f, analysis.shiftHz, 0f)
// Same instance: the caller's array must not even be copied.
assertSame("in-window audio must be passed through", audio, result)
}
}
/**
* The decoder runs this scan even with shifting switched off, purely to tell the
* operator why nothing is decoding. That only works if the scan reaches past the
* model's window: the spectrogram's own pitch readout cannot, being confined to the
* window by construction, and it reports edge leakage as though it were the tone.
*/
@Test
fun `the scan reports tones the model window excludes`() {
for (tone in listOf(120.0, 250.0, 1400.0, 1500.0)) {
val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate)
val reported = analysis.toneHz
assertNotNull("$tone Hz went undetected, so the UI has nothing to report", reported)
assertEquals("$tone Hz was misreported", tone, reported!!.toDouble(), 30.0)
assertFalse(
"$tone Hz must read as outside the window",
CwToneShifter.isInsideWindow(reported)
)
}
}
/**
* The waterfall draws a marker at [CwToneShifter.TARGET_HZ] to show the operator where
* a shifted tone is being delivered. Moving the target outside the model's window, or
* moving the window off the target, would leave that marker pointing at a frequency
* nothing arrives at — and nothing else in the build would object.
*/
@Test
fun `the shift target sits inside the model window, clear of its edges`() {
assertTrue(
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is outside the model window " +
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz",
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
)
// Clear of the edges by a decent margin, so a tone landing a little off target
// still lands inside: a target hugging an edge would make the shift pointless.
val margin = (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 4
assertTrue(
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is within $margin Hz of a window edge",
CwToneShifter.TARGET_HZ >= CwDeepSpectrogram.MIN_FREQ_HZ + margin &&
CwToneShifter.TARGET_HZ <= CwDeepSpectrogram.MAX_FREQ_HZ - margin
)
}
@Test
fun `out-of-window tones move to the target with no competing tone`() {
for (tone in listOf(150.0, 200.0, 250.0, 300.0, 350.0, 1300.0, 1400.0, 1500.0)) {
val audio = cwTone(tone)
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
assertTrue("$tone Hz is outside the window, must shift", analysis.needsShift)
val (strongest, competing) = peaks(result)
assertEquals(
"$tone Hz did not land on the target",
CwToneShifter.TARGET_HZ, strongest, 30.0
)
assertTrue(
"$tone Hz left a competing tone at $competing (single-sideband mixing failed)",
competing.isEmpty()
)
assertTrue(
"shifted tone must land inside the model window",
CwToneShifter.isInsideWindow(strongest.toFloat())
)
}
}
@Test
fun `shift with zero offset returns the same array`() {
val audio = cwTone(800.0)
assertSame(audio, CwToneShifter.shift(audio, 0f, sampleRate))
}
@Test
fun `shift preserves length and stays finite`() {
val audio = cwTone(1500.0)
val shifted = CwToneShifter.shift(audio, -700f, sampleRate)
assertEquals("length must be preserved", audio.size, shifted.size)
assertTrue("output must be finite", shifted.all { it.isFinite() })
}
@Test
fun `empty input is handled`() {
val empty = FloatArray(0)
assertSame(empty, CwToneShifter.shift(empty, -700f, sampleRate))
assertNull(CwToneShifter.detectToneHz(empty, sampleRate))
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(empty, sampleRate)
assertSame(empty, result)
assertFalse(analysis.needsShift)
}
@Test
fun `shifted audio survives the spectrogram with energy inside the window`() {
// End-to-end: a 1500 Hz tone is invisible to the model, the shifted one is not.
val audio = cwTone(1500.0, samples = 3200)
val rawSpectrogram = CwDeepSpectrogram.compute(audio)
val rawEnergy = rawSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
val (shifted, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
assertTrue(analysis.needsShift)
val shiftedSpectrogram = CwDeepSpectrogram.compute(shifted)
val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
assertTrue(
"shifting must put more energy in the model window (raw=$rawEnergy shifted=$shiftedEnergy)",
shiftedEnergy > rawEnergy * 1.5
)
assertEquals(
"bin count must stay compatible with the model",
CwDeepSpectrogram.FREQUENCY_BINS, shiftedSpectrogram[0].size
)
}
}
@@ -0,0 +1,133 @@
package com.rtbishop.look4sat.core.domain.qrz
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class QrzGridParserTest {
/** A signed-in page for a station that published a locator. */
private val withGrid = """
<table class="detail">
<tr><td class="dh">Callsign</td><td class="di">BG7NTA</td></tr>
<tr><td class="dh">Grid Square</td><td class="di">OL72</td></tr>
<tr><td class="dh">Country</td><td class="di">China</td></tr>
</table>
""".trimIndent()
/** Signed in, but this station has no Grid Square row at all. */
private val withoutGrid = """
<table class="detail">
<tr><td class="dh">Callsign</td><td class="di">W1AW</td></tr>
<tr><td class="dh">Country</td><td class="di">United States</td></tr>
</table>
""".trimIndent()
/**
* What QRZ actually serves for a real callsign when nobody is signed in: no detail table,
* plus its own notice saying why. Transcribed from a live response rather than invented.
*/
private val signedOut = """
<div class="csgn">W1AW</div>
<div>Login is required for additional detail.</div>
<div>Email: Login required to view</div>
""".trimIndent()
/**
* A callsign QRZ has never heard of. Also HTTP 200, also zero detail rows, but no login
* notice - QRZ serves its search form instead of a callsign page.
*/
private val unknownCallsign = """
<div class="search">The available search types are callsign, name, address.</div>
""".trimIndent()
@Test
fun `reads the locator when the station published one`() {
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(withGrid))
}
/**
* The distinction that matters: a station with no grid must not look like an expired
* cookie, because the fix for one is nothing and the fix for the other is re-pasting it.
*/
@Test
fun `a station with no grid is not confused with being signed out`() {
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(withoutGrid))
assertEquals(QrzGrid.SignedOut, QrzGridParser.parseGrid(signedOut))
}
/**
* The cookie prompt must fire only when the cookie is the problem. Measured against live
* responses, a mistyped callsign returns a page with no detail rows either - so classifying
* on their absence would blame the cookie and send the operator off to re-paste a working
* one, mid-pass, over a satellite that is about to set.
*/
@Test
fun `an unknown callsign does not read as an expired cookie`() {
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(unknownCallsign))
}
@Test
fun `an empty grid cell counts as not on file`() {
val blank = withGrid.replace(">OL72<", "><")
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(blank))
}
@Test
fun `whitespace around the locator is trimmed`() {
val padded = withGrid.replace(">OL72<", "> OL72 <")
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(padded))
}
/** Six-character locators are as common as four; nothing may truncate them. */
@Test
fun `a six character locator survives intact`() {
val six = withGrid.replace(">OL72<", ">OL72ab<")
assertEquals(QrzGrid.Found("OL72ab"), QrzGridParser.parseGrid(six))
}
/** QRZ puts the two cells adjacent, but whitespace between them must not break the match. */
@Test
fun `whitespace between the two table cells is tolerated`() {
val spaced = withGrid.replace(
"<td class=\"dh\">Grid Square</td><td class=\"di\">OL72</td>",
"<td class=\"dh\">Grid Square</td>\n <td class=\"di\">OL72</td>"
)
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(spaced))
}
@Test
fun `reads back which callsign the cookie belongs to`() {
val menu = """<li class="leaf last" onclick="return true">BG7NTA <ul class="sub">"""
assertEquals("BG7NTA", QrzGridParser.parseOwnCallsign(menu))
assertNull(QrzGridParser.parseOwnCallsign(signedOut))
}
@Test
fun `a raw cookie header is passed through unchanged`() {
val raw = "qz_userid=1266043; qz_sess=abc123"
assertEquals(raw, QrzGridParser.cookieHeader(raw))
}
/** The shape a cookie-export extension produces. */
@Test
fun `a json cookie export is flattened into a header`() {
val json = """
[{"domain":".qrz.com","name":"qz_userid","value":"1266043"},
{"domain":".qrz.com","name":"qz_sess","value":"abc123"}]
""".trimIndent()
assertEquals("qz_userid=1266043; qz_sess=abc123", QrzGridParser.cookieHeader(json))
}
@Test
fun `an empty cookie yields an empty header`() {
assertEquals("", QrzGridParser.cookieHeader(" "))
}
/** Malformed JSON is handed over as-is rather than silently becoming empty. */
@Test
fun `unparseable json is passed through rather than dropped`() {
val broken = """[{"nope":"nothing here"}]"""
assertEquals(broken, QrzGridParser.cookieHeader(broken))
}
}
@@ -0,0 +1,154 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule under test: never discard a plausible callsign, and never discard anything silently.
*
* A strict pattern rejected 16 of these 28 real callsigns, so the check is deliberately loose and
* reports doubt as a warning instead of refusing.
*/
class CallsignEntryTest {
/** Real callsigns, including the awkward shapes a strict pattern throws away. */
private val realCallsigns = listOf(
"W1AW", "BG7NTA", "G0ABC", "VK2XYZ", "JA1ABC", "PY2ABC",
"W1AW/4", "K1ABC/P", "DL1ABC/M", "SV2ASP/A", "OH2ABC/MM",
// Prefix-first portable calls: the LEADING token is a country prefix with no digit in it.
"DL/W1AW", "ZL/JA1ABC", "OH/W1AW/MM", "PA/G0ABC/P", "F/BG7NTA",
"2E0ABC", "2M0XYZ", "9A1CCY", "4X4ABC", "3DA0ABC",
"VP2MDD", "ZS6ABC", "5B4ABC", "HB9ABC", "OE1ABC",
"LU1ABC", "CT1ABC", "EA8ABC", "TF3ABC", "VU2ABC",
"R1ABC", "UA9ABC"
)
@Test
fun `every real callsign is accepted`() {
val rejected = realCallsigns.filter { CallsignEntry.check(it) !is CallsignEntry.Verdict.Acceptable }
assertEquals("these real callsigns were rejected: $rejected", emptyList<String>(), rejected)
}
/** The entry is upper-cased for logging, since ADIF and LoTW expect that. */
@Test
fun `entries are normalised to upper case and trimmed`() {
val verdict = CallsignEntry.check(" bg7nta ")
assertEquals(CallsignEntry.Verdict.Acceptable("BG7NTA"), verdict)
}
/**
* The defect this replaces: `if (call.length < 3) return` discarded the entry with no message,
* so the operator pressed done mid-pass and nothing happened. Two characters is a real
* callsign length, and anything shorter now says so instead of vanishing.
*/
@Test
fun `a short entry is rejected with a reason rather than dropped`() {
assertEquals(
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_SHORT),
CallsignEntry.check("W")
)
assertEquals(
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.EMPTY),
CallsignEntry.check(" ")
)
}
@Test
fun `illegal characters are named as such`() {
for (bad in listOf("W1AW!", "W1 AW", "W1AW.", "БГ7НТА", "W1AW@")) {
assertEquals(
bad,
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.ILLEGAL_CHARACTERS),
CallsignEntry.check(bad)
)
}
}
/** No callsign is all digits or all letters. */
/**
* The regression this guards. Checking only the first segment rejected every prefix-first
* portable call, because a country prefix like DL or ZL has no digit in it. The old
* length-only check had accepted them, so this was a real loss for anyone logging one.
*/
@Test
fun `a prefix-first portable callsign is accepted`() {
for (call in listOf("DL/W1AW", "ZL/JA1ABC", "OH/W1AW/MM", "PA/G0ABC/P", "F/BG7NTA")) {
assertTrue(
"$call must be accepted",
CallsignEntry.check(call) is CallsignEntry.Verdict.Acceptable
)
}
}
@Test
fun `something with no digit or no letter is not a callsign`() {
assertEquals(
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
CallsignEntry.check("ABCDE")
)
assertEquals(
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
CallsignEntry.check("12345")
)
}
@Test
fun `an absurdly long entry is rejected`() {
assertEquals(
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_LONG),
CallsignEntry.check("W1AW/QRP/PORTABLE/EXTRA")
)
}
/**
* A grid typed into the callsign field is warned about, not refused: DXLog's own loose
* pattern accepts GG77DH as a callsign, and the two are exchanged together on FM satellites.
*/
@Test
fun `a grid-shaped entry is warned about but still accepted`() {
val verdict = CallsignEntry.check("GG77DH")
assertEquals(
CallsignEntry.Verdict.Acceptable("GG77DH", CallsignEntry.Warning.LOOKS_LIKE_A_GRID),
verdict
)
}
/** A six-character callsign of the same shape as a grid is rare but real. The warning is advisory. */
@Test
fun `a normal six character callsign carries no warning`() {
val verdict = CallsignEntry.check("BG7NTA") as CallsignEntry.Verdict.Acceptable
assertNull(verdict.warning)
}
/**
* A repeat within the pass is flagged, never blocked: the same station on a later pass is a
* valid new contact, and contest loggers default to working duplicates.
*/
@Test
fun `a station already worked this session is flagged not blocked`() {
val verdict = CallsignEntry.check("W1AW", setOf("W1AW"))
assertEquals(
CallsignEntry.Verdict.Acceptable("W1AW", CallsignEntry.Warning.ALREADY_WORKED),
verdict
)
}
@Test
fun `a station worked on a previous pass carries no warning`() {
val verdict = CallsignEntry.check("W1AW", setOf("K1ABC")) as CallsignEntry.Verdict.Acceptable
assertNull(verdict.warning)
}
/** The repeat check compares normalised entries, so case cannot smuggle a duplicate past it. */
@Test
fun `the repeat check is case insensitive`() {
val verdict = CallsignEntry.check("w1aw", setOf("W1AW"))
assertTrue(verdict is CallsignEntry.Verdict.Acceptable)
assertEquals(
CallsignEntry.Warning.ALREADY_WORKED,
(verdict as CallsignEntry.Verdict.Acceptable).warning
)
}
}
@@ -0,0 +1,214 @@
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A typed grid goes into the ADIF GRIDSQUARE field and Wavelog stores whatever arrives, where a
* wrong square is worse than a missing one - it pollutes grid statistics and VUCC tracking.
*
* The rule follows the callsign field: refuse only what is certainly wrong. Four characters is a
* legitimate exchange on satellites, so requiring six would reject good entries.
*/
class GridEntryTest {
/** Real grids, from stations and from this project's own test data. */
private val realGrids = listOf(
"OL72AP", // Shenzhen, the user's own
"FN31pr", // W1AW, mixed case as conventionally written
"JO31", // four-character exchange
"PM95uq",
"IO91vl39", // eight-character extended
"gf15vc", // all lower case
"RR73" // the highest legal field pair
)
@Test
fun `real grids are accepted`() {
for (grid in realGrids) {
val verdict = GridEntry.check(grid)
assertTrue("$grid must be usable, got $verdict", verdict is GridEntry.Verdict.Acceptable)
}
}
/**
* Cross-check against the app's own converter: anything this accepts at six characters or more
* must also decode to a real position, or the two disagree about what a grid is.
*/
@Test
fun `accepted grids of six or more decode to a position`() {
for (grid in realGrids.filter { it.length >= 6 }) {
val verdict = GridEntry.check(grid)
assertTrue(verdict is GridEntry.Verdict.Acceptable)
assertNotNull(
"$grid was accepted here but qthToPosition rejects it",
qthToPosition((verdict as GridEntry.Verdict.Acceptable).normalised)
)
}
}
/** Four characters is a real exchange, flagged rather than refused. */
@Test
fun `a four character square is acceptable with a warning`() {
val verdict = GridEntry.check("JO31")
assertEquals(
GridEntry.Verdict.Acceptable("JO31", GridEntry.Warning.SQUARE_ONLY),
verdict
)
}
@Test
fun `six characters carry no warning`() {
val verdict = GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable
assertNull(verdict.warning)
}
/** Upper field, lower subsquare - the conventional rendering, whatever was typed. */
@Test
fun `case is normalised rather than demanded`() {
assertEquals("OL72ap", (GridEntry.check("ol72ap") as GridEntry.Verdict.Acceptable).normalised)
assertEquals("OL72ap", (GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable).normalised)
assertEquals("OL72ap", (GridEntry.check("oL72Ap") as GridEntry.Verdict.Acceptable).normalised)
}
@Test
fun `surrounding whitespace does not matter`() {
assertEquals("OL72ap", (GridEntry.check(" OL72AP ") as GridEntry.Verdict.Acceptable).normalised)
}
/**
* Two characters is legal per ADIF, so it is accepted - but a field is 20 by 10 degrees, close
* to useless for a satellite contact, so it says so rather than refusing.
*/
@Test
fun `a two character field is acceptable with a warning`() {
assertEquals(
GridEntry.Verdict.Acceptable("OL", GridEntry.Warning.FIELD_ONLY),
GridEntry.check("ol")
)
}
/** A 2-character entry must not read past its own end. */
@Test
fun `a two character field does not crash`() {
for (grid in listOf("AA", "RR", "ol", "FN")) {
assertTrue(GridEntry.check(grid) is GridEntry.Verdict.Acceptable)
}
}
/**
* Kotlin's Char.isDigit() is Unicode-aware and accepted Arabic-Indic, Devanagari, Persian and
* fullwidth digits, which a localised keypad produces without the operator seeing a difference.
* ADIF 3.1.7 defines Digit as ASCII 48-57, and Wavelog stores GRIDSQUARE verbatim, so such a
* value would never match a real grid in any statistics query.
*/
@Test
fun `non-ascii digits are not digits`() {
val nonAscii = listOf(
"OL\u0661\u0662ap",
"OL\u0968\u0968ap",
"OL\uff11\uff12ap",
"OL\u06f1\u06f2ap",
"IO91vl\u0663\u0669"
)
for (grid in nonAscii) {
assertTrue(
"must be refused: " + grid,
GridEntry.check(grid) is GridEntry.Verdict.Unusable
)
}
}
/** Nothing typed means the QRZ lookup should run, which is a different outcome from bad input. */
@Test
fun `an empty entry is neither acceptable nor unusable`() {
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(""))
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(" "))
}
/**
* ADIF 3.1.7 permits 2, 4, 6 and 8 characters and nothing between them. A 10 or 12 character
* locator stores its first 8 here with the rest in GRIDSQUARE_EXT, which nothing in this app
* carries - the field clips at 8, which produces the spec-correct value.
*/
@Test
fun `only 2 4 6 or 8 characters can be a grid`() {
for (bad in listOf("O", "OL7", "OL72A", "OL72APX", "OL72AP123")) {
val verdict = GridEntry.check(bad)
assertEquals(
"$bad must be refused for length, got $verdict",
GridEntry.Verdict.Unusable(GridEntry.Reason.WRONG_LENGTH),
verdict
)
}
}
/**
* The first pair runs A-R only. S-X decodes past the poles, which is how a plausible-looking
* entry produces a position that cannot exist.
*/
@Test
fun `a field pair past R is refused`() {
for (bad in listOf("SS12AA", "ZZ99ZZ", "TT34bb")) {
assertEquals(
"$bad decodes outside the world",
GridEntry.Verdict.Unusable(GridEntry.Reason.FIELD_OUT_OF_RANGE),
GridEntry.check(bad)
)
}
}
@Test
fun `the square pair must be digits`() {
for (bad in listOf("OLAAAP", "OL7AAP", "ABCDEF")) {
assertEquals(
"$bad has no square digits",
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
GridEntry.check(bad)
)
}
}
/** The subsquare runs A-X. Y and Z do not exist. */
@Test
fun `a subsquare past X is refused`() {
assertEquals(
GridEntry.Verdict.Unusable(GridEntry.Reason.SUBSQUARE_OUT_OF_RANGE),
GridEntry.check("OL72YZ")
)
}
/** A callsign is the most likely wrong thing to be typed into this field. */
@Test
fun `a callsign is not a grid`() {
for (call in listOf("BG7NTA", "W1AW", "JA1ABC", "DL1ABC")) {
val verdict = GridEntry.check(call)
assertTrue(
"$call must not read as a grid, got $verdict",
verdict is GridEntry.Verdict.Unusable
)
}
}
/** Digits alone are a frequency or a report, not a grid. */
@Test
fun `numbers alone are not a grid`() {
for (bad in listOf("123456", "5959", "14550")) {
assertTrue(GridEntry.check(bad) is GridEntry.Verdict.Unusable)
}
}
/** The extended form ends in two digits. */
@Test
fun `an eight character grid needs digits at the end`() {
assertTrue(GridEntry.check("IO91vl39") is GridEntry.Verdict.Acceptable)
assertEquals(
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
GridEntry.check("IO91vlab")
)
}
}
@@ -0,0 +1,115 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LotwSatelliteIdsTest {
/**
* Every name the table produces has to exist in the LoTW list verbatim, because LoTW
* rejects a QSO whose SAT_NAME is spelled differently - AO7 for AO-7 is refused.
*/
@Test
fun `every mapped name is spelled exactly as LoTW has it`() {
val known = LotwSatellites.names
for (catnum in catnums) {
val name = LotwSatelliteIds.nameFor(catnum)
assertTrue("$catnum maps to $name, which LoTW does not list", name in known)
}
}
/**
* The catalogue number is the key precisely so that the source a user happens to fetch
* from cannot change the answer. These are the numbers whose names differ most between
* Celestrak and AMSAT, so they are the ones worth pinning.
*/
@Test
fun `names that differ between sources resolve to the LoTW spelling`() {
assertEquals("AO-91", LotwSatelliteIds.nameFor(43017)) // RADFXSAT (FOX-1B) / AO-91
assertEquals("QO-100", LotwSatelliteIds.nameFor(43700)) // ES'HAIL 2 / QO-100
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) // TIANYAN 01 / CAS-6 (TO-108)
assertEquals("SO-50", LotwSatelliteIds.nameFor(27607)) // SAUDISAT 1C (SO-50) / SO-50
assertEquals("AO-123", LotwSatelliteIds.nameFor(61781)) // ASRTU-1 (AO-123) / AO-123
}
/**
* ARISS is the station, not its modules. Celestrak's full catalogue lists ISS (UNITY),
* (ZVEZDA), (DESTINY) and (NAUKA) as separate objects; matching on the name "ISS" pulled
* all of them in, and a QSO cannot be worked through a module.
*/
@Test
fun `ARISS is the station and not one of its modules`() {
assertEquals("ARISS", LotwSatelliteIds.nameFor(25544))
for (module in listOf(25575, 26400, 26700, 49044)) {
assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module))
}
}
/**
* One source (R4UAB) names 53109 as ROBUSTA 1F while four others call it GREENCUBE
* (IO-117), and 53106 appears in that one source alone. Trusting either would have put a
* second catalogue number on the same LoTW name.
*/
@Test
fun `IO-117 resolves to the number the amateur sources agree on`() {
assertEquals("IO-117", LotwSatelliteIds.nameFor(53109))
assertNull(LotwSatelliteIds.nameFor(53106))
}
/** 44879 is TIANQIN 1, catalogued but not an amateur satellite carrying TO-108. */
@Test
fun `TO-108 does not also match TIANQIN 1`() {
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881))
assertNull(LotwSatelliteIds.nameFor(44879))
}
/** No two numbers may share a name, or one of them is the wrong satellite. */
@Test
fun `each LoTW name is claimed by a single catalogue number`() {
val names = catnums.mapNotNull { LotwSatelliteIds.nameFor(it) }
assertEquals(names.size, names.toSet().size)
}
@Test
fun `isKnown agrees with nameFor`() {
assertTrue(LotwSatelliteIds.isKnown(27607))
assertFalse(LotwSatelliteIds.isKnown(99999))
assertNull(LotwSatelliteIds.nameFor(99999))
}
/** Guards against an edit that empties or truncates the table. */
@Test
fun `table holds every entry`() {
assertEquals(catnums.size, LotwSatelliteIds.size)
}
/**
* The TEVEL-2 constellation needs the table more than anything else does: every source
* writes these TEVEL2-N while LoTW has TEV2-N, and stripping separators does not bridge
* that - TEVEL21 is not TEV21 - so without these rows their QSOs upload under a name LoTW
* refuses. Nine satellites launched in 2025 and currently workable; they were missing from
* the first version of this table.
*/
@Test
fun `the TEVEL-2 constellation maps to the LoTW spelling`() {
assertEquals("TEV2-1", LotwSatelliteIds.nameFor(63217))
assertEquals("TEV2-4", LotwSatelliteIds.nameFor(63213))
assertEquals("TEV2-9", LotwSatelliteIds.nameFor(63237))
// Nine numbers, nine distinct names. The numbering is deliberately not sequential -
// 63217 is TEVEL2-1 while 63213 is TEVEL2-4 - so an off-by-one logs the wrong bird.
val tevel = listOf(63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239)
val names = tevel.mapNotNull { LotwSatelliteIds.nameFor(it) }
assertEquals(tevel.size, names.size)
assertEquals(tevel.size, names.toSet().size)
}
private val catnums = listOf(
7530, 14129, 20439, 20442, 22825, 23439, 24278, 25544, 26609, 26931,
27607, 28650, 39444, 40025, 40074, 40908, 40931, 40967, 41847, 43017,
43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781,
63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239
)
}
@@ -0,0 +1,130 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The screen used to stamp the current time with no way to change it, which is wrong for the way
* satellite operators actually work: record the pass, transcribe it afterwards. Every transcribed
* contact was then off by however long the transcription took.
*
* The parse is deliberately narrow. A mis-parsed time silently backdates a contact and nothing
* downstream would notice, so anything unclear has to be rejected rather than guessed.
*/
class PassClockTest {
private val dayStart = 1_800_000_000_000L // some midnight UTC
private val minute = 60_000L
private val hour = 60 * minute
@Test
fun `a colon separated time is understood`() {
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("14:55"))
}
@Test
fun `a bare four digit time is understood`() {
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("1455"))
}
@Test
fun `midnight and the last minute of the day both parse`() {
assertEquals(PassClock.Command.At(0), PassClock.parse("00:00"))
assertEquals(PassClock.Command.At(23 * 60 + 59), PassClock.parse("23:59"))
}
@Test
fun `an impossible time is not guessed at`() {
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("24:00"))
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("12:60"))
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("99:99"))
}
@Test
fun `a callsign shaped entry is not a time`() {
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("BG7NTA"))
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("59"))
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("OL72AP"))
}
@Test
fun `a shift in minutes is understood in both directions`() {
assertEquals(PassClock.Command.Shift(3), PassClock.parse("+3"))
assertEquals(PassClock.Command.Shift(-2), PassClock.parse("-2"))
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15m"))
}
/** A pass lasts minutes, so a huge shift is a typo rather than an intention. */
@Test
fun `an absurd shift is rejected`() {
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+99999"))
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+x"))
}
@Test
fun `empty or now returns to live time`() {
assertEquals(PassClock.Command.Live, PassClock.parse(""))
assertEquals(PassClock.Command.Live, PassClock.parse(" "))
assertEquals(PassClock.Command.Live, PassClock.parse("now"))
assertEquals(PassClock.Command.Live, PassClock.parse("NOW"))
}
@Test
fun `live time resolves to now`() {
val now = dayStart + 10 * hour
assertEquals(now, PassClock.resolve(PassClock.Command.Live, now, dayStart))
}
/** An unrecognised entry must not move the clock. */
@Test
fun `an unrecognised command leaves the time alone`() {
val now = dayStart + 10 * hour
assertEquals(now, PassClock.resolve(PassClock.Command.Unrecognised, now, dayStart))
}
@Test
fun `an absolute time earlier today resolves to today`() {
val now = dayStart + 14 * hour
val resolved = PassClock.resolve(PassClock.Command.At(10 * 60 + 30), now, dayStart)
assertEquals(dayStart + 10 * hour + 30 * minute, resolved)
}
/**
* The case that would otherwise put a contact a day in the future. Transcribing at 00:05 UTC a
* pass that ran at 23:58 is normal, not an error - passes cross midnight UTC routinely.
*/
@Test
fun `an absolute time later than now belongs to the previous day`() {
val now = dayStart + 5 * minute // 00:05 UTC
val resolved = PassClock.resolve(PassClock.Command.At(23 * 60 + 58), now, dayStart)
assertEquals(dayStart - 86_400_000L + 23 * hour + 58 * minute, resolved)
assertTrue("must be in the past", resolved < now)
}
@Test
fun `a shift moves the time by whole minutes`() {
val now = dayStart + 10 * hour
assertEquals(now + 3 * minute, PassClock.resolve(PassClock.Command.Shift(3), now, dayStart))
assertEquals(now - 2 * minute, PassClock.resolve(PassClock.Command.Shift(-2), now, dayStart))
}
/** The UI needs to show that the clock is no longer following real time. */
@Test
fun `holding is reported only when the clock has moved`() {
assertTrue(PassClock.isHolding(PassClock.Command.At(600)))
assertTrue(PassClock.isHolding(PassClock.Command.Shift(-5)))
assertFalse(PassClock.isHolding(PassClock.Command.Live))
assertFalse(PassClock.isHolding(PassClock.Command.Unrecognised))
assertFalse("a zero shift is still live", PassClock.isHolding(PassClock.Command.Shift(0)))
}
/** Whitespace and case must not change the verdict - this is typed one-handed outdoors. */
@Test
fun `surrounding whitespace does not matter`() {
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse(" 14:55 "))
assertEquals(PassClock.Command.Shift(3), PassClock.parse(" +3 "))
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15M"))
}
}
@@ -0,0 +1,110 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it, so these pin
* the exact spellings against the names real TLE sources actually publish.
*/
class SatNameResolutionTest {
/**
* The catalogue number decides it whenever it is known, because sources disagree on the
* name for the same object and the descriptive part of a TLE name is never the designator.
*/
@Test
fun `the catalogue number wins over whatever the TLE calls it`() {
assertEquals("AO-123", WaveLogApi.normalizeSatName("ASRTU-1", 61781))
assertEquals("AO-91", WaveLogApi.normalizeSatName("RADFXSAT (FOX-1B)", 43017))
assertEquals("QO-100", WaveLogApi.normalizeSatName("ES'HAIL 2", 43700))
assertEquals("TO-108", WaveLogApi.normalizeSatName("TIANYAN 01", 44881))
assertEquals("CAS-3H", WaveLogApi.normalizeSatName("LILACSAT-2", 40908))
assertEquals("ARISS", WaveLogApi.normalizeSatName("ISS (ZARYA)", 25544))
}
/**
* Two sources naming one satellite differently must still upload identically, or which
* source the operator fetched from would decide whether the QSO can be confirmed.
*/
@Test
fun `the same satellite resolves alike whichever source named it`() {
val fromCelestrak = WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 27607)
val fromAmsat = WaveLogApi.normalizeSatName("SO-50", 27607)
assertEquals(fromCelestrak, fromAmsat)
assertEquals("SO-50", fromCelestrak)
}
/** Without a catalogue number - QSOs logged before it was recorded - the name is tried. */
@Test
fun `falls back to the name when no catalogue number is given`() {
assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)"))
assertEquals("AO-7", WaveLogApi.normalizeSatName("OSCAR 7 (AO-7)"))
assertEquals("AO-73", WaveLogApi.normalizeSatName("FUNCUBE-1 (AO-73)"))
}
/**
* Which side of the parentheses holds the designator varies, so both are tried:
* "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" has the useful part outside.
*/
@Test
fun `looks on both sides of the parentheses`() {
assertEquals("IO-117", WaveLogApi.normalizeSatName("GREENCUBE (IO-117)"))
assertEquals("ARISS", WaveLogApi.normalizeSatName("ARISS (ZARYA)"))
}
/** Sources write RS15 where LoTW has RS-15; a hyphen must not decide confirmability. */
@Test
fun `a differing hyphen still matches`() {
assertEquals("RS-15", WaveLogApi.normalizeSatName("RADIO ROSTO (RS15)"))
assertEquals("UKUBE1", WaveLogApi.normalizeSatName("UKUBE-1"))
}
/** Formation launches are catalogued with a companion object appended. */
@Test
fun `a shared launch entry resolves to the satellite`() {
assertEquals("CAS-2T", WaveLogApi.normalizeSatName("CAS-2T & KS-1Q"))
assertEquals("RS-44", WaveLogApi.normalizeSatName("RS-44 & BREEZE-KM R/B"))
}
/**
* LoTW's list is not all upper case - Arsene is written that way - and uploading ARSENE
* would be rejected just as AO7 is.
*/
@Test
fun `the spelling LoTW uses is preserved rather than upper-cased`() {
assertEquals("Arsene", WaveLogApi.normalizeSatName("ARSENE"))
assertEquals("Arsene", WaveLogApi.normalizeSatName("arsene"))
assertEquals("Arsene", WaveLogApi.normalizeSatName("Arsene"))
}
/**
* An unknown satellite is passed through rather than dropped: the QSO is still worth
* saving, and the caller uses [WaveLogApi.isLotwSatellite] to tell the operator that
* this one will not be confirmed.
*/
@Test
fun `an unknown satellite is passed through and reported as unknown`() {
assertEquals("CUTE-1 (CO-55)", WaveLogApi.normalizeSatName("CUTE-1 (CO-55)"))
assertFalse(WaveLogApi.isLotwSatellite("CUTE-1 (CO-55)"))
assertFalse(WaveLogApi.isLotwSatellite("CUBESAT XI-V"))
assertFalse(WaveLogApi.isLotwSatellite("SWISSCUBE"))
assertTrue(WaveLogApi.isLotwSatellite("SAUDISAT 1C (SO-50)"))
assertTrue(WaveLogApi.isLotwSatellite("ASRTU-1", 61781))
}
@Test
fun `an empty name does not crash`() {
assertEquals("", WaveLogApi.normalizeSatName(""))
assertEquals("", WaveLogApi.normalizeSatName(" "))
assertFalse(WaveLogApi.isLotwSatellite(""))
}
/** A catalogue number nobody mapped falls through to the name rather than returning null. */
@Test
fun `an unmapped catalogue number falls back to the name`() {
assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 99999))
}
}
@@ -0,0 +1,262 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule these pin down: a 200 is not an acceptance.
*
* Wavelog validates after responding and reports the outcome in the body, so a rejected QSO comes
* back as HTTP 200 with `{"status":"failed"}`. The uploader trusted the code alone, marked the QSO
* uploaded and dropped it from the queue - the same failure as the APRS reporter claiming a send
* succeeded when nothing had left the phone.
*
* The response shapes are transcribed from the Wavelog API reference at
* docs.wavelog.org/developer/api, not invented.
*/
class WavelogResponseTest {
@Test
fun `a success body is accepted`() {
assertTrue(
WavelogResponse.verdict(201, """{"status":"success","message":"1 locations imported."}""")
is WavelogResponse.Verdict.Accepted
)
}
/** The reference uses both spellings; treating one as unknown would requeue a stored QSO. */
@Test
fun `successful is recognised as well as success`() {
assertTrue(
WavelogResponse.verdict(200, """{"status":"successful","members":[]}""")
is WavelogResponse.Verdict.Accepted
)
}
/** The defect this class exists for. */
@Test
fun `a failure body beats a success code`() {
val verdict = WavelogResponse.verdict(200, """{"status":"failed","reason":"No club members found"}""")
assertEquals(
WavelogResponse.Verdict.Rejected("No club members found"),
verdict
)
}
@Test
fun `an error status with a message is rejected with that message`() {
val verdict = WavelogResponse.verdict(401, """{"status":"error","message":"Auth Error, invalid key"}""")
assertEquals(
WavelogResponse.Verdict.Rejected("Auth Error, invalid key"),
verdict
)
}
/** Wavelog's own duplicate wording, which arrives with a 200 rather than a 409. */
@Test
fun `a dupe status is a duplicate not a failure`() {
assertEquals(
WavelogResponse.Verdict.Duplicate,
WavelogResponse.verdict(200, """{"status":"dupe","message":"0 locations imported."}""")
)
}
@Test
fun `a 409 is a duplicate whatever the body says`() {
assertEquals(
WavelogResponse.Verdict.Duplicate,
WavelogResponse.verdict(409, "")
)
}
/** A duplicate is safe to drop from the queue: the log holds the QSO either way. */
@Test
fun `a duplicate is not treated as an error`() {
val verdict = WavelogResponse.verdict(200, """{"status":"dupe"}""")
assertTrue(verdict !is WavelogResponse.Verdict.Rejected)
}
/** The v1 endpoint answers an accepted ADIF record with a success code and nothing else. */
@Test
fun `an empty body with a success code is accepted`() {
assertTrue(
WavelogResponse.verdict(200, "") is WavelogResponse.Verdict.Accepted
)
assertTrue(
WavelogResponse.verdict(200, " ") is WavelogResponse.Verdict.Accepted
)
}
/**
* An unrecognised status must not read as success. Keeping the QSO queued costs a retry;
* dropping it loses the contact.
*/
@Test
fun `an unrecognised status keeps the QSO queued`() {
val verdict = WavelogResponse.verdict(200, """{"status":"something-new"}""")
assertTrue("got $verdict", verdict is WavelogResponse.Verdict.Unreadable)
}
@Test
fun `a non-success code without a body reports the code`() {
assertEquals(
WavelogResponse.Verdict.Rejected("HTTP 500"),
WavelogResponse.verdict(500, "")
)
}
/** Whitespace in the JSON must not change the verdict - servers format differently. */
@Test
fun `spacing in the json does not change the verdict`() {
assertEquals(
WavelogResponse.Verdict.Rejected("nope"),
WavelogResponse.verdict(200, """{ "status" : "failed" , "reason" : "nope" }""")
)
}
/** A messages array, which the ADIF endpoint returns for a malformed record. */
@Test
fun `a messages array supplies the reason`() {
val verdict = WavelogResponse.verdict(200, """{"status":"failed","messages":["Bad ADIF field"]}""")
assertEquals(
WavelogResponse.Verdict.Rejected("Bad ADIF field"),
verdict
)
}
/** Case must not matter: the marker comparison lower-cases the body. */
@Test
fun `an upper case status is still understood`() {
assertTrue(
WavelogResponse.verdict(200, """{"STATUS":"FAILED","reason":"x"}""")
is WavelogResponse.Verdict.Rejected
)
}
/**
* The defect that lost contacts. Wavelog's own rejection text is "Duplicate for <call>", and
* Api_v2 surfaces that inside validation_error bodies - so matching the bare word classified a
* hard rejection as a duplicate, which maps to success and drops the QSO from the queue.
*
* Bodies transcribed from the Wavelog server source, not from its documentation.
*/
@Test
fun `the word duplicate in a rejection does not make it a duplicate`() {
val bodies = listOf(
400 to """{"error":{"code":"validation_error","message":"duplicate submode is invalid"}}""",
400 to """{"status":"failed","reason":"duplicate key violation; QSO NOT stored"}""",
200 to """{"status":"failed","reason":"Duplicate for BG7NTA"}"""
)
for ((code, body) in bodies) {
val verdict = WavelogResponse.verdict(code, body)
assertTrue(
"must be a rejection, got " + verdict + " for " + body,
verdict is WavelogResponse.Verdict.Rejected
)
}
}
/** A success that mentions the word must still be a success. */
@Test
fun `the word duplicate in a success does not make it a failure`() {
val verdict = WavelogResponse.verdict(
201,
"""{"status":"created","adif_errors":0,"messages":["Removed duplicate mode entry"]}"""
)
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted)
}
/**
* A reverse proxy or a PHP fatal answers 200 with HTML. There is no status token, so it used to
* read as a stored QSO and a misconfigured proxy would eat contacts silently.
*/
@Test
fun `an html body is never an acceptance`() {
val pages = listOf(
"<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head></html>",
"<html><body><h1>Maintenance</h1></body></html>",
"<br /><b>Fatal error</b>: Uncaught Error in /var/www/index.php"
)
for (page in pages) {
val verdict = WavelogResponse.verdict(200, page)
assertTrue(
"html must not be accepted, got " + verdict,
verdict is WavelogResponse.Verdict.Unreadable
)
}
}
/** The v2 endpoint reports errors in an envelope with no status key at all. */
@Test
fun `a v2 error envelope is a rejection`() {
val verdict = WavelogResponse.verdict(
401,
"""{"error":{"code":"invalid_token","message":"Bearer token must start with wl2_"}}"""
)
assertEquals(
WavelogResponse.Verdict.Rejected("Bearer token must start with wl2_"),
verdict
)
}
/** The QSO endpoint answers `created`, which is what the v1 source actually emits. */
@Test
fun `the created status the qso endpoint returns is accepted`() {
val verdict = WavelogResponse.verdict(
201,
"""{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}"""
)
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted)
}
/** v1 uses `abort` with a 400 when any record in a batch failed. Not a success. */
@Test
fun `an abort status is a rejection`() {
val verdict = WavelogResponse.verdict(
400,
"""{"status":"abort","messages":["Bad ADIF field CALL"]}"""
)
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Rejected)
}
/** A bulk reply that stored nothing is not an acceptance, whatever its status says. */
@Test
fun `a reply that imported nothing is a rejection`() {
for (body in listOf("""{"imported":0,"skipped":3}""",
"""{"status":"created","adif_count":0,"adif_errors":1,"messages":["bad"]}""")) {
assertTrue(
"must not be accepted: " + body,
WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Rejected
)
}
}
/**
* The count check must not misfire on a real success. v1 answers with `adif_errors:0` next to
* `adif_count:1`, and matching the wrong key would reject every stored QSO.
*/
@Test
fun `adif_errors zero does not look like nothing imported`() {
for (body in listOf(
"""{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}""",
"""{"status":"created","adif_errors":0}""",
"""{"imported":2,"skipped":1}"""
)) {
assertTrue(
"must still be accepted: " + body,
WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Accepted
)
}
}
/** A failure with no explanation still has to say something usable. */
@Test
fun `a failure without a reason still reports one`() {
val verdict = WavelogResponse.verdict(200, """{"status":"failed"}""")
assertEquals(
WavelogResponse.Verdict.Rejected("server reported failure"),
verdict
)
}
}
@@ -169,4 +169,11 @@
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
<string name="prefs_other_switch_cw_tone_shift">Desplazar tonos CW fuera de rango</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica.</string>
<string name="amsat_no_report_legend">Sin informes</string>
<string name="amsat_no_data_legend">Sin datos</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: una franja por franja de 2 horas</string>
<string name="prefs_other_amsat_stripes_help">Activado, cada día son doce franjas de dos horas, así se ve una interrupción dentro del día. Desactivado, cada día es un color y un recuento de informes; el color es el peor estado del día, así que un solo fallo sigue viéndose.</string>
<string name="amsat_day_desc">%1$s, %2$s, %3$d informes</string>
</resources>
@@ -22,7 +22,7 @@
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
@@ -35,9 +35,10 @@
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="prefs_aprs_request_passcode">Minta kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
@@ -93,22 +94,29 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
* FIX: APRS reported every beacon as sent even when nothing left the phone
* FIX: APRS could not start at all on Android 10 and later
* FIX: the APRS login line was malformed and real servers refused it
* FIX: a refused APRS login is now reported instead of being read as success
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
* FIX: a mistyped passcode is no longer described as receive-only mode
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
* NEW: APRS beacons survive a locked screen
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
* FIX: a rejected contact now stays in the queue instead of being dropped
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
* FIX: the upload count no longer includes contacts sent days ago
* NEW: the contact time can be set, for logging a pass after it ends
* NEW: the counterpart grid can be typed, and is checked before it is logged
* FIX: the mode no longer keeps the value from the previous transponder
* FIX: a callsign that cannot be logged says why instead of vanishing
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
* FIX: the custom source switch no longer turns itself off
* FIX: a dead custom URL no longer counts as a successful update
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
* FIX: contacts can be deleted with a screen reader
* FIX: the log table grid and the sent column are readable in sunlight and at night
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
@@ -298,4 +306,11 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
<string name="prefs_other_amsat_stripes_help">Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat.</string>
<string name="amsat_day_desc">%1$s, %2$s, %3$d laporan</string>
</resources>
@@ -22,7 +22,7 @@
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
@@ -35,9 +35,10 @@
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="prefs_aprs_request_passcode">Minta kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
@@ -92,22 +93,29 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
* FIX: APRS reported every beacon as sent even when nothing left the phone
* FIX: APRS could not start at all on Android 10 and later
* FIX: the APRS login line was malformed and real servers refused it
* FIX: a refused APRS login is now reported instead of being read as success
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
* FIX: a mistyped passcode is no longer described as receive-only mode
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
* NEW: APRS beacons survive a locked screen
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
* FIX: a rejected contact now stays in the queue instead of being dropped
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
* FIX: the upload count no longer includes contacts sent days ago
* NEW: the contact time can be set, for logging a pass after it ends
* NEW: the counterpart grid can be typed, and is checked before it is logged
* FIX: the mode no longer keeps the value from the previous transponder
* FIX: a callsign that cannot be logged says why instead of vanishing
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
* FIX: the custom source switch no longer turns itself off
* FIX: a dead custom URL no longer counts as a successful update
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
* FIX: contacts can be deleted with a screen reader
* FIX: the log table grid and the sent column are readable in sunlight and at night
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
@@ -298,4 +306,11 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
<string name="prefs_other_amsat_stripes_help">Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat.</string>
<string name="amsat_day_desc">%1$s, %2$s, %3$d laporan</string>
</resources>
@@ -169,4 +169,11 @@
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
<string name="prefs_other_switch_cw_tone_shift">Сдвигать CW-тоны вне диапазона</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
<string name="amsat_no_report_legend">Нет отчётов</string>
<string name="amsat_no_data_legend">Нет данных</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: полоса на каждые 2 часа</string>
<string name="prefs_other_amsat_stripes_help">Включено — каждый день это двенадцать двухчасовых полос, поэтому перерыв внутри дня виден. Выключено — каждый день это один цвет и число отчётов; цвет соответствует худшему состоянию за день, так что даже один сбой остаётся заметен.</string>
<string name="amsat_day_desc">%1$s, %2$s, отчётов: %3$d</string>
</resources>
@@ -169,4 +169,11 @@
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
<string name="prefs_other_switch_cw_tone_shift">පරාසයෙන් පිටත CW ස්වර මාරු කරන්න</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
<string name="amsat_no_report_legend">වාර්තා නැත</string>
<string name="amsat_no_data_legend">දත්ත නැත</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: පැය 2 කට එක තීරුවක්</string>
<string name="prefs_other_amsat_stripes_help">සක්‍රිය විට සෑම දිනයක් පැය දෙකේ තීරු දොළහකි, එබැවින් දිනක් තුළ ඇති බිඳවැටීම දැකිය හැක. අක්‍රිය විට සෑම දිනයක් එක් වර්ණයක් සහ වාර්තා ගණනකි — වර්ණය එදින නරකම තත්ත්වයයි, එබැවින් එක් අසාර්ථකත්වයක් වුවද පෙනේ.</string>
<string name="amsat_day_desc">%1$s, %2$s, වාර්තා %3$d</string>
</resources>
@@ -23,7 +23,7 @@
<string name="prefs_aprs_callsign">Çağrı işareti</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Parola</string>
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
<string name="prefs_aprs_passcode_hint">Passcode bos = yalnizca alma modu, raporlar iletilmez</string>
<string name="prefs_aprs_interval">Aralık (dakika)</string>
<string name="prefs_aprs_status">Durum metni</string>
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
@@ -36,9 +36,10 @@
<string name="aprs_notif_connected">APRS-IS bağlı</string>
<string name="aprs_notif_error">Bağlantı hatası</string>
<string name="aprs_notif_stop">Durdur</string>
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
<string name="prefs_aprs_request_passcode">Passcode isteyin</string>
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS passcode bilginizi doğrulamadı. Raporlarınız iletilmiyor - Ayarlar bölümünde çağrı işareti ve passcode bilgisini kontrol edin.</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
@@ -97,22 +98,29 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
* Daha Fazla menüsü: sağda ince panel, karartma yok
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
* 64-bit (arm64) desteği geri geldi
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
* Düzeltme: radar Doppler hesaplaması artık canlı kayma kaydırıcı değerini kullanıyor
* Düzeltme: geçiş ilerleme hesaplaması sıfıra bölme durumuna karşı korumalı
* Düzeltme: eşzamanlı calculatePasses çağrıları artık yinelenen hesaplamaları tetiklemiyor
* Düzeltme: WaveLog yinelenen QSO gönderimleri ve ızgara kare güncellemesi yarış durumları
* FIX: APRS reported every beacon as sent even when nothing left the phone
* FIX: APRS could not start at all on Android 10 and later
* FIX: the APRS login line was malformed and real servers refused it
* FIX: a refused APRS login is now reported instead of being read as success
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
* FIX: a mistyped passcode is no longer described as receive-only mode
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
* NEW: APRS beacons survive a locked screen
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
* FIX: a rejected contact now stays in the queue instead of being dropped
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
* FIX: the upload count no longer includes contacts sent days ago
* NEW: the contact time can be set, for logging a pass after it ends
* NEW: the counterpart grid can be typed, and is checked before it is logged
* FIX: the mode no longer keeps the value from the previous transponder
* FIX: a callsign that cannot be logged says why instead of vanishing
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
* FIX: the custom source switch no longer turns itself off
* FIX: a dead custom URL no longer counts as a successful update
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
* FIX: contacts can be deleted with a screen reader
* FIX: the log table grid and the sent column are readable in sunlight and at night
</string>
<!-- Radar screen -->
@@ -310,4 +318,11 @@
<string name="radar_cw_tone">Ton %1$d Hz</string>
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
<string name="prefs_other_switch_cw_tone_shift">Aralık dışı CW tonlarını kaydır</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez.</string>
<string name="amsat_no_report_legend">Rapor yok</string>
<string name="amsat_no_data_legend">Veri yok</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: 2 saatlik dilim başına şerit</string>
<string name="prefs_other_amsat_stripes_help">Açıkken her gün on iki iki saatlik şerittir, böylece gün içindeki kesinti görünür. Kapalıyken her gün tek renk ve rapor sayısıdır; renk günün en kötü durumudur, yani tek bir arıza bile görünür kalır.</string>
<string name="amsat_day_desc">%1$s, %2$s, %3$d rapor</string>
</resources>
@@ -169,4 +169,11 @@
<string name="prefs_outro_title">Я хотів би подякувати:</string>
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
<string name="prefs_other_switch_cw_tone_shift">Зсувати CW-тони поза діапазоном</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
<string name="amsat_no_report_legend">Немає звітів</string>
<string name="amsat_no_data_legend">Немає даних</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: смуга на кожні 2 години</string>
<string name="prefs_other_amsat_stripes_help">Увімкнено — кожен день це дванадцять двогодинних смуг, тож перерва всередині дня видна. Вимкнено — кожен день це один колір і кількість звітів; колір відповідає найгіршому стану за день, тож навіть один збій залишається помітним.</string>
<string name="amsat_day_desc">%1$s, %2$s, звітів: %3$d</string>
</resources>
@@ -24,10 +24,27 @@
<string name="prefs_aprs_callsign">呼号</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">验证码</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 只收模式, 上报不会被转发</string>
<string name="prefs_aprs_interval">周期(分钟)</string>
<string name="prefs_aprs_status">状态文本</string>
<string name="prefs_aprs_symbol_table">符号表</string>
<string name="aprs_symbol_house">房屋 - 固定台站</string>
<string name="aprs_symbol_house_alt">房屋, 备用符号表</string>
<string name="aprs_symbol_person">步行操作员</string>
<string name="aprs_symbol_yagi">固定站八木天线</string>
<string name="aprs_symbol_satellite">卫星台站</string>
<string name="aprs_symbol_portable">便携 / 野外运行</string>
<string name="aprs_symbol_phone">手机</string>
<string name="aprs_symbol_tcpip">仅互联网台站</string>
<string name="aprs_symbol_ht">手持电台</string>
<string name="aprs_symbol_car">轿车</string>
<string name="aprs_symbol_truck">卡车</string>
<string name="aprs_symbol_van">厢式车</string>
<string name="aprs_symbol_rv">房车</string>
<string name="aprs_symbol_bike">自行车</string>
<string name="prefs_aprs_symbol">地图符号</string>
<string name="prefs_aprs_symbol_custom">自定义 (%1$s%2$s)</string>
<string name="prefs_aprs_symbol_help">你的台站在 APRS 地图上的显示方式</string>
<string name="prefs_aprs_symbol_code">符号码</string>
<string name="prefs_aprs_save">保存</string>
<string name="prefs_aprs_cancel">取消</string>
@@ -37,9 +54,10 @@
<string name="aprs_notif_connected">已连接 APRS-IS</string>
<string name="aprs_notif_error">连接错误</string>
<string name="aprs_notif_stop">停止</string>
<string name="prefs_aprs_calc_passcode">计算验证码</string>
<string name="prefs_aprs_request_passcode">申请验证码</string>
<string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS 未验证你的 passcode, 上报不会被转发 - 请在设置里检查呼号与 passcode。</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string>
@@ -89,22 +107,29 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
* 新: 内置完整版 fp32 模型,解码精度最高
* 新: CW 解码历史永久保留(文字不再消失)
* CW 瀑布图: 全新 inferno 配色与平滑渐变
* 更多菜单: 改为靠右窄面板,不再压暗页面
* CW 页面: 移除无效的返回键与误导性状态提示
* 恢复 64 位(arm64)支持
* 修复: 暂停/恢复不再误报"解码失败"
* 修复: 移动页面到主菜单不再导致设置入口永久消失
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
* 修复: 过境进度计算防除零崩溃
* 修复: 并发 calculatePasses 调用不再触发重复计算
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
* 修复: APRS 无论是否真的发出都报告成功
* 修复: APRS 在 Android 10 及以上完全无法启动
* 修复: APRS 登录行格式错误, 真实服务器会拒绝
* 修复: 服务器拒绝登录时不再当作成功
* 修复: 不再代算 APRS 验证码 - 请从设置里的链接申请
* 修复: 验证码打错不再被说成只收模式
* 修复: 没有位置时拒绝上报, 不再发送 0N 0E
* 新: APRS 地图符号改为列表选择, 默认房屋而非汽车
* 新: APRS 上报在锁屏后继续
* 修复: WaveLog 上传会读服务器回复 - 被拒的联络原先仍报成功
* 修复: 被拒的联络保留在队列里, 不再被丢弃
* 修复: 上传给 WaveLog 的卫星名改由编号解析
* 修复: 上传计数不再把几天前传过的算进来
* 新: 联络时间可以设置, 便于过境结束后再记录
* 新: 对方网格可以手动输入, 并在记录前校验
* 修复: 切换转发器后模式不再保留上一个的值
* 修复: 无法记录的呼号会说明原因, 不再消失
* 修复: 自定义 TLE 地址现在替换内置源, 而不是额外增加
* 修复: 自定义源开关不再自己关掉
* 修复: 自定义地址失效时不再算作更新成功
* 修复: QRZ 能区分 Cookie 过期与对方未填网格
* 修复: 使用读屏软件也能删除联络
* 修复: 日志表格线与已传列在阳光下和夜间都看得清
</string>
<!-- Radar screen -->
@@ -245,8 +270,43 @@
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_grid_hint">网格 (可选)</string>
<string name="wavelog_grid_help">对方报给你的网格。留空则从 QRZ 查询。</string>
<string name="wavelog_grid_bad">不是有效网格</string>
<string name="wavelog_grid_square_only">仅方格 - 精度约 100 公里</string>
<string name="wavelog_grid_field_only">仅大方格 - 精度约 1000 公里</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_mode_edit">修改模式</string>
<string name="wavelog_time_hint">时间</string>
<string name="wavelog_time_live">当前</string>
<string name="wavelog_time_held">按住 %1$s</string>
<string name="wavelog_time_edit">设置记录时间</string>
<string name="wavelog_time_help">UTC 时间如 14:55, 或 +3 / -2 分钟。留空则用当前时间。</string>
<string name="wavelog_time_unrecognised">不是时间 - 将使用当前时间</string>
<string name="wavelog_saved">已存入本地日志</string>
<string name="wavelog_upload_done">已上传 %1$d 条, %2$d 条仍在队列</string>
<string name="wavelog_upload_all_ok">已上传 %1$d 条</string>
<string name="wavelog_nothing_to_upload">没有待上传的联络</string>
<string name="wavelog_not_configured">未配置 WaveLog 服务器</string>
<string name="wavelog_no_station">读不到站点信息 - 请检查站点 ID 与密钥权限</string>
<string name="wavelog_upload_failed">上传失败</string>
<string name="wavelog_upload_queued">部分联络未能上传, 仍在队列中</string>
<string name="lotw_list_updated">LoTW 卫星列表已更新: %1$d 个</string>
<string name="lotw_list_failed">LoTW 列表更新失败: %1$s</string>
<string name="log_call_too_short">呼号太短</string>
<string name="log_call_illegal">呼号里不能有这个字符</string>
<string name="log_call_not_a_call">这看起来不像呼号</string>
<string name="log_call_too_long">呼号太长</string>
<string name="log_warn_grid">已保存 - 但这看起来像网格而不是呼号</string>
<string name="log_warn_worked">已保存 - %1$s 本圈已经通联过</string>
<string name="log_grid_no_cookie">查网格需要先在设置里填 QRZ Cookie</string>
<string name="log_grid_signed_out">QRZ Cookie 已过期 - 请在设置里重新粘贴</string>
<string name="log_grid_unreachable">连不上 QRZ, 没查到网格</string>
<string name="qrz_test_empty">请先粘贴 Cookie</string>
<string name="qrz_test_signed_out">Cookie 无效或已过期</string>
<string name="qrz_test_ok">登录呼号 %1$s, 网格 %2$s</string>
<string name="qrz_test_no_grid">登录呼号 %1$s, 未填写网格</string>
<string name="qrz_test_unreachable">连不上 QRZ</string>
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
<string name="wavelog_empty">暂无日志记录</string>
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
@@ -257,6 +317,7 @@
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">复制</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_delete_action">删除这条联络</string>
<string name="wavelog_col_time">时间</string>
<string name="wavelog_col_freq">频率</string>
<string name="wavelog_col_sat">卫星</string>
@@ -300,4 +361,11 @@
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
<string name="prefs_other_switch_cw_tone_shift">搬移超出范围的 CW 音调</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
<string name="amsat_no_report_legend">无人上报</string>
<string name="amsat_no_data_legend">无数据</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT:每 2 小时一条纹</string>
<string name="prefs_other_amsat_stripes_help">开启时每天画成 12 条两小时条纹,一天之内的中断也看得见。关闭时每天显示一个颜色和报告总数——颜色取当天最差状态,所以出现过一次故障也不会被藏起来。</string>
<string name="amsat_day_desc">%1$s,%2$s,%3$d 条报告</string>
</resources>
@@ -25,10 +25,27 @@
<string name="prefs_aprs_callsign">Callsign</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Passcode</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = receive-only, reports are not forwarded</string>
<string name="prefs_aprs_interval">Interval (min)</string>
<string name="prefs_aprs_status">Status text</string>
<string name="prefs_aprs_symbol_table">Symbol table</string>
<string name="aprs_symbol_house">House - fixed station</string>
<string name="aprs_symbol_house_alt">House, alternate table</string>
<string name="aprs_symbol_person">Person on foot</string>
<string name="aprs_symbol_yagi">Yagi at a fixed site</string>
<string name="aprs_symbol_satellite">Satellite station</string>
<string name="aprs_symbol_portable">Portable / field operation</string>
<string name="aprs_symbol_phone">Phone</string>
<string name="aprs_symbol_tcpip">Internet-only station</string>
<string name="aprs_symbol_ht">Handheld radio</string>
<string name="aprs_symbol_car">Car</string>
<string name="aprs_symbol_truck">Truck</string>
<string name="aprs_symbol_van">Van</string>
<string name="aprs_symbol_rv">Motorhome</string>
<string name="aprs_symbol_bike">Bicycle</string>
<string name="prefs_aprs_symbol">Map symbol</string>
<string name="prefs_aprs_symbol_custom">Custom (%1$s%2$s)</string>
<string name="prefs_aprs_symbol_help">How your station appears on the APRS map</string>
<string name="prefs_aprs_symbol_code">Symbol code</string>
<string name="prefs_aprs_save">Save</string>
<string name="prefs_aprs_cancel">Cancel</string>
@@ -38,9 +55,10 @@
<string name="aprs_notif_connected">Connected to APRS-IS</string>
<string name="aprs_notif_error">Connection error</string>
<string name="aprs_notif_stop">Stop</string>
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
<string name="prefs_aprs_request_passcode">Request a passcode</string>
<string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_unverified">APRS-IS did not verify your passcode. Your reports are not being passed on - check the callsign and passcode in Settings.</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
@@ -101,22 +119,29 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
* NEW: CW decode history is kept permanently (text no longer disappears)
* CW waterfall: new inferno colour scheme with smooth gradients
* More menu: slim right-hand panel, no dimming overlay
* CW page: removed the dead back button and the misleading status line
* Restored 64-bit (arm64) support
* Fixed: pause/resume no longer shows a spurious "decode failed" error
* Fixed: moving pages to main menu no longer hides Settings permanently
* Fixed: AMSAT/WavelogLog can now be moved to main menu
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
* Fixed: radar page now auto-switches to next pass after current pass ends
* Fixed: radar Doppler calculation now uses live offset slider value
* Fixed: pass progress calculation guards against division by zero
* Fixed: concurrent calculatePasses calls no longer trigger duplicate calculations
* Fixed: WaveLog duplicate QSO submissions and grid square update race conditions
* FIX: APRS reported every beacon as sent even when nothing left the phone
* FIX: APRS could not start at all on Android 10 and later
* FIX: the APRS login line was malformed and real servers refused it
* FIX: a refused APRS login is now reported instead of being read as success
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
* FIX: a mistyped passcode is no longer described as receive-only mode
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
* NEW: APRS beacons survive a locked screen
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
* FIX: a rejected contact now stays in the queue instead of being dropped
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
* FIX: the upload count no longer includes contacts sent days ago
* NEW: the contact time can be set, for logging a pass after it ends
* NEW: the counterpart grid can be typed, and is checked before it is logged
* FIX: the mode no longer keeps the value from the previous transponder
* FIX: a callsign that cannot be logged says why instead of vanishing
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
* FIX: the custom source switch no longer turns itself off
* FIX: a dead custom URL no longer counts as a successful update
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
* FIX: contacts can be deleted with a screen reader
* FIX: the log table grid and the sent column are readable in sunlight and at night
</string>
<!-- Radar screen -->
@@ -276,8 +301,43 @@
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_grid_hint">Grid (optional)</string>
<string name="wavelog_grid_help">What they sent you. Left empty, it is looked up on QRZ.</string>
<string name="wavelog_grid_bad">Not a grid square</string>
<string name="wavelog_grid_square_only">Square only - accurate to about 100 km</string>
<string name="wavelog_grid_field_only">Field only - accurate to about 1000 km</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_mode_edit">Edit the mode</string>
<string name="wavelog_time_hint">Time</string>
<string name="wavelog_time_live">Now</string>
<string name="wavelog_time_held">Held at %1$s</string>
<string name="wavelog_time_edit">Set the logging time</string>
<string name="wavelog_time_help">UTC time such as 14:55, or +3 / -2 minutes. Empty logs at the current time.</string>
<string name="wavelog_time_unrecognised">Not a time - the current time will be used</string>
<string name="wavelog_saved">Saved to local log</string>
<string name="wavelog_upload_done">Uploaded %1$d, %2$d still queued</string>
<string name="wavelog_upload_all_ok">Uploaded %1$d QSO</string>
<string name="wavelog_nothing_to_upload">Nothing waiting to upload</string>
<string name="wavelog_not_configured">WaveLog server not configured</string>
<string name="wavelog_no_station">Cannot read the station profile - check the station ID and key permissions</string>
<string name="wavelog_upload_failed">Upload failed</string>
<string name="wavelog_upload_queued">Some contacts could not be uploaded and stay in the queue</string>
<string name="lotw_list_updated">LoTW satellite list updated: %1$d entries</string>
<string name="lotw_list_failed">LoTW list update failed: %1$s</string>
<string name="log_call_too_short">Callsign too short</string>
<string name="log_call_illegal">A callsign cannot contain that</string>
<string name="log_call_not_a_call">That does not look like a callsign</string>
<string name="log_call_too_long">Callsign too long</string>
<string name="log_warn_grid">Saved - that looks like a grid, not a callsign</string>
<string name="log_warn_worked">Saved - %1$s was already worked this pass</string>
<string name="log_grid_no_cookie">Grid lookup needs a QRZ cookie in Settings</string>
<string name="log_grid_signed_out">QRZ cookie expired - paste a fresh one in Settings</string>
<string name="log_grid_unreachable">Could not reach QRZ for the grid</string>
<string name="qrz_test_empty">Paste a cookie first</string>
<string name="qrz_test_signed_out">Cookie is not valid or has expired</string>
<string name="qrz_test_ok">Signed in as %1$s, grid %2$s</string>
<string name="qrz_test_no_grid">Signed in as %1$s, no grid on file</string>
<string name="qrz_test_unreachable">Could not reach QRZ</string>
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
<string name="wavelog_empty">No log entries yet</string>
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
@@ -288,6 +348,7 @@
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Copy</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_delete_action">Delete this contact</string>
<string name="wavelog_col_time">Time</string>
<string name="wavelog_col_freq">Freq</string>
<string name="wavelog_col_sat">Satellite</string>
@@ -333,4 +394,11 @@
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_other_switch_cw_tone_shift">Shift out-of-range CW tones</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched.</string>
<string name="amsat_no_report_legend">No report</string>
<string name="amsat_no_data_legend">No data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: stripe per 2-hour slot</string>
<string name="prefs_other_amsat_stripes_help">On, each day is twelve two-hour stripes, so an outage inside a day is visible. Off, each day is one colour and a report count - the colour is the day\'s worst status, so a single failure still shows.</string>
<string name="amsat_day_desc">%1$s, %2$s, %3$d reports</string>
</resources>
@@ -51,20 +51,27 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.launch
import androidx.compose.ui.draw.rotate
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R as CoreR
import kotlin.math.roundToInt
/**
* Full-page CW decoder backed by DeepCW.
@@ -92,10 +99,22 @@ fun CwDecodeScreen() {
}
var permanentlyDenied by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Hoisted so the toolbar button can reach it: the record below does not follow
// the decode, so jumping to the newest text has to be an explicit action.
val transcriptScroll = rememberScrollState()
val clipboard = LocalClipboardManager.current
val showToast = remember { container.provideShowToast() }
val copiedMessage = stringResource(R.string.cw_copied)
val emptyRecordMessage = stringResource(R.string.cw_record_empty)
// Archiving the tail on the way out has to outlive this composable's own scope.
val appScope = container.appScope
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val detectedToneHz by decoder.detectedToneHz.collectAsState()
val activeShiftHz by decoder.activeShiftHz.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
@@ -112,7 +131,13 @@ fun CwDecodeScreen() {
// permission so granting the permission mid-flow restarts collection
// (isListening may already be true when the launcher returns).
LaunchedEffect(isListening, permissionGranted) {
if (!isListening) return@LaunchedEffect
if (!isListening) {
// Pausing must not throw away the tail. The live window plus the batch waiting to
// be archived are both still holding audio at this point, and nothing else ever
// decodes either of them, so the end of the transmission would be lost outright.
decoder.flush()
return@LaunchedEffect
}
if (!permissionGranted) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return@LaunchedEffect
@@ -125,10 +150,16 @@ fun CwDecodeScreen() {
DisposableEffect(Unit) {
onDispose {
// Archive the tail before tearing down, on a scope that outlives this composable:
// the screen's own scope is cancelled as it leaves, which would abort the decode.
// close() waits for it, because the inference needs the session it is closing.
appScope.launch {
runCatching { decoder.flush() }
// OrtSession holds native memory and must be released explicitly.
decoder.close()
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
@@ -144,6 +175,41 @@ fun CwDecodeScreen() {
.weight(1f)
.padding(start = 12.dp)
)
// Copy everything decoded so far. The decoder is built fresh every time this screen
// is opened, so leaving loses the transcript, and until now there was no way at all
// to get the text off it: an operator who had just copied a call sign by ear had to
// transcribe it a second time by hand.
//
// Includes the live window, not just the record. The record only holds archived text,
// which needs the window to fill and then a batch to accumulate, so for the opening
// half-minute it is empty - and gating the button on it left the one control that
// rescues the text greyed out over exactly the short exchange most likely to be lost.
val copyable = (historyText + decodedText).trim()
IconButton(
onClick = {
if (copyable.isNotEmpty()) {
clipboard.setText(AnnotatedString(copyable))
showToast(copiedMessage)
}
},
enabled = copyable.isNotEmpty()
) {
Icon(
painter = painterResource(CoreR.drawable.ic_save),
contentDescription = stringResource(R.string.cw_copy)
)
}
// Jump to the newest text. The record below deliberately does not follow the decode,
// so this is how you get back to the bottom after reading earlier traffic.
IconButton(onClick = { scope.launch { transcriptScroll.animateScrollTo(transcriptScroll.maxValue) } }) {
Icon(
// The shared arrow points right; rotated to point down. Adding a second
// drawable for the same shape is how icon sets start to drift.
painter = painterResource(CoreR.drawable.ic_arrow),
contentDescription = stringResource(R.string.cw_scroll_newest),
modifier = Modifier.rotate(90f)
)
}
IconButton(onClick = { isListening = !isListening }) {
Icon(
painter = painterResource(
@@ -169,7 +235,34 @@ fun CwDecodeScreen() {
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(8.dp))
) {
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
CwWaterfallView(
state = waterfall,
signalStrength = signalStrength,
detectedToneHz = detectedToneHz,
toneShiftHz = activeShiftHz
)
}
// What the markers cannot say on their own. The waterfall covers only the model's
// 400-1200 Hz window, so a tone outside it is missing from the picture entirely -
// and with tone shift off there is nothing to mark either. One line of text is
// what turns "nothing is happening" into a reason and a remedy.
val toneHz = detectedToneHz
val hint = when {
toneHz == null -> null
activeShiftHz != 0f -> stringResource(R.string.cw_tone_shifted_hint, toneHz.roundToInt())
CwToneShifter.isInsideWindow(toneHz) -> null
else -> stringResource(R.string.cw_tone_outside_hint, toneHz.roundToInt())
}
if (hint != null) {
Text(
text = hint,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, top = 4.dp, end = 12.dp)
)
}
Text(
@@ -183,6 +276,16 @@ fun CwDecodeScreen() {
.padding(horizontal = 12.dp, vertical = 6.dp)
)
// The pane below was an unlabelled grey box showing a bare ellipsis, which reads as a
// disabled text field rather than a transcript. Naming it is what makes the split
// between the live line above and the record below legible at all.
Text(
text = stringResource(R.string.cw_record_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 4.dp)
)
Box(
modifier = Modifier
.weight(1f)
@@ -191,11 +294,25 @@ fun CwDecodeScreen() {
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
// The record shows settled text only. decodedText is the live 20-second window,
// which the model rewrites from scratch every 1.5 seconds as more context arrives -
// right for the line above, where the newest guess is what you want, but it meant the
// bottom of the record kept changing and sometimes got shorter. That reads as the
// decoder deleting what it just wrote, which for something whose whole purpose is to
// be read back is the one thing it must not do.
val transcript = historyText.ifEmpty { emptyRecordMessage }
// This pane does not follow the decode. The single line above it is where new
// characters appear; this is the record, and a record that scrolls itself is worse
// than paper - you cannot read back over what just arrived because it keeps moving.
//
// It used to auto-scroll to the bottom on every decode, dropping out of follow mode
// only once the operator had already fought it by scrolling away. Now it stays where
// it was put, and the button below jumps to the newest text on request.
Text(
text = (historyText + decodedText).ifEmpty { "…" },
text = transcript,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(transcriptScroll)
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
@@ -18,20 +18,34 @@
package com.rtbishop.look4sat.feature.cw
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.stringResource
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.math.ceil
import kotlin.math.roundToInt
/**
* Rolling spectrogram history for the waterfall display.
@@ -45,6 +59,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
// Compose draw thread, so every touch of these two collections is guarded.
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
private val lock = Any()
/** Anti-alias filter for the decimation, built once the capture rate is known. */
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
// Incremented by clear(). A pushSamples call records the generation before
@@ -67,8 +85,21 @@ class CwWaterfallState(private val historyRows: Int = 96) {
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
if (chunk.isEmpty()) return
// Band-limit before decimating, for the same reason the decoder does: without it
// everything above 1600 Hz folds into the display. That is not a cosmetic problem -
// the entire 1600-22050 Hz band of hiss lands on top of the signal and smears across
// the whole waterfall, and a strong out-of-band tone appears as a convincing ghost at
// a frequency nothing is transmitting on.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(chunk) ?: chunk
// The filter holds back its group delay, so the first call yields nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val audio: FloatArray
val generationAtStart: Long
@@ -83,7 +114,13 @@ class CwWaterfallState(private val historyRows: Int = 96) {
}
// FFT outside the lock; only the append below needs exclusivity.
val computed = CwDeepSpectrogram.compute(audio)
// The whole band, not just the model's window: a tone outside the window leaves no
// usable trace inside it, so the narrow view showed the operator nothing at all.
val computed = CwDeepSpectrogram.compute(
audio,
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
)
synchronized(lock) {
// Drop the result when the user cleared the display while this FFT
// was running: those samples belong to the discarded history.
@@ -101,6 +138,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
}
fun clear() {
antiAlias?.reset()
synchronized(lock) {
generation++
rows.clear()
@@ -113,16 +151,40 @@ class CwWaterfallState(private val historyRows: Int = 96) {
/**
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
*
* Spans the whole audio band, not just the model's window, so a tone the decoder cannot
* read is still in the picture — inside the window such a tone leaves no usable trace at
* all, and the operator could not even tell a signal was present. The window itself is
* framed and the rest dimmed, so it stays clear which part is being decoded.
*
* When [toneShiftHz] is non-zero a tone is being moved into that window: green marks
* where it is being delivered, orange marks [detectedToneHz] where the tone really is.
*/
@Composable
internal fun CwWaterfallView(
state: CwWaterfallState,
signalStrength: Float,
detectedToneHz: Float? = null,
toneShiftHz: Float = 0f,
modifier: Modifier = Modifier
) {
val revision by state.revision.collectAsState()
Canvas(modifier = modifier.fillMaxSize()) {
// A Canvas announces nothing, so the whole spectrum was silent to a screen reader.
// The tone and whether the decoder can reach it are the facts the picture conveys,
// so they are what the description says.
val hz = detectedToneHz?.roundToInt()
val toneDesc = when {
hz == null || hz <= 0 -> stringResource(R.string.cw_waterfall_idle)
toneShiftHz != 0f -> stringResource(R.string.cw_waterfall_shifted, hz)
CwToneShifter.isInsideWindow(hz.toFloat()) ->
stringResource(R.string.cw_waterfall_inside, hz)
else -> stringResource(R.string.cw_waterfall_outside, hz)
}
val description = stringResource(R.string.cw_waterfall_desc, toneDesc)
Box(modifier = modifier.fillMaxSize().semantics { contentDescription = description }) {
Canvas(modifier = Modifier.fillMaxSize()) {
// Touch the revision inside the draw scope so a new spectrum triggers a
// redraw; without this read the canvas would only ever render once.
@Suppress("UNUSED_EXPRESSION") revision
@@ -130,20 +192,19 @@ internal fun CwWaterfallView(
drawRect(color = Color(0xFF00060F), size = size)
val rows = state.snapshot()
if (rows.isEmpty()) return@Canvas
// Scale to the loudest value on screen so quiet signals stay visible.
var peak = 0f
// Scale to the loudest value on screen so quiet signals stay visible.
for (row in rows) for (v in row) if (v > peak) peak = v
if (peak <= 0f) return@Canvas
if (peak > 0f) {
val rowHeight = size.height / rows.size
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
// From the row itself, not the model's bin count: the display spans the
// whole band and so carries more bins than the model reads.
val binWidth = size.width / rows.first().size
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
// Linear interpolation between adjacent bins via a horizontal
// gradient removes the blocky "pixel" look of 65 discrete columns.
// gradient removes the blocky "pixel" look of discrete columns.
for (bin in 0 until row.size - 1) {
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
@@ -155,6 +216,14 @@ internal fun CwWaterfallView(
)
}
}
}
// After the spectrum so it cannot bury them, and outside the `peak > 0`
// branch above because a shift stays applied through key-up gaps: the
// markers must hold still through them, not blink out whenever the
// picture goes momentarily quiet.
drawDecoderWindow()
drawToneShiftMarkers(detectedToneHz, toneShiftHz)
if (signalStrength > 0f) {
drawRect(
@@ -164,8 +233,128 @@ internal fun CwWaterfallView(
)
}
}
// The tone's own frequency, as text: Canvas has no drawText, so this rides on top
// of it, on the side the tone lies beyond so it reads with the edge marker.
// Suppressed for a non-positive pitch, where the readout is an artefact of the
// loudest bin drifting below the shift and printing it would just show nonsense —
// the marker itself still shows the low edge.
if (toneShiftHz != 0f && detectedToneHz != null && detectedToneHz > 0f) {
// Halfway is the tipping point, so the text sits nearer the marker it belongs
// to wherever that is — including a pitch on the upper edge, whose line is
// drawn hard against the right of the picture.
val onHighSide = detectedToneHz > CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ / 2
Text(
text = "${detectedToneHz.roundToInt()} Hz",
fontSize = 9.sp,
color = TONE_ORIGIN_COLOUR,
modifier = Modifier
.align(if (onHighSide) Alignment.TopEnd else Alignment.TopStart)
.padding(start = 6.dp, end = 6.dp, top = 2.dp)
)
}
}
}
/**
* Where the shifter actually puts the tone.
*
* Read from the shifter rather than recomputed as the window midpoint: the two agree
* today only by coincidence, and retuning either one would leave this line marking a
* frequency nothing is being delivered to — with no test or compiler error to say so.
*/
private val TONE_SHIFT_TARGET_HZ = CwToneShifter.TARGET_HZ.toFloat()
private val TONE_TARGET_COLOUR = Color(0xFF4CD964)
/**
* Marker colour for the tone's own frequency.
*
* Cyan, not the orange it used to be: the inferno ramp runs black through purple and
* orange to pale yellow, so an orange marker sitting on the very trace it points at was
* the same hue as that trace and could not be told apart from it. Cyan appears nowhere in
* the ramp.
*/
private val TONE_ORIGIN_COLOUR = Color(0xFF00E5FF)
/**
* Shades the part of the band the model does not read, and marks the tone within it.
*
* The picture spans the whole band while the decoder reads only a window of it, so without
* this the operator cannot tell which half of what they are looking at is being decoded.
*/
private fun DrawScope.drawDecoderWindow() {
val loX = hzToX(CwDeepSpectrogram.MIN_FREQ_HZ.toFloat()) * size.width
val hiX = hzToX(CwDeepSpectrogram.MAX_FREQ_HZ.toFloat()) * size.width
// Lift the readable band rather than darken the rest. The background is already almost
// black, so a dim wash over it moves only a couple of levels and reads as nothing; a
// faint lift inside is visible against it while leaving the trace itself untouched.
drawRect(
color = Color(0xFF7FA8D8).copy(alpha = 0.16f),
topLeft = Offset(loX, 0f),
size = Size(hiX - loX, size.height)
)
val edge = Color(0xFF8FA6C4).copy(alpha = 0.8f)
drawRect(color = edge, topLeft = Offset(loX, 0f), size = Size(1.5f, size.height))
drawRect(color = edge, topLeft = Offset(hiX - 1.5f, 0f), size = Size(1.5f, size.height))
}
/**
* Marks where the shifter is delivering the tone, and where the tone really is.
*
* Draws nothing when no shift is applied: the tone is then inside the window, plainly
* visible in the spectrum on its own, and a marker would only add clutter.
*/
private fun DrawScope.drawToneShiftMarkers(detectedToneHz: Float?, toneShiftHz: Float) {
if (toneShiftHz == 0f) return
// The target line is drawn on the strength of the shift alone. A shift being applied
// is the fact worth showing, and it must not depend on the tone readout, which can be
// absent for a weak or slow fist even while a shift stays latched from an earlier scan.
markerBracket(hzToX(TONE_SHIFT_TARGET_HZ), TONE_TARGET_COLOUR)
// No usable tone estimate: the target line alone, rather than a guessed position.
if (detectedToneHz == null || detectedToneHz.isNaN() || detectedToneHz <= 0f) return
// The tone is genuinely in the picture now, so mark it where it is.
markerBracket(hzToX(detectedToneHz), TONE_ORIGIN_COLOUR)
}
/** Height of the strip along the top reserved for frequency markers. */
private const val MARKER_GUTTER_PX = 7f
/**
* A marker pip in the gutter above the spectrum, at [fraction] across.
*
* Kept out of the spectrum rather than drawn across it. A line laid over a CW trace cannot
* be told apart from the keying gaps in that trace, and the marker that matters most sits
* exactly on the tone it points at - so it was invisible in the one place it was needed.
* A pip in its own strip is clear of the signal and still reads against the axis.
*/
private fun DrawScope.markerBracket(fraction: Float, colour: Color) {
val x = (fraction * size.width).coerceIn(1f, size.width - 3f)
drawRect(
color = colour,
topLeft = Offset(x - 1f, 0f),
size = Size(3f, MARKER_GUTTER_PX)
)
// A short stub reaching into the spectrum, so the pip reads as pointing at a
// frequency rather than floating above one, without masking the trace below.
drawRect(
color = colour.copy(alpha = 0.55f),
topLeft = Offset(x, MARKER_GUTTER_PX),
size = Size(1f, MARKER_GUTTER_PX * 0.7f)
)
}
/** Fraction across the display for [hz], 0..1 spanning the visible band. */
private fun hzToX(hz: Float): Float {
val lo = CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ.toFloat()
val hi = CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ.toFloat()
return (hz - lo) / (hi - lo)
}
/**
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_tone_shifted_hint">Tono de %1$d Hz trasladado a la ventana de decodificación de 400-1200 Hz</string>
<string name="cw_tone_outside_hint">El tono de %1$d Hz está fuera de la ventana de decodificación de 400-1200 Hz. Active el desplazamiento de tono en Ajustes.</string>
<string name="cw_waterfall_desc">Espectro de cascada, %1$s</string>
<string name="cw_waterfall_idle">aún sin señal</string>
<string name="cw_waterfall_shifted">tono de %1$d hercios, trasladado al rango de decodificación</string>
<string name="cw_waterfall_outside">tono de %1$d hercios, fuera del rango de decodificación</string>
<string name="cw_waterfall_inside">tono de %1$d hercios</string>
</resources>
@@ -3,7 +3,19 @@
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
<string name="cw_waterfall_idle">belum ada sinyal</string>
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string>
<string name="cw_copy">Salin catatan</string>
<string name="cw_copied">Catatan disalin</string>
<string name="cw_record_label">Catatan</string>
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
</resources>
@@ -3,7 +3,19 @@
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
<string name="cw_waterfall_idle">belum ada sinyal</string>
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string>
<string name="cw_copy">Salin catatan</string>
<string name="cw_copied">Catatan disalin</string>
<string name="cw_record_label">Catatan</string>
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_tone_shifted_hint">Тон %1$d Гц перенесён в окно декодирования 400-1200 Гц</string>
<string name="cw_tone_outside_hint">Тон %1$d Гц находится вне окна декодирования 400-1200 Гц. Включите сдвиг тона в настройках.</string>
<string name="cw_waterfall_desc">Водопадный спектр, %1$s</string>
<string name="cw_waterfall_idle">сигнала пока нет</string>
<string name="cw_waterfall_shifted">тон %1$d герц, перенесён в диапазон декодирования</string>
<string name="cw_waterfall_outside">тон %1$d герц, вне диапазона декодирования</string>
<string name="cw_waterfall_inside">тон %1$d герц</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_tone_shifted_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවට ගෙන ගියා</string>
<string name="cw_tone_outside_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවෙන් පිටත. සැකසුම් තුළ ස්වර මාරුව සක්‍රිය කරන්න.</string>
<string name="cw_waterfall_desc">දිය ඇලි වර්ණාවලිය, %1$s</string>
<string name="cw_waterfall_idle">තවම සංඥාවක් නැත</string>
<string name="cw_waterfall_shifted">ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයට ගෙන ගියා</string>
<string name="cw_waterfall_outside">ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයෙන් පිටත</string>
<string name="cw_waterfall_inside">ස්වරය %1$d හර්ට්ස්</string>
</resources>
@@ -3,7 +3,19 @@
<string name="cw_pause">Çözmeyi duraklat</string>
<string name="cw_resume">Çözmeyi sürdür</string>
<string name="cw_clear">Çözülen metni temizle</string>
<string name="cw_scroll_newest">En yeni metne git</string>
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
<string name="cw_tone_shifted_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığına taşındı</string>
<string name="cw_tone_outside_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığının dışında. Ayarlar\'dan ton kaydırmayı açın.</string>
<string name="cw_waterfall_desc">Şelale spektrumu, %1$s</string>
<string name="cw_waterfall_idle">henüz sinyal yok</string>
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
<string name="cw_waterfall_inside">ton %1$d hertz</string>
<string name="cw_copy">Kaydı kopyala</string>
<string name="cw_copied">Kayıt kopyalandı</string>
<string name="cw_record_label">Kayıt</string>
<string name="cw_record_empty">Bir ses grubu çözüldükten sonra metin buraya düşer. Üstteki satır daha erken güncellenir. Çıkmadan önce kopyalayın: kayıt saklanmaz.</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_tone_shifted_hint">Тон %1$d Гц перенесено у вікно декодування 400-1200 Гц</string>
<string name="cw_tone_outside_hint">Тон %1$d Гц перебуває поза вікном декодування 400-1200 Гц. Увімкніть зсув тону в налаштуваннях.</string>
<string name="cw_waterfall_desc">Водоспадний спектр, %1$s</string>
<string name="cw_waterfall_idle">сигналу ще немає</string>
<string name="cw_waterfall_shifted">тон %1$d герц, перенесено в діапазон декодування</string>
<string name="cw_waterfall_outside">тон %1$d герц, поза діапазоном декодування</string>
<string name="cw_waterfall_inside">тон %1$d герц</string>
</resources>
@@ -3,7 +3,19 @@
<string name="cw_pause">暂停解码</string>
<string name="cw_resume">继续解码</string>
<string name="cw_clear">清空解码文本</string>
<string name="cw_scroll_newest">跳到最新</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
<string name="cw_tone_shifted_hint">已将 %1$d Hz 音调搬入 400-1200 Hz 解码范围</string>
<string name="cw_tone_outside_hint">%1$d Hz 音调在 400-1200 Hz 解码范围外,请在设置中开启音调搬移。</string>
<string name="cw_waterfall_desc">瀑布频谱图,%1$s</string>
<string name="cw_waterfall_idle">暂无信号</string>
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
<string name="cw_copy">复制记录</string>
<string name="cw_copied">记录已复制</string>
<string name="cw_record_label">记录</string>
<string name="cw_record_empty">一批音频解码完成后文字会落到这里,上面那行更新更早。离开本页前请复制:记录不会被存下来。</string>
</resources>
@@ -3,7 +3,19 @@
<string name="cw_pause">Pause decoding</string>
<string name="cw_resume">Resume decoding</string>
<string name="cw_clear">Clear decoded text</string>
<string name="cw_scroll_newest">Jump to the newest text</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
<string name="cw_tone_shifted_hint">Tone %1$d Hz moved into the 400-1200 Hz decoder window</string>
<string name="cw_tone_outside_hint">Tone %1$d Hz is outside the 400-1200 Hz decoder window. Enable tone shift in Settings.</string>
<string name="cw_waterfall_desc">Waterfall spectrum, %1$s</string>
<string name="cw_waterfall_idle">no signal yet</string>
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
<string name="cw_copy">Copy the record</string>
<string name="cw_copied">Record copied</string>
<string name="cw_record_label">Record</string>
<string name="cw_record_empty">Text lands here once a batch of audio has been decoded. The line above updates sooner. Copy before leaving: the record is not stored.</string>
</resources>
@@ -16,6 +16,11 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,7 +43,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -52,9 +57,14 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -70,6 +80,10 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.wavelog.GridEntry
import com.rtbishop.look4sat.core.domain.wavelog.PassClock
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
@@ -84,7 +98,6 @@ import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
private const val DUPLICATE_WINDOW_MS = 2000L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -92,11 +105,14 @@ fun LogTab(
transceivers: List<SatRadio>,
orbitalPos: OrbitalPos?,
satelliteName: String,
satelliteCatnum: Int,
queue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
onAutoUpload: () -> Unit,
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -136,7 +152,7 @@ fun LogTab(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
@@ -167,10 +183,13 @@ fun LogTab(
radio = radio,
orbitalPos = orbitalPos,
satelliteName = satelliteName,
satelliteCatnum = satelliteCatnum,
queue = queue,
showToast = showToast,
txBaseFrequencyHz = txBaseFrequencyHz,
aosTimeMs = aosTimeMs,
onLookupGrid = onLookupGrid,
onAutoUpload = onAutoUpload,
onSaved = { refreshTick++ }
)
}
@@ -253,61 +272,123 @@ private fun ExpandedLogInput(
radio: SatRadio,
orbitalPos: OrbitalPos?,
satelliteName: String,
satelliteCatnum: Int,
queue: WavelogQueue,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
onAutoUpload: () -> Unit,
onSaved: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val scope = rememberCoroutineScope()
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
// A plain in-flight flag cannot help: submit() is synchronous, so it always
// clears before the next key event arrives.
var lastSaved by remember { mutableStateOf("" to 0L) }
// Keyed on the transponder. Without the key, switching transponder mid-session kept the
// previous one's mode and uploaded a value the operator never chose.
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
val editModeLabel = stringResource(id = R.string.wavelog_mode_edit)
val editTimeLabel = stringResource(id = R.string.wavelog_time_edit)
// rememberSaveable, not remember: this goes through the saved instance state, so a held clock
// survives rotation AND system-initiated process death. Losing it mid-transcription would put
// every remaining contact at the wrong time and on the wrong UTC day without saying so. It is
// deliberately NOT restored when the user swipes the app away - that is them ending the session.
var timeEntry by rememberSaveable { mutableStateOf("") }
var timeEditable by rememberSaveable { mutableStateOf(false) }
// Typed by the operator. On FM satellites the grid IS the exchange, and until now it could only
// arrive by scraping QRZ - which needs a cookie the operator may not have and cannot supply for
// a station that has no locator on file.
var gridEntry by rememberSaveable { mutableStateOf("") }
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
// swallowed what it guessed were accidental double submissions - a guess that could discard
// a real second contact instead.
var workedThisSession by rememberSaveable { mutableStateOf(emptySet<String>()) }
val savedMsg = stringResource(id = R.string.wavelog_saved)
val gridWarnMsg = stringResource(id = R.string.log_warn_grid)
val workedWarnMsg = stringResource(id = R.string.log_warn_worked)
val tooShortMsg = stringResource(id = R.string.log_call_too_short)
val illegalMsg = stringResource(id = R.string.log_call_illegal)
val notACallMsg = stringResource(id = R.string.log_call_not_a_call)
val tooLongMsg = stringResource(id = R.string.log_call_too_long)
val gridSignedOutMsg = stringResource(id = R.string.log_grid_signed_out)
val gridUnreachableMsg = stringResource(id = R.string.log_grid_unreachable)
fun rejectionMessage(reason: CallsignEntry.Reason): String = when (reason) {
CallsignEntry.Reason.EMPTY, CallsignEntry.Reason.TOO_SHORT -> tooShortMsg
CallsignEntry.Reason.TOO_LONG -> tooLongMsg
CallsignEntry.Reason.ILLEGAL_CHARACTERS -> illegalMsg
CallsignEntry.Reason.NOT_A_CALLSIGN -> notACallMsg
}
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
val now = System.currentTimeMillis()
val (lastCall, lastAt) = lastSaved
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
lastSaved = call to now
// Every outcome says something. The previous `if (call.length < 3) return` discarded the
// entry in silence, so pressing done mid-pass appeared to do nothing, and none of the
// logging software surveyed drops a submission that way.
val verdict = CallsignEntry.check(callsign, workedThisSession)
if (verdict is CallsignEntry.Verdict.Rejected) {
showToast(rejectionMessage(verdict.reason))
return
}
val accepted = verdict as CallsignEntry.Verdict.Acceptable
val call = accepted.callsign
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val qsoId = UUID.randomUUID().toString()
// Only a usable grid is logged. A malformed one is dropped rather than stored, and the
// field has been saying so while it was typed.
val typedGrid = (GridEntry.check(gridEntry) as? GridEntry.Verdict.Acceptable)?.normalised ?: ""
queue.add(
WavelogQso(
id = qsoId,
timeUtcMs = System.currentTimeMillis(),
timeUtcMs = PassClock.resolve(
PassClock.parse(timeEntry),
System.currentTimeMillis(),
utcDayStart(System.currentTimeMillis())
),
call = call,
mode = mode.trim().ifBlank { "FM" }.uppercase(),
freqTxHz = tx,
freqRxHz = rx,
satName = satelliteName,
catnum = satelliteCatnum,
gridsquare = typedGrid,
sessionId = buildSessionId(satelliteName, aosTimeMs)
)
)
callsign = ""
gridEntry = ""
workedThisSession = workedThisSession + call
onSaved()
showToast(savedMsg)
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
scope.launch {
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
val rawCookie = prefs.getString("cookie", "") ?: ""
if (rawCookie.isNotBlank()) {
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
)
if (grid != null) {
queue.updateGridsquare(qsoId, grid)
onSaved() // 触发 refreshTick++ 刷新列表
showToast(
when (accepted.warning) {
CallsignEntry.Warning.LOOKS_LIKE_A_GRID -> gridWarnMsg
CallsignEntry.Warning.ALREADY_WORKED -> workedWarnMsg.format(call)
null -> savedMsg
}
)
// Upload now if the operator asked for it. Doing this as the contact is saved, rather than
// on a timer, means the result can actually be shown to somebody.
onAutoUpload()
// Grid backfill. The view model owns the cookie and the request; this used to read
// SharedPreferences through LocalContext right here, inside composition. Failures now say
// something: an expired cookie was indistinguishable from a station with no grid filed.
// A typed grid is better evidence than a scrape, so the lookup is skipped entirely rather
// than allowed to overwrite it.
if (typedGrid.isNotBlank()) return
onLookupGrid(call) { outcome ->
when (outcome) {
is QrzGrid.Found -> {
queue.updateGridsquare(qsoId, outcome.locator)
onSaved()
}
// Nothing to fetch and nothing wrong: the station has no locator on file.
QrzGrid.NotOnFile -> Unit
QrzGrid.SignedOut -> showToast(gridSignedOutMsg)
is QrzGrid.Unreachable -> showToast(gridUnreachableMsg)
}
}
}
@@ -337,7 +418,7 @@ private fun ExpandedLogInput(
if (isLinearRange) {
Text(
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 11.sp,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -358,7 +439,7 @@ private fun ExpandedLogInput(
if (downLow != null && downHigh != null && downLow != downHigh) {
Text(
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
fontSize = 11.sp,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -373,6 +454,48 @@ private fun ExpandedLogInput(
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
// The counterpart grid, optional and second. On FM satellites this is the exchange, and it
// could previously only arrive by scraping QRZ - which needs a cookie, and returns nothing
// for a station with no locator on file. Typed here it also skips the lookup entirely,
// because what the operator heard beats what a web page says.
val gridVerdict = GridEntry.check(gridEntry)
OutlinedTextField(
value = gridEntry,
// 8 characters, not 6: the extended form exists and truncating it would silently
// change the location. Case is normalised on commit rather than while typing, so the
// cursor does not jump under the operator's thumb.
onValueChange = { gridEntry = it.take(8) },
label = { Text(stringResource(id = R.string.wavelog_grid_hint)) },
supportingText = {
Text(
// Says what is wrong while there is still time to fix it. A malformed grid is
// stored by Wavelog as-is and then pollutes grid statistics and VUCC tracking,
// where a wrong square is worse than a missing one.
text = when (gridVerdict) {
is GridEntry.Verdict.Unusable -> stringResource(id = R.string.wavelog_grid_bad)
is GridEntry.Verdict.Acceptable -> when (gridVerdict.warning) {
GridEntry.Warning.FIELD_ONLY ->
stringResource(id = R.string.wavelog_grid_field_only)
GridEntry.Warning.SQUARE_ONLY ->
stringResource(id = R.string.wavelog_grid_square_only)
null -> stringResource(id = R.string.wavelog_grid_help)
}
GridEntry.Verdict.Empty -> stringResource(id = R.string.wavelog_grid_help)
},
style = MaterialTheme.typography.bodySmall
)
},
isError = gridVerdict is GridEntry.Verdict.Unusable,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
// The mode comes from the transponder, so it is shown rather than typed - one less field
// during a pass that lasts eight minutes. Tapping it reveals the field, because a
// transponder record can be wrong and the operator has to be able to say so.
if (modeEditable) {
OutlinedTextField(
value = mode,
onValueChange = { mode = it.take(8) },
@@ -381,6 +504,91 @@ private fun ExpandedLogInput(
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
} else {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
// 48dp is the Material Design minimum for anything tappable, and this row is
// tapped to reveal the mode field. Padding alone left it around 20dp.
.heightIn(min = 48.dp)
// Role.Button so TalkBack announces it as interactive. A bare clickable
// declares no role, so the row read as two labels and the tap was undiscoverable.
.clickable(role = Role.Button, onClickLabel = editModeLabel) { modeEditable = true }
.padding(horizontal = LocalSpacing.current.extraExtraSmall)
) {
Text(
text = stringResource(id = R.string.wavelog_mode_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = mode, style = MaterialTheme.typography.bodyMedium)
}
}
// The clock. Serious operators record a pass and transcribe it afterwards, so a fixed
// timestamp made every transcribed contact wrong by however long the transcription took -
// and a pass crossing midnight UTC landed a whole day out.
val command = PassClock.parse(timeEntry)
val holding = PassClock.isHolding(command)
if (timeEditable) {
OutlinedTextField(
value = timeEntry,
onValueChange = { timeEntry = it.take(6) },
label = { Text(stringResource(id = R.string.wavelog_time_hint)) },
supportingText = {
Text(
text = stringResource(
if (command is PassClock.Command.Unrecognised) {
R.string.wavelog_time_unrecognised
} else {
R.string.wavelog_time_help
}
),
style = MaterialTheme.typography.bodySmall
)
},
isError = command is PassClock.Command.Unrecognised,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
} else {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(role = Role.Button, onClickLabel = editTimeLabel) {
timeEditable = true
}
.padding(horizontal = LocalSpacing.current.extraExtraSmall)
) {
Text(
text = stringResource(id = R.string.wavelog_time_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
// A held clock is marked in words as well as colour. Material is explicit that
// colour must not be the only carrier of meaning, and roughly one man in twelve
// cannot reliably separate the primary colour from the default text here. The
// consequence of missing it is every remaining contact logged at the wrong time.
text = if (holding) {
stringResource(id = R.string.wavelog_time_held, timeEntry.trim())
} else {
stringResource(id = R.string.wavelog_time_live)
},
style = MaterialTheme.typography.bodyMedium,
color = if (holding) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
}
}
@@ -423,10 +631,33 @@ internal fun SwipeDeleteRow(
}
}
val deleteActionLabel = stringResource(id = R.string.wavelog_delete_action)
val undoActionLabel = stringResource(id = R.string.wavelog_delete_undo)
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowWidth = it.width }
// Deleting was reachable ONLY by dragging, so TalkBack, switch access and Voice Access
// users could not delete a record at all - not with difficulty, at all. The Compose
// accessibility guide names this exact case: swipe gestures should be exposed as custom
// actions because they are hard or impossible for users with motor impairments.
.semantics {
customActions = buildList {
if (pending) {
add(CustomAccessibilityAction(undoActionLabel) {
pending = false
offsetX = 0f
true
})
} else {
add(CustomAccessibilityAction(deleteActionLabel) {
pending = true
offsetX = -threshold
true
})
}
}
}
) {
// Background layer (right-side icons: trash / undo + countdown)
Box(
@@ -452,22 +683,34 @@ internal fun SwipeDeleteRow(
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow,
textAlign = TextAlign.Center,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(WaveLogYellow.copy(alpha = 0.15f))
.clickable {
// 48dp, the Material minimum. This was a 29dp target with a five-second
// countdown running behind it, so a miss was unrecoverable - the worst
// possible place in the screen to be hard to hit.
.heightIn(min = 48.dp)
.widthIn(min = 64.dp)
.clickable(role = Role.Button) {
pending = false
offsetX = 0f
}
.padding(horizontal = 10.dp, vertical = 6.dp)
.wrapContentHeight(Alignment.CenterVertically)
.padding(horizontal = 10.dp)
)
}
} else {
// Trash icon (always visible while swiping, yellow)
Text(
text = "🗑",
fontSize = 20.sp,
modifier = Modifier.padding(end = 16.dp)
// A real icon, not an emoji: emoji render differently on every device and font, and
// this project forbids them as icons. contentDescription is null because the row
// already exposes delete as a custom accessibility action.
Icon(
painter = painterResource(id = R.drawable.ic_delete),
contentDescription = null,
tint = WaveLogYellow,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
}
}
@@ -522,3 +765,19 @@ private fun buildSessionId(satName: String, aosTimeMs: Long): String {
)
return "$satName-$stamp"
}
/**
* Midnight UTC of the day a timestamp falls in.
*
* PassClock needs this passed in: core:domain holds no calendar, so the day boundary is computed
* here where java.util is available.
*/
private fun utcDayStart(nowMs: Long): Long {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
cal.set(java.util.Calendar.MINUTE, 0)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
return cal.timeInMillis
}
@@ -58,10 +58,11 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
@@ -92,6 +93,19 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Captured as a value because `viewModel` also names the composable factory function, so
// referring to it inside a lambda resolves to that instead of this local.
val uploadQueuedMsg = stringResource(id = R.string.wavelog_upload_queued)
val lookupGrid: (String, (QrzGrid) -> Unit) -> Unit = viewModel::lookupGrid
// Reports through the shared toast so an automatic upload is not silent the way the deleted
// ten-minute loop was.
val autoUpload: () -> Unit = {
viewModel.uploadIfAutomatic { outcome ->
if (outcome is UploadOutcome.Done && outcome.failedCount > 0) {
container.provideShowToast()(uploadQueuedMsg)
}
}
}
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
@@ -124,21 +138,11 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
val wavelogUploader = remember { container.provideWavelogUploader() }
LaunchedEffect(Unit) {
while (true) {
delay(10 * 60 * 1000L)
val s = container.settingsRepo.otherSettings.value
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
val outcome = wavelogUploader.uploadQueue()
if (outcome is UploadOutcome.NeedConfirm) {
// Skip this run; the user can upload and confirm manually in settings
}
}
}
}
// Auto-upload happens when a QSO is saved, not on a timer. The ten-minute polling loop that
// used to live here uploaded in the background with no way to tell the operator what happened:
// a grid mismatch was skipped by an empty if, and any other failure just retried silently
// forever. A QSO that cannot be uploaded now stays in the queue for a manual upload from
// settings, where the result is actually shown.
RadarScreen(
uiState,
viewModel::onAction,
@@ -149,7 +153,9 @@ fun RadarDestination(navigateUp: () -> Unit) {
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
},
showToast = { msg -> container.provideShowToast()(msg) }
showToast = { msg -> container.provideShowToast()(msg) },
onLookupGrid = lookupGrid,
onAutoUpload = autoUpload
)
}
@@ -162,7 +168,9 @@ private fun RadarScreen(
requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit
showToast: (String) -> Unit,
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
onAutoUpload: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -216,11 +224,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, onAutoUpload, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, onAutoUpload, Modifier.weight(1f))
}
}
}
@@ -234,6 +242,8 @@ private fun PagerCard(
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
onAutoUpload: () -> Unit,
modifier: Modifier = Modifier
) {
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
@@ -297,11 +307,14 @@ private fun PagerCard(
transceivers = uiState.transceivers.transmitters,
orbitalPos = uiState.orbitalPos,
satelliteName = uiState.currentPass?.name ?: "",
satelliteCatnum = uiState.currentPass?.catNum ?: 0,
queue = wavelogQueue,
wavelogConfigured = wavelogConfigured,
showToast = showToast,
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
aosTimeMs = uiState.currentPass?.aosTime ?: 0L,
onLookupGrid = onLookupGrid,
onAutoUpload = onAutoUpload
)
}
}
@@ -26,6 +26,10 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
@@ -64,7 +68,9 @@ class RadarViewModel(
private val audioCapture: IAudioCapture,
private val cwDecoderFactory: () -> ICwDecoder,
private val saveImage: ISaveImage,
private val showToast: IShowToast
private val showToast: IShowToast,
private val qrzGridLookup: IQrzGridLookup,
private val wavelogUploader: WavelogUploader
) : ViewModel() {
private var stationPos = settingsRepo.stationPosition.value
@@ -544,6 +550,42 @@ class RadarViewModel(
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
}
/**
* Upload the queue now, if the operator asked for automatic uploads.
*
* Called when a contact is saved rather than on a timer. The ten-minute polling loop this
* replaces ran in the background with no way to report anything, so a grid mismatch was
* silently skipped and every other failure retried forever. Uploading as the contact is saved
* means somebody is present to see the result, and a QSO that cannot go now waits in the queue
* for a manual upload from settings.
*/
fun uploadIfAutomatic(onResult: (UploadOutcome?) -> Unit) {
val settings = settingsRepo.otherSettings.value
if (!settings.wavelogAutoUpload || settings.wavelogUrl.isBlank()) {
onResult(null)
return
}
viewModelScope.launch {
onResult(runCatching { wavelogUploader.uploadQueue() }.getOrNull())
}
}
/**
* Look up a station's grid and hand the outcome back.
*
* Lives here rather than in the composable, which read the QRZ cookie straight out of
* SharedPreferences through LocalContext - disk access inside composition, around the
* repository layer. Every outcome is returned, because a lookup that failed used to be
* indistinguishable from a station that simply has no grid on file.
*/
fun lookupGrid(callsign: String, onResult: (QrzGrid) -> Unit) {
viewModelScope.launch {
val outcome = runCatching { qrzGridLookup.lookup(callsign) }
.getOrElse { QrzGrid.Unreachable(1) }
onResult(outcome)
}
}
companion object {
// SSTV Decoder Tuning Parameters
// ==============================
@@ -599,7 +641,9 @@ class RadarViewModel(
audioCapture = container.provideAudioCapture(),
cwDecoderFactory = { container.provideCwDecoder() },
saveImage = container.provideSaveImage(),
showToast = container.provideShowToast()
showToast = container.provideShowToast(),
qrzGridLookup = container.provideQrzGridLookup(),
wavelogUploader = container.provideWavelogUploader()
)
}
}
@@ -44,7 +44,15 @@ import java.util.Calendar
import java.util.TimeZone
private val CheckGreen = Color(0xFF4CAF50)
private val GridLineColor = Color(0xFF3A3A3A)
/**
* Grid rules and column separators.
*
* Was 0xFF3A3A3A, which computes to 1.65:1 against the navBar background and 1.36:1 against a card,
* where Material asks 3:1 for non-text elements - the grid this page is built around was nearly
* invisible, and gone in sunlight. 0xFF6D6D6D is the lowest grey clearing 3:1 against both
* (3.62:1 and 3.00:1).
*/
private val GridLineColor = Color(0xFF6D6D6D)
@Composable
fun WavelogLogScreen(
@@ -139,15 +147,21 @@ fun WavelogLogScreen(
GridVLine()
Cell(entry.call, 0.dp, weight = 1f)
GridVLine()
// Upload state carried by the glyph, not by colour alone. The
// green tick collapses to 1.17:1 under this app's night filter,
// which zeroes green and blue, so the column disappeared entirely -
// and colour was the only thing separating sent from waiting.
Box(modifier = Modifier.width(44.dp)) {
if (entry.uploaded) {
Text(
text = "✓",
fontSize = 15.sp,
text = if (entry.uploaded) "OK" else "...",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = CheckGreen
)
color = if (entry.uploaded) {
CheckGreen
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
// Row divider
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.feature.settings
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -10,6 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import com.rtbishop.look4sat.core.domain.aprs.AprsSymbols
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@@ -33,12 +40,19 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
/**
* Where an operator obtains an APRS-IS passcode.
*
* The app links here rather than deriving one: the passcode is a licence check, and APRS-IS
* states that supplying it is the software author's responsibility.
*/
private const val PASSCODE_REQUEST_URL = "https://apps.magicbug.co.uk/passcode/"
@Composable
fun AprsCard() {
val context = LocalContext.current
@@ -171,6 +185,7 @@ fun AprsCard() {
}
/** APRS settings dialog (plenty of room, full config) */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AprsSettingsDialog(
config: AprsConfig,
@@ -186,7 +201,9 @@ private fun AprsSettingsDialog(
var status by remember { mutableStateOf(config.statusText) }
var symbolTable by remember { mutableStateOf(config.symbolTable) }
var symbolCode by remember { mutableStateOf(config.symbolCode) }
var symbolMenuExpanded by remember { mutableStateOf(false) }
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
@@ -215,16 +232,19 @@ private fun AprsSettingsDialog(
singleLine = true,
textStyle = textStyle
)
// Passcode compute button (user request: let the user see the computed result)
// Links out instead of computing it. APRS-IS treats the passcode as a licence
// check and says supplying it to a user is the software author's job; APRSdroid
// carries the same algorithm and deliberately does not use it here for that
// reason. Filling the field in claims a check that nobody performed.
TextButton(
onClick = {
val call = callsign.trim().uppercase()
if (call.isNotBlank()) {
passcode = AprsPacket.passcode(call).toString()
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(PASSCODE_REQUEST_URL))
)
}
},
enabled = callsign.trim().isNotBlank()
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
}
) { Text(stringResource(id = R.string.prefs_aprs_request_passcode)) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = ssid,
@@ -262,24 +282,62 @@ private fun AprsSettingsDialog(
textStyle = textStyle
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = symbolTable,
onValueChange = { symbolTable = it },
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
// A list rather than two free-text fields. The fields accepted any string and used
// only the first character, so typing "satellite" persisted the word and beaconed
// as `/`. The pair that makes this worth doing: \S is Satellite but /S is SHUTTLE.
val selected = AprsSymbols.find(symbolTable, symbolCode)
val customLabel = stringResource(
id = R.string.prefs_aprs_symbol_custom,
symbolTable.take(1),
symbolCode.take(1)
)
val selectedLabel = selected?.let { symbolLabel(it.descriptionKey) } ?: customLabel
ExposedDropdownMenuBox(
expanded = symbolMenuExpanded,
onExpandedChange = { symbolMenuExpanded = it }
) {
OutlinedTextField(
value = symbolCode,
onValueChange = { symbolCode = it },
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.prefs_aprs_symbol)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = symbolMenuExpanded)
},
textStyle = textStyle,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = symbolMenuExpanded,
onDismissRequest = { symbolMenuExpanded = false }
) {
// An existing setting that is not on the list appears first and stays
// selectable, so opening the menu cannot silently change it.
if (selected == null) {
DropdownMenuItem(
text = { Text(text = customLabel, maxLines = 1) },
onClick = { symbolMenuExpanded = false }
)
}
AprsSymbols.curated.forEach { symbol ->
DropdownMenuItem(
text = { Text(text = symbolLabel(symbol.descriptionKey), maxLines = 1) },
onClick = {
symbolTable = symbol.table.toString()
symbolCode = symbol.code.toString()
symbolMenuExpanded = false
}
)
}
}
}
Text(
text = stringResource(id = R.string.prefs_aprs_symbol_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
fontSize = 12.sp,
@@ -304,7 +362,7 @@ private fun AprsSettingsDialog(
intervalMin = interval.toIntOrNull() ?: 5,
statusText = status,
symbolTable = symbolTable.ifBlank { "/" },
symbolCode = symbolCode.ifBlank { ">" }
symbolCode = symbolCode.ifBlank { "-" }
)
)
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
@@ -327,3 +385,30 @@ private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Resolve a symbol's description key to its localised text.
*
* AprsSymbols names a resource rather than holding text, because core:domain cannot reach resources
* and hardcoding English there would put wording outside the locale files. The mapping has to live
* on this side, and an unknown key falls back to the key itself rather than crashing - a missing
* translation should not take the settings screen down.
*/
@Composable
private fun symbolLabel(descriptionKey: String): String = when (descriptionKey) {
"aprs_symbol_house" -> stringResource(id = R.string.aprs_symbol_house)
"aprs_symbol_house_alt" -> stringResource(id = R.string.aprs_symbol_house_alt)
"aprs_symbol_person" -> stringResource(id = R.string.aprs_symbol_person)
"aprs_symbol_yagi" -> stringResource(id = R.string.aprs_symbol_yagi)
"aprs_symbol_satellite" -> stringResource(id = R.string.aprs_symbol_satellite)
"aprs_symbol_portable" -> stringResource(id = R.string.aprs_symbol_portable)
"aprs_symbol_phone" -> stringResource(id = R.string.aprs_symbol_phone)
"aprs_symbol_tcpip" -> stringResource(id = R.string.aprs_symbol_tcpip)
"aprs_symbol_ht" -> stringResource(id = R.string.aprs_symbol_ht)
"aprs_symbol_car" -> stringResource(id = R.string.aprs_symbol_car)
"aprs_symbol_truck" -> stringResource(id = R.string.aprs_symbol_truck)
"aprs_symbol_van" -> stringResource(id = R.string.aprs_symbol_van)
"aprs_symbol_rv" -> stringResource(id = R.string.aprs_symbol_rv)
"aprs_symbol_bike" -> stringResource(id = R.string.aprs_symbol_bike)
else -> descriptionKey
}
@@ -100,6 +100,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -123,7 +125,12 @@ fun SettingsDestination() {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo())
SettingsScreen(
uiState,
viewModel::onAction,
container.provideLotwSatellitesRepo(),
container.provideQrzGridLookup()
)
// WaveLog grid mismatch confirmation dialog (4.5.2)
val gridConfirm = viewModel.gridConfirm
@@ -187,7 +194,8 @@ fun SettingsDestination() {
private fun SettingsScreen(
uiState: SettingsState,
onAction: (SettingsAction) -> Unit,
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo,
qrzLookup: IQrzGridLookup
) {
val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
@@ -405,7 +413,7 @@ private fun SettingsScreen(
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) }
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo, qrzLookup) }
item {
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
@@ -636,6 +644,26 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
onAction(SettingsAction.ToggleUpdate(it))
}
Spacer(modifier = Modifier.height(4.dp))
// CW decoding: shift out-of-window tones into the model's 400-1200 Hz window
SwitchRow(R.string.prefs_other_switch_cw_tone_shift, settings.cwToneShiftEnabled) {
onAction(SettingsAction.ToggleCwToneShift(it))
}
Text(
text = stringResource(id = R.string.prefs_other_cw_tone_shift_help),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// AMSAT status: twelve two-hour stripes per day, or one colour for the day
SwitchRow(R.string.prefs_other_switch_amsat_stripes, settings.amsatDayStripes) {
onAction(SettingsAction.ToggleAmsatDayStripes(it))
}
Text(
text = stringResource(id = R.string.prefs_other_amsat_stripes_help),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom
CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset,
@@ -663,7 +691,8 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
private fun WavelogCard(
settings: OtherSettings,
onAction: (SettingsAction) -> Unit,
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo,
qrzLookup: IQrzGridLookup
) {
val qrzContext = LocalContext.current
var showQrzDialog by remember { mutableStateOf(false) }
@@ -674,6 +703,11 @@ private fun WavelogCard(
)
}
var qrzTestResult by remember { mutableStateOf<String?>(null) }
val qrzEmptyMsg = stringResource(id = R.string.qrz_test_empty)
val qrzSignedOutMsg = stringResource(id = R.string.qrz_test_signed_out)
val qrzOkMsg = stringResource(id = R.string.qrz_test_ok)
val qrzNoGridMsg = stringResource(id = R.string.qrz_test_no_grid)
val qrzUnreachableMsg = stringResource(id = R.string.qrz_test_unreachable)
var qrzTesting by remember { mutableStateOf(false) }
val qrzScope = rememberCoroutineScope()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -775,21 +809,22 @@ private fun WavelogCard(
qrzTesting = true
qrzTestResult = null
qrzScope.launch {
val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie)
if (header.isBlank()) {
qrzTestResult = "Cookie 为空, 请先粘贴"
qrzTesting = false
return@launch
}
val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header)
// Goes through the repository rather than scraping from the UI, and
// reports each outcome distinctly - the old version could not tell
// an expired cookie from a station with no grid on file.
qrzTestResult = if (qrzCookie.isBlank()) {
qrzEmptyMsg
} else {
val own = qrzLookup.signedInAs()
if (own == null) {
qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)"
qrzSignedOutMsg
} else {
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header)
qrzTestResult = if (grid != null) {
"登录呼号: $own 网格: $grid ✅"
} else {
"登录呼号: $own 网格: 未填写"
when (val grid = qrzLookup.lookup(own)) {
is QrzGrid.Found -> qrzOkMsg.format(own, grid.locator)
QrzGrid.NotOnFile -> qrzNoGridMsg.format(own)
QrzGrid.SignedOut -> qrzSignedOutMsg
is QrzGrid.Unreachable -> qrzUnreachableMsg
}
}
}
qrzTesting = false
@@ -61,6 +61,10 @@ sealed interface SettingsAction {
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
/** Shift CW tones outside the model's 400-1200 Hz window into it before decoding. */
data class ToggleCwToneShift(val value: Boolean) : SettingsAction
data class ToggleAmsatDayStripes(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
@@ -127,6 +127,8 @@ class SettingsViewModel(
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleCwToneShift -> settingsRepo.updateOtherSettings { it.copy(cwToneShiftEnabled = action.value) }
is SettingsAction.ToggleAmsatDayStripes -> settingsRepo.updateOtherSettings { it.copy(amsatDayStripes = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
@@ -273,9 +275,11 @@ class SettingsViewModel(
viewModelScope.launch {
when (val r = lotwRepo.refresh()) {
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
showToast("LoTW 卫星列表已更新: ${r.count} 个")
showToast(R.string.lotw_list_updated, r.count)
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
wavelogError = "LoTW 卫星列表更新失败: ${r.message}"
// Server's own words only: the dialog supplies the framing, so the wording
// stays in the resource files rather than being built here in one language.
wavelogError = r.message
}
}
}
@@ -284,21 +288,37 @@ class SettingsViewModel(
viewModelScope.launch {
val result = pendingUploader?.uploadQueue()
when (result) {
is UploadOutcome.Done -> {
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
wavelogError = result.firstError
} else {
showToast("WaveLog: ${result.message}")
}
}
is UploadOutcome.Done -> reportUpload(result)
is UploadOutcome.NeedConfirm -> {
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
}
null -> showToast("WaveLog: 上传失败")
null -> showToast(R.string.wavelog_upload_failed)
}
}
}
/**
* Report an upload outcome, keeping the wording in the resource files.
*
* The server's own explanation goes to [wavelogError] rather than a Toast, because it is the
* only thing that says why Wavelog refused a QSO and a Toast is gone in three seconds.
*/
private fun reportUpload(result: UploadOutcome.Done) {
when {
result.failedCount > 0 && result.firstError.isNotBlank() ->
wavelogError = result.firstError
result.reason == UploadOutcome.Reason.NOT_CONFIGURED ->
showToast(R.string.wavelog_not_configured)
result.reason == UploadOutcome.Reason.NO_STATION_INFO ->
showToast(R.string.wavelog_no_station)
result.failedCount > 0 ->
showToast(R.string.wavelog_upload_done, result.successCount, result.failedCount)
// "Uploaded 0" is accurate but reads oddly when the queue was already clear.
result.successCount == 0 -> showToast(R.string.wavelog_nothing_to_upload)
else -> showToast(R.string.wavelog_upload_all_ok, result.successCount)
}
}
/** Grid-confirm result: ignore and upload / cancel */
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
val confirm = gridConfirm ?: return
@@ -307,8 +327,8 @@ class SettingsViewModel(
viewModelScope.launch {
val result = pendingUploader?.uploadQueue(force = true)
when (result) {
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
else -> showToast("WaveLog: 上传失败")
is UploadOutcome.Done -> reportUpload(result)
else -> showToast(R.string.wavelog_upload_failed)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -35,9 +36,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -48,7 +52,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.InfoDialog
@@ -114,7 +117,11 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
HorizontalDivider(thickness = 1.dp)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
StatusRow(
status = status,
stripes = uiState.dayStripes,
onClickDay = { day -> selectedDay = status to day }
)
}
}
}
@@ -167,11 +174,16 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh:
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
// The two greys are listed because most of a typical grid is grey: measured live, 81%
// of cells were "nobody reported" and 11% were outside the data we received. Without
// the distinction a mostly-grey row reads as a dead satellite.
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100),
stringResource(id = R.string.amsat_no_report_legend) to Color(0xFFC0C0C0),
stringResource(id = R.string.amsat_no_data_legend) to Color(0xFFE8E8E8)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
@@ -234,8 +246,7 @@ private fun HeaderRow(statuses: List<SatStatus>) {
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
private fun StatusRow(status: SatStatus, stripes: Boolean, onClickDay: (SatDay) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
@@ -247,10 +258,25 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
// When the global report pull is incomplete, show how many we actually got
// versus what the summary endpoint says exists. The summary is a single extra
// request, so this is honest without the 88-request cost of per-satellite pulls.
if (status.summaryCount > 0) {
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
if (actual < status.summaryCount) {
Text(
text = "$actual / ${status.summaryCount}",
fontSize = 11.sp,
color = Color(0xFF888888),
maxLines = 1,
modifier = Modifier.padding(end = 8.dp)
)
}
}
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
day = day,
stripes = stripes,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
@@ -258,20 +284,123 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
private const val NO_REPORT_COLOUR = 0xFFC0C0C0
/**
* Status colours worst first, for collapsing a day to one of them.
*
* Ordered by how much the operator needs to know about it: a reported failure outranks a
* partial contact, which outranks a success, and both greys come last because they are
* absences rather than observations. Duplicated from AmSatRepository, which owns these
* literals — see the note in AGENTS.md.
*/
private val SEVERITY = listOf(
0xFFDC267F, // not heard
0xFFFE6100, // unrecognised status
0xFFFFB000, // telemetry only
0xFF648FFF, // heard
NO_REPORT_COLOUR,
0xFFE8E8E8 // no data fetched
)
/**
* Black or white for text on [background], whichever reads better.
*
* White is not safe on all of the status colours: on the telemetry amber it measures
* 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. Relative luminance decides it instead.
*/
private fun readableOn(background: Long): Color {
val colour = Color(background)
return if (colour.luminance() > 0.4f) Color(0xFF1A1A1A) else Color.White
}
/** The day's worst status colour, used for both the flat tile and the spoken summary. */
private fun worstStatusColour(day: SatDay): Long =
day.slots.map { it.statusColor }
.minByOrNull { SEVERITY.indexOf(it).takeIf { i -> i >= 0 } ?: SEVERITY.size }
?: NO_REPORT_COLOUR
/** Legend string for a status colour, so the grid speaks the same words the legend shows. */
private fun statusLabel(colour: Long): Int = when (colour) {
0xFF648FFF -> R.string.amsat_active
0xFFFFB000 -> R.string.amsat_tlm
0xFFDC267F -> R.string.amsat_not_heard
0xFFFE6100 -> R.string.amsat_conflict
0xFFE8E8E8 -> R.string.amsat_no_data_legend
else -> R.string.amsat_no_report_legend
}
/**
* One day as a stripe per two-hour slot.
*
* Showing a single colour per day hid the shape of the day: a satellite that worked all
* morning and failed all afternoon looked identical to one that worked once. At 64 dp
* across, twelve stripes are about 5 dp each - roughly 15 px on a 440 dpi screen - and
* runs of the same status merge visually, so a typical day reads as a few blocks rather
* than twelve thin lines.
*
* Stripes run newest-first, left to right, matching both the slot order the repository
* produces and the day columns in the header. Time therefore flows right to left within
* a cell, which is the opposite of the usual convention but consistent with the rest of
* the grid.
*/
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
private fun DayCell(day: SatDay, stripes: Boolean, modifier: Modifier, onClick: () -> Unit) {
// Coloured Boxes announce nothing, so the grid - the entire content of this page -
// was silent to a screen reader. The worst status and the report count are what the
// cell conveys either way, and they are also what the tap dialog then expands on.
val worst = worstStatusColour(day)
val total = day.slots.sumOf { it.count }
val description = stringResource(
R.string.amsat_day_desc, day.dateLabel, stringResource(statusLabel(worst)), total
)
// 28 dp, below the 48 dp minimum touch target. Raising it to 48 dp 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 this page is for. Compose cannot
// extend a touch target past the layout bounds, so the two cannot both be had here.
Box(
modifier = modifier
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.semantics(mergeDescendants = true) { contentDescription = description }
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
val tile = Modifier
.fillMaxWidth()
.fillMaxHeight()
.clip(RoundedCornerShape(4.dp))
if (stripes) {
Row(modifier = tile) {
day.slots.forEach { slot ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(Color(slot.statusColor))
)
}
}
return@Box
}
// One colour for the whole day, for operators who preferred the original tile.
// The colour is the day's worst status rather than its first reported one: picking
// the first hid outages behind an earlier good report, which is what the stripes
// were introduced to expose, and a summary that hides bad news is worse than none.
Box(
modifier = tile.background(Color(worst)),
contentAlignment = Alignment.Center
) {
if (total > 0) {
Text(
text = total.toString(),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = readableOn(worst)
)
}
}
}
}
@@ -8,6 +8,7 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -19,17 +20,33 @@ data class SatStatusUiState(
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
val error: String? = null,
/** Draw each day as twelve two-hour stripes rather than a single colour. */
val dayStripes: Boolean = true
)
class SatStatusViewModel(
private val amSatRepo: IAmSatRepository
private val amSatRepo: IAmSatRepository,
settingsRepo: ISettingsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
private val _uiState = MutableStateFlow(
SatStatusUiState(
isLoading = true,
dayStripes = settingsRepo.otherSettings.value.amsatDayStripes
)
)
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
// Collected rather than read once: the switch lives in Settings, so the operator
// is on another screen when they change it and would otherwise come back to the
// old style until the page was rebuilt.
viewModelScope.launch {
settingsRepo.otherSettings.collect { other ->
_uiState.update { it.copy(dayStripes = other.amsatDayStripes) }
}
}
fetch()
}
@@ -88,7 +105,7 @@ class SatStatusViewModel(
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container.amSatRepo)
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "464"
appVersionCode = "469"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.5.7"
appVersionName = "4.6.2"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry