eb66a77cae452b8de289b011efb14d2d938a8254
307
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
57d6f9d7ed |
chore(merge): drop dead leftovers from the upstream merge
Post-merge audit found code the merge left unreferenced: - SettingsRepo: keySatelliteUrls / keyTransceiversUrls / separatorUrl were upstream's list-shaped data-source keys. The merge kept the fork's map-shaped DataSourcesSettings, so these three had a definition and zero uses. - feature/status/res/drawable/ic_refresh.xml: SatStatusScreen imports core.presentation.R only, so its R.drawable.ic_refresh resolves to the core copy; the feature-local copy was never addressable. It was the only file under feature/status/src/main/res, so the directory goes with it. Verified zero references with a repo-wide grep before removing each symbol. compileReleaseKotlin plus core:domain / core:data / feature:map / feature:roaming unit tests stay green. |
||
|
|
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> |
||
|
|
b749733289 |
fix(audio): keep cleanup from masking start failures or skipping release
AudioCapture.audioFlow's finally ran recorder.stop() then release() naked. If startRecording() threw - permission revoked mid-request, audio device error - the finally's stop() threw IllegalStateException (stop on an uninitialized recorder), which replaced the original error AND skipped release(), leaking the AudioRecord. The flow's caller saw "recorder failure" instead of "no permission" and the native recorder was never freed. Wrapping each cleanup step in runCatching preserves the original exception while guaranteeing release() runs. Probe: a start failure previously surfaced as RuntimeError with released=false; it now surfaces as the original PermissionError with released=true. |
||
|
|
b4cfb16159 |
fix(radio): don't record frequencies the radio rejected
RadioTrackingService wrote lastSetTxFreq/lastSetRxFreq unconditionally after calling setFrequency, ignoring its Boolean result. When the radio rejected the frequency - the FT-817 CAT limit added in the previous commit, a dropped Bluetooth link, or a failed ack - the remembered value no longer matched what the radio actually holds. The manual-tuning detector then saw a phantom dial change on the next read-back (read is the real frequency, lastSet is the one that never landed) and entered tuning mode: it locked onto the wrong base and kept rewriting the radio. Probe of the state machine: before the fix, a rejected 1.26 GHz write against a radio sitting on 145.5 MHz left lastSet at 1.26 GHz, so every subsequent cycle read a 1.1 GHz gap and flagged manual tuning forever. After the fix the lastSet is only updated on success, so the detector sees no change and the loop keeps applying the next valid frequency. Same fix applied to the split IC-705 path (setWorkingFrequency/setTxVfoFrequency). |
||
|
|
7319cf8f5b |
fix(coroutines): propagate cancellation in remote source and status view model
RemoteSource's five suspend functions and SatStatusViewModel's two fetch paths caught bare Exception, which also swallows CancellationException. When the owning scope is cancelled (screen leaves, app closes) a cancelled network call was reported as a null/error result instead of stopping: the caller kept running until the next suspension point, and SatStatusViewModel wrote state updates into an already-cancelled scope. Correct coroutine hygiene is to let cancellation propagate - rethrow CancellationException before the generic catch. Verified semantically with an asyncio probe: a swallowed cancel returns a normal-looking null and the caller continues; a propagated cancel stops the coroutine immediately. No behaviour change for real errors; :core:data and :feature:status compile. |
||
|
|
baf2a7022d |
fix(aprs): hold the client lock across the response read in sendPacket
sendPacket wrote the packet under the lock but read the server response outside it. disconnect() - called concurrently from stop() and from the reconnect path in AprsReporter.reportOnce's catch - nulls and closes writer/reader/socket under the same lock, so the lock-free read raced with it. A probe interleaving 5,000 sends with repeated disconnects produced a mix of 744 OK and 4,256 exception results: the response read hit a just-closed socket and the swallowing runCatching reported Pair(true,"OK") for a packet that may never have left, or read through a stale reference. The tracker believed the beacon was heard while APRS-IS never received it. Holding the lock across write+read serialises against disconnect: either disconnect got the lock first and sendPacket returns null (writer cleared), or sendPacket runs to completion and disconnect waits, bounded by the 3 s read timeout. Re-ran the interleaving probe: 3,000 sends, zero inconsistent results. Compiles and :core:data tests stay green. |
||
|
|
b95a86c97f |
fix(radio): reject FT-817 frequencies the CAT protocol cannot express
The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution, so the largest representable value is 999,999,990 Hz. encodeFrequencyBcd is exact below that, but for anything above it the %08d formatting silently drops the leading digit: 1,267.6 MHz encodes as 126.76 MHz. Verified against the release bytecode - 1,000,000,000 Hz -> [10 00 00 00] -> 100,000,000 Hz, ten times lower - and the SatNOGS catalogue has 17 transmitters with uplinks over 1 GHz (QO-100 at 2400.05 MHz, several 23 cm links), so the wrong value is reachable via RadioTrackingService when an FT-817 is mis-configured as the TX radio. The tracking loop's read-back then locks onto the wrong band with no warning. Reject out-of-range frequencies at setFrequency with a log and return false instead of sending a corrupted command. In-range values are unaffected (probe: 7.074/145.5/435.1 MHz and both 999,999,98x/99x MHz round-trip exactly; every value above the limit is refused before the encoder runs). |
||
|
|
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). |
||
|
|
1e24673632 |
fix(network): close sockets on setup failure and on write failure
Two leaks in NetworkReporter, the same family as the Bluetooth/APRS/radio socket leaks fixed earlier: 1. ensureRotatorConnected/ensureFrequencyConnected assigned the field directly, so a channel that opened but threw during the rest of setup was never closed and remained referenced. Use a local `opened` and close it in the catch, matching the pattern used in AprsIsClient/Ic705Controller/ Ft817Controller/BluetoothReporter. 2. write() only flipped connected=false on failure. The broken channel stayed in the field, the next ensure* reconnected and overwrote it, and the old channel was never closed. Now a failed write closes the channel and nulls the field. The null check is identity-based (socket === field) so a stale reference from a concurrent report can never close a newer channel. State-machine probe: normal write keeps the socket, failed write closes and nulls, next report reconnects fresh, and passing a stale reference does not close the newer socket. |
||
|
|
ed0f5678b3 |
fix(cw): cap the crash-probe log file at 1 MiB
CwProbe.step() appended one line per call with no size limit, rotation or cleanup, and it runs on every build: CwDeepDecoder is the only ICwDecoder implementation and writes infer_begin + infer_done every 1.5 s inference tick. Measured against the actual line format that is ~170 KB/hour, ~4 MB/day of unbounded growth in files/probe_cw.txt while CW audio is monitored, plus synchronous disk I/O on every inference. Truncate when the file exceeds 1 MiB instead of deleting, so the probe keeps the most recent diagnostics (the reason it exists: the last lines show where a flash-crash died). Simulated 10 h of continuous use: 2.7 MB written in total, file stays bounded around ~640 KB; previously it would have kept all 2.7 MB and grown without limit. |
||
|
|
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. |
||
|
|
27d41eb2ee |
fix(amsat): render only the days the API actually returned
The status grid always drew six day columns, but the AMSAT reports endpoint cannot supply six days for the full catalogue. Measured against the live API: limit=500 -> meta.count=500, covers 4 days (Aug 11..Aug 14) limit=1000 -> meta.count=500, same 4 days (server clamps the limit) hours=336 -> meta.count=500, same 4 days (window size does not help) before/offset/page -> ignored, same 500 newest rows With ~90 catalogued satellites the 500 newest rows only reach about four days back, so the two oldest columns were guaranteed to be uniformly gray. Gray means "no report" in this UI, so the screen asserted nobody reported those days when the truth was that the data was never fetched. Derive the column count from the oldest report actually received, capped at six. On live data that yields four columns labelled Aug 14..Aug 11 instead of six with Aug 10 and Aug 9 blank. The UI already renders whatever days it is given, so no UI change is needed. Also name the request constants and record what was measured about the endpoint, so the 500 is not mistaken for an arbitrary choice that can simply be raised. Note for a future change: the per-satellite form of the endpoint (reports.php?name=...) is not affected by the cap - sampling eight satellites returned 926 rows spanning eight days, i.e. full six-day coverage - but it needs one request per satellite (~0.8 s each, ~68 s for the whole catalogue), so switching to it is a deliberate trade-off rather than a bug fix. :core:data:compileReleaseKotlin, :core:data:testDebugUnitTest and :core:domain:test all BUILD SUCCESSFUL. |
||
|
|
2bac6655f3 |
fix(amsat): align status slots to their UTC calendar-day labels
AmSatRepository labelled columns by calendar date but filled them by slicing a
rolling 72-slot window ending at fetch time. The two timelines coincide only
near 23:59 UTC. At common fetch times the status grid lied about dates:
UTC 00:00: 72 / 72 slots under the wrong label
"today" column contained all of yesterday
UTC 12:00: 36 / 72 wrong; every column straddled two dates
UTC 13:37: 30 / 72 wrong
UTC 23:59: 0 / 72 wrong (the accidental alignment case)
Anchor the six columns on UTC midnight instead. Every SatDay now covers exactly
[day 00:00, next day 00:00), split into twelve 2-hour slots newest-first so the
UI's existing first-non-gray lookup still chooses the latest daily report.
A standalone Java probe porting the old arithmetic reproduced the 72/72,
36/72 and 30/72 mismatches. Porting the new formula gives 0/72 mismatches at
00:00, 12:00, 13:37 and 23:59 UTC.
Also restore core:data's unit-test compilation. DatabaseRepoTest's fakes were
stale after IRemoteSource gained AMSAT methods and ISettingsRepo's zero-arg GPS
setter became suspend; the whole data test suite previously could not compile,
so data-layer regressions were untestable. Updated the fake members and verified
:core:data:testDebugUnitTest plus :core:data:compileReleaseKotlin BUILD
SUCCESSFUL. The product code does not use org.json in JVM tests because Android
org.json stubs throw there, so the date math remains verified by the standalone
same-JVM probe rather than a misleading mocked parser test.
|
||
|
|
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 |