main
136
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 (
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
b6753a4fa6 |
Revert "fix(cw): scale and band-pass the shifted audio instead of clipping it"
This reverts commit
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
40ba3fecdf |
chore(amsat): remove the HTML scraping path superseded by the JSON API
The merged upstream AMSAT implementation fetches status data from AMSAT's JSON endpoints (getAmSatCatalog / getAmSatReports), so the fork's HTML scraping path no longer has a caller: - core/data/.../source/AmSatParser.kt (136 lines): parsed the amsat.org status table, deriving state from the page's inline colour codes. - IRemoteSource.getStatusHtml() plus its RemoteSource implementation and the DatabaseRepoTest fake override. Verified zero references repo-wide before removing, and again afterwards. Request / CancellationException imports in RemoteSource remain in use by the other fetchers. compileReleaseKotlin plus core:domain / core:data / feature:map / feature:roaming unit tests stay green. |
||
|
|
a654735337 |
merge: upstream rt-bishop main (18 commits) with conflict resolution
Merges rt-bishop/Look4Sat main (
|
||
|
|
a42a5f1f0d |
Tweaked sunrise/sunset calculations, added unit tests (#241)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com> |
||
|
|
ed1fe66892 |
fix(geo): replace the clipLon while-loop with a modulo reduction
clipLon reduced longitudes by looping += 360 until in range. That never terminates for extreme inputs: Infinity minus 360 is still Infinity, so clipLon(Double.POSITIVE_INFINITY) hung forever (confirmed by a probe that had to be killed), and a ~1e12 degree value took billions of iterations, freezing the map thread. NaN came back as NaN either way. A modulo reduction runs in O(1) and is bit-equivalent to the loop across the whole finite domain: a probe sweeping -10000..10000 at 0.01 degree steps (2 million points) plus the boundary values -180/-179.999/0/179.999/180/180.001/ ±360/±540 shows zero mismatches. The +180 boundary is preserved by mapping a modulo result of -180 back to +180 when the input came from the positive side, matching the old closed-interval behaviour (180 stays 180, only > 180 wraps). Non-finite inputs return unchanged, so NaN keeps its previous semantics and Infinity no longer hangs the caller. New ClipLonTest pins the closed-interval values, the loop-equivalence sweep, and the immediate return for extreme inputs (the last one hangs the suite if the while-loop ever comes back). |
||
|
|
af96fe1cf0 |
test(predict): pin the Moon hour angle to a reduced range
MapViewModel converts MoonPosition.gha into the sub-lunar longitude with `if (gha <= 180) -gha else 360 - gha`, which is only a valid longitude while gha stays inside 0..360. Nothing enforced that: getMoonPosition relies on `while (teg > 360) teg -= 360` reducing GMST before the single `if (gha < 0) gha += 360` correction, and the raw GMST polynomial is about 3.5e6 degrees today, so losing that one line silently pushes the Moon marker millions of degrees off the map instead of failing loudly. Sweep a synodic month at 37-minute steps (1,167 samples) asserting gha stays in 0..360 and the derived longitude in -180..180, plus a check that the hour angle advances 10-20 degrees per hour. Verified the test has teeth: deleting the teg reduction makes both cases fail; with the current implementation :core:domain:test is green. No production change - the existing code is correct. |
||
|
|
d5230b3bc6 |
fix(qth): correct Maidenhead boundaries and longitude wrapping
A full-domain round-trip probe found three related boundary bugs.
1. Exact positive limits wrapped the square/subsquare terms to zero
positionToQth clamped only the A-R field index. At +90 latitude / +180
longitude the field saturated at R, but all later terms used modulo and wrapped
to square 0 / subsquare a:
(90, 180) -> RR00aa00 -> (80.002083, 160.004167)
error: -9.998 deg latitude, -19.996 deg longitude
The existing test incorrectly asserted RR00aa00 and had therefore fossilised
the defect. Clamp shifted coordinates just inside the half-open upper bound so
the limits land in the final cell RR99xx99.
2. isValidPosition allowed longitude through +360
Maidenhead covers -180..180, but 181..360 was accepted and produced plausible
locators that decoded 20-200 degrees away:
lon 181 -> decoded 161.004167 (error -19.996)
lon 270 -> decoded 170.004167 (error -99.996)
lon 360 -> decoded 160.004167 (error -199.996)
Restrict the converter contract to -180..180.
3. Locator validation allowed S-X as field letters
The first pair has 18 fields A-R, while only the later subsquare pairs use
A-X. The shared [A-X]{2} regex accepted SS00aa / XX99xx and decoded them past
the poles (up to lat 149.98, lon 299.96). Use A-R for the field pair.
The SettingsRepo caller had a separate wrapping bug that masked part of this:
it mapped longitude>180 by subtracting 180 (270 -> +90, wrong hemisphere)
instead of modulo 360 (270 -> -90). Fix that at the writer too.
Verification:
- standalone JVM sweep: 519,841 points, old code had 1,441 large-error points
with max drift 9.997917 deg lat / 19.995833 deg lon
- new Kotlin regression sweep requires every 8-char round trip <=0.01 deg
- QthConverterTest BUILD SUCCESSFUL
- full :core:domain:test + :core:data:compileReleaseKotlin BUILD SUCCESSFUL
|
||
|
|
5f1f90067f |
fix(aprs): clamp altitude and wrap course to keep fixed-width fields
Both extensions are fixed-width decimal fields, but neither value was range
checked before formatting:
formatAltitude(-50.0) -> /A=-00164 ('-' eats a digit slot)
formatCourseSpeed(_, 360f) -> /360/... (course must be 000..359)
formatCourseSpeed(_, -1f) -> /-01/... (widens the field)
A negative altitude is reachable from a below-sea-level position or a poor GPS
fix, and the malformed extension corrupts everything after it in the comment
field. Altitude now clamps to 0..999999, course wraps modulo 360, and speed
clamps to three digits.
Found by the same locale probe that produced the previous commit.
:core:domain:test BUILD SUCCESSFUL.
|
||
|
|
f4e188261d |
fix(aprs): format packets with Locale.ROOT so they stay ASCII
All nine String.format calls in AprsPacket used the JVM default locale. On a device set to Arabic, Persian or Bengali the digit shapes come out as Eastern Arabic / Bengali numerals, so every position report was malformed: ar_EG lat=٣٩٥٤.٢٥N lon=١١٦٢٤.٤٤E alt=/A=٠٠٠٣٢٨ fa_IR lat=۳۹۵۴.۲۵N lon=۱۱۶۲۴.۴۴E alt=/A=۰۰۰۳۲۸ bn_BD lat=৩৯৫৪.২৫N lon=১১৬২৪.৪৪E alt=/A=০০০৩২৮ APRS-IS is an ASCII line protocol, so aprsc rejects these packets outright: APRS reporting simply never worked for those users, with no clear error. A locale using ',' as the decimal separator would corrupt the range filter the same way. Affected: getDMS position encoding (all five ambiguity branches), the DDMM.MM/DDDMM.MM assembly, formatAltitude, formatCourseSpeed and formatRangeFilter. TDD proof: without Locale.ROOT: 4 of 4 AprsPacketLocaleTest cases FAILED with Locale.ROOT: BUILD SUCCESSFUL, full :core:domain:test green Ruled out by the same probe (no change made): getDMS degree/minute split matches an independent DDMM.MM reference implementation over 1,800,000 sampled latitudes with zero divergence; the passcode loop dropping the trailing NUL on even-length callsigns is the standard algorithm's behaviour. |
||
|
|
e1233dceaa |
fix(wavelog): preserve six-character grids in v1 ADIF upload
WaveLog v1 truncated every grid to four characters with gridsquare.take(4), while v2 sent the same grid at full precision. QRZ backfill provides six-character locators (e.g. OM89ab / FN31pr), so the v1 path - the one used by the user's server in practice - degraded position precision from roughly 4.6 km to around 100 km and stored different data depending on which API version answered. Send the complete grid through v1 as well. The ADIF length field is already computed from the actual value, so six/eight-character locators need no special handling. TDD proof: old take(4): v1_adif_preservesSixCharacterGrid FAILED fixed: WaveLogApiPayloadTest BUILD SUCCESSFUL full suite: :core:domain:test BUILD SUCCESSFUL |
||
|
|
d749c5ab48 |
fix(domain): WavelogQueue.updateGridsquare 添加同步锁
问题:updateGridsquare 是唯一没有 @Synchronized 的修改方法,与 add() 并发(LogTab 主线程 add + 后台协程 updateGridsquare)会触发 read-modify-write 竞态,导致新 QSO 丢失。 修复:给 updateGridsquare 加 @Synchronized,与其他修改方法保持一致。 影响:消除数据丢失风险。 |
||
|
|
cf93ca9f71 |
fix(ui): 修复菜单布局的三个严重 bug
Bug #1: moveToMain 误驱逐页面 - 根因:每次调用 moveToMain 都执行驱逐逻辑,即使页面本来就在主菜单 - 场景:拖拽主菜单内部顺序 → SettingsViewModel 遍历新顺序逐个调 moveToMain → 每次都判断 main.size > 5 → 误驱逐最后一个页面 - 修复:只在真正从 More 移到主菜单时才驱逐(加 wasInMore 标志位) Bug #2: onReorder 传 resolve 输出污染状态 - 根因:SettingsScreen 拖拽回调传的是 resolve 输出(mainItems/subItems) 而不是持久化输入(screenOrder/subMenuOrder) - 场景:拖拽主菜单 → onReorder 传 [Radar, ..., Settings](完整列表) → Settings 被显式存入 screenOrder → 下次 resolve 当作用户手动放置 → 参与驱逐逻辑 - 修复: 1. 拖拽主菜单时过滤掉 Settings(锁定页面用默认位置,不存持久化) 2. 另一个菜单用输入的 screenOrder/subMenuOrder,不用 resolve 输出 Bug #3: onReorder More 菜单传错参数 - 根因:拖拽 More 菜单时传的是 mainItems(resolve 输出),不是 screenOrder - 修复:改用输入的 screenOrder 影响:修复前,拖拽主菜单会丢页面,移页面到主菜单可能导致 Settings 消失 |
||
|
|
7cadded6ca |
fix(data): compute the OMM epoch day fraction numerically, not by string surgery
排查页面顺序问题时顺带审计发现: OMM/CSV 历元在子夜后约 86 秒内会被解析成
完全错误的值, 且不抛异常 —— 静默的错误数据。
## 根因
DataParser.parseCSV 用字符串拼接构造历元:
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
substring(1) 的意图是切掉 "0.123" 的前导 0。但 Double.toString() 在数值
小于 1e-3 时切换为科学计数法, 于是被切掉的是【有效数字】, 剩下的指数后缀
让整个字符串重新变成一个合法但语义完全错误的 double。
Kotlin 侧实测(单测失败信息):
00:00:01.000 期望 25001.000011574073 实得 0.2500115740740741
00:01:00.000 期望 25001.000694444443 实得 2.5001944444444444
与 JDK 侧独立验证逐位一致。00:01:26.4 之后 frac >= 0.001, 不再用科学计数法,
所以这个 bug 只在每天前 86.4 秒的历元上出现(约占 0.1%), Celestrak OMM 数据
里整分历元并不罕见。
## 影响
runCatching 抓不到(没有异常), 该卫星的 juliandDateOfEpoch 会推出 year=2000
day≈0, tsince 偏差约 26 年 —— 方位/仰角/过境预报彻底失效, 不是精度下降。
且用户无从察觉。
## 修复
改为数值相加, 不经过字符串:
val dayFraction = (hour + min + sec + ms) / 86400000.0
val epoch = "${year.substring(2)}$day".toDouble() + dayFraction
"25001".toDouble() + 0.0000115 = 25001.0000115, 无科学计数法风险。
## 验证
DataParserTest 新增 5 个历元回归测试(子夜整点/子夜后 1 秒/子夜后 1 分钟/
正午/当日最后一毫秒)。先确认前两个在旧实现下失败(failures=2), 修复后:
- DataParserTest 24 个测试全绿(原有 19 个无回归)
- :core:domain:test 全量 105 个测试 0 失败 0 错误
|
||
|
|
6fc2f560b7 |
fix(nav): resolve the menu layout in core:domain so page order actually applies
用户报告"把 AMSAT 从更多菜单移到主菜单没生效"。排查后发现这是两个互相
掩盖的 bug, 其中一个会让用户永久无法进入设置页。
## Bug 1 (致命): 移任意页进主菜单 -> 设置入口从所有菜单消失
对"主菜单上限 5"的定义两处不一致:
- SettingsScreen.kt:892 判断 mainItems.filter { it != "Settings" }.size >= 5,
上限是【不含 Settings 的 5 个】, 于是认为 [Satellites,Passes,Radar,Map]
还有空位, 直接追加 -> 共 6 项
- MainScreen.kt:165 的 .take(5) 上限是【含 Settings 的前 5 个】, 而 Settings
排在最后 -> 被截断丢弃
结果 Settings 既不在底栏也不在更多菜单(它不在 subMenuOrder 里), 用户再也
进不去设置页, 无法自行改回, 只能清数据或重装。
## Bug 2 (用户报告): AMSAT / WavelogLog 移到主菜单被静默撤销
MainScreen.kt:161-163 给老用户补新页面的迁移逻辑【无条件执行】:
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
用户把 AMSAT 移出子菜单后 subMenuOrder 里没有它, 这段又加回去, 第 165 行
filter { it.screenId !in subOrder } 于是永远过滤掉 AMSAT。
副作用: 这个 bug 恰好把主菜单拉回 5 项内, 反而掩盖了 Bug 1 —— 所以用户只
看到"AMSAT 移不过去", 没触发"设置锁死"。
## Bug 3: 溢出页面凭空消失而非落入更多菜单
.take(5) 直接丢弃超出的页面, 它们既不在底栏也不在更多菜单。
## Bug 4: 设置页展示顺序与底栏实际顺序不一致 (WYSIWYG 失效)
两处各自实现排序: 设置页不做 take(5)、用 sortedWith 把 Settings 钉最后;
MainScreen 做 take(5)、Settings 位置由 screenOrder 决定。
## Bug 5: 更多菜单里 AMSAT / Roaming 当前页不高亮
MoreMenuPopup.kt:69-79 的 when (currentKey) 缺 AmSat 与 Roaming 分支,
MainScreen.kt:188-198 缺 Roaming 分支, 靠 else -> false 兜底, 新增页面时
不会有编译错误提醒。Screen 子类都是 data object, 直接用 currentKey == screen
即可, 新增页面自动生效。
## Bug 6: 设置页主菜单区不过滤 hiddenScreens
MainScreen 会过滤隐藏页, 设置页不过滤, 于是隐藏的页面仍占据设置页的位置且
"移出主菜单"按钮可点, 与底栏实际情况不符。
## 改动
新增 core/domain/navigation/MenuLayout.kt (纯 Kotlin, 为 KMP 就绪) 作为菜单
布局的唯一入口:
- resolve(): 持久化偏好 -> 底栏 + 更多菜单。先给 Settings 预留名额再截断,
溢出页面落入更多菜单而非丢弃; 两个列表都没提到的页面按默认归位(升级不丢页)
- moveToMain() / moveToMore(): 移动语义集中一处, 拒绝把 Settings 移出
MainScreen 与 SettingsScreen 改为共用它, 删除双份实现与无条件迁移逻辑。
## 死代码清理 (每项已 grep 全仓库确认零引用)
- SettingsAction.ResetScreenOrder: 无任何派发点, 仅定义与 when 分支
- UiSettingsCard 的 onReorder 参数: 函数体内两个 DragOrderList 的 onReorder
实参都调 onUpdateMenu, 从未调用该参数
- SettingsAction.ReorderScreens: 唯一引用是上面那个死参数
- MainScreen 的 navigateToRadar 参数: 函数体内唯一出现是注释掉的一行
- Navigation.kt 的 defaultScreenOrder / defaultSubMenuOrder: 迁移后零引用,
默认值已在 MenuLayout 内
保留 entry<RadarDestination> 分支不动 —— RadarDestination NavKey 被
PassDetailsMatcher deeplink 使用。
## 验证
- MenuLayoutTest 13 个新测试全绿, 每个对应上面一个 bug 场景
- :core:domain:test 全量 100 个测试 0 失败 0 错误
(DataParser 19 / Doppler 17 / Qth 8 / Transponder 7 / CwCtc 7 /
CwDeepBuffer 13 / CwGolden 3 / CwSpectrogram 8 / MenuLayout 13 /
WaveLogApi 5)
- :core:domain:compileKotlin + :core:presentation + :app +
:feature:settings compileDebugKotlin => BUILD SUCCESSFUL
- 用 Python 复刻新规则重跑当初失败的全部场景: 5 个页面逐一移入主菜单,
设置入口全部保住、页面无丢失; 隐藏页 + 移动组合无页面丢失; 幂等性通过
|
||
|
|
21f14848da | Merge origin/main (resolve CW fldigi vs DeepCW conflicts, keep DeepCW) | ||
|
|
2e1d8b9c00 |
feat(cw): archive decoded history, polish the waterfall, document licensing
三个用户反馈一并解决: 1. 解码文字不再消失 (核心) 旧实现: 20 秒环形缓冲满了就静默覆盖最旧样本, 文字随之从屏幕消失。 新实现: CwDeepBuffer 新增 overflow —— 满时被覆盖的旧样本先进 overflow, 解码器累积到 15 秒就单独解码一次, 结果追加到 historyText (只增不减)。 UI 记录区显示 historyText + decodedText (历史稳定 + 当前窗口实时)。 ICwDecoder 接口新增 historyText StateFlow。 归档音频已离开主窗口, 不再被 CTC 修正, 故其文本是"最终版", 追加安全。 归档窗口 15 秒: 内容已在 20 秒窗口里解过多次, 短一点几乎无损, 且推理 开销小。 2. 瀑布图更好看 配色从"深蓝->青->黄"换成 matplotlib inferno (黑->紫->品红->橙->黄), 与静态频谱图保持一致。相邻 bin 之间用水平渐变做线性插值, 消除 65 列 离散方块的像素感。 3. AGPL 合规补漏 (用户提醒: 仓库许可证没体现 AGPL 组件) README 新增 License 章节: 声明项目主体 GPL-3.0 + feature/cw 的 DeepCW 模型 AGPL-3.0-only, 并说明合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理。 验证: - :core:domain:test => 31 个 CW 测试全绿 (CwDeepBufferTest 新增 3 个 overflow 归档测试: 顺序/清空/reset) - :core:domain:compileKotlin + :core:data + :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL |
||
|
|
bd51044690 | Added custom frequency offset setting to network reporting | ||
|
|
9b0d543f12 |
refactor(cw)!: replace legacy CW engine with DeepCW in both entry points
DeepCW 成为唯一 CW 解码内核, 不保留旧引擎作兜底 (用户决定: 完全移植)。 删除旧内核 (1298 行): - CwDecoder.kt / CwBayesianDecoder.kt / CwChannelTracker.kt / CwSpectrogram.kt - CwDsp / CwFFT / CwSTFFT / CwGoertzel / CwFilter / CwResampler.kt - CwDecoderTest.kt 旧内核的自动定频与 squelch 门控由 DeepCW 的 400-1200Hz 固定频窗替代 —— 模型自带定频, 不再需要频谱峰值跟踪和噪声门。 两个 CW 入口都改接 DeepCW: 1. feature:cw 独立整页 CwDecodeScreen.kt 重写为纯 Compose (瀑布图 -> 实时行 -> 历史区), 移除 AndroidView/LayoutInflater 和被删的 activity_main 布局; 删除 CwSettingsDialog.kt (旧引擎的手动音调/带宽面板, DeepCW 全自动无需) 2. feature:radar 内嵌可折叠面板改走 ViewModel 的 CW state/action, 移除 Morse Expert 控制器与 cw_panel_main 布局 (该面板此前注释写明 "no longer feeds it", ViewModel 里的 CW 通路是死代码) 新增 CwWaterfall.kt: 复用 CwDeepSpectrogram 绘制瀑布图, 显示的正是模型 分析的 400-1200Hz 频段与同一批幅值。 接口接线: - IMainContainer.provideCwDecoder() 提供 ICwDecoder (实现需 Context 读 assets) - RadarViewModel 构造新增 cwDecoderFactory, onCleared() 中 close() 释放 OrtSession 避免原生内存泄漏 - 删除已无调用方的 RadarAction.CwSetToneFreq (DeepCW 定频固定, 无参数可调); CwSubState.cwToneFreq 语义改为只读显示检测到的音调 依赖清理: - feature:radar 不再依赖 feature:cw 与 constraintlayout - feature:cw 不再依赖 constraintlayout 字符串: feature:cw 清理 Morse Expert 遗留串 (premium/rate/help/ tap_back_again_to_close/purple_500), 按 en/zh/tr/in/id 五语补全新串; core:presentation 补 radar_cw_tone / radar_cw_waiting 五语。 验证: ./gradlew :core:domain:test => 4 个测试类全绿 ./gradlew :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL ./gradlew :feature:radar:compileDebugKotlin => BUILD SUCCESSFUL ./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL |