3810e76ea2a0f0889d14f2596bf76157e90d82ab
250
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.
|
||
|
|
8445c17033 |
fix: remove the receive-only notice, and stop the CW record scrolling itself
Two things the operator asked for after running 4.6.1. The receive-only notice named a state this app does not have. APRS-IS lets an unverified station connect and then discards its packets, which is what "receive-only" means at the protocol level - but this app only reports its own position. There is no receiving side to it, and none intended, so telling the operator they are in receive-only mode described a mode that does not exist here. Without a passcode the packet does not arrive, and the unverified notice already says exactly that. The string is gone from all five locales, along with the AprsReport.receiveOnly field, which had no remaining consumer. AprsPasscode.classify stays: loginValue still uses it, and its tests hold the distinction between a deliberate -1 and a typo, which is a separate defect worth keeping fixed. The CW history pane no longer follows the decode. Its whole purpose is to be read back, and a record that scrolls itself is worse than paper - as the operator put it, if it scrolls away then why use a decoder instead of listening and writing it down, since paper does not erase itself. The single line above it is where new characters appear; that still scrolls, because that is its job. A down arrow in the toolbar jumps to the newest text when wanted. Not fixed here: logged times in the log page look wrong and inconsistent. I proposed a timezone explanation and wrote a probe, and the probe disproved it - on a real JVM both the session header and the row times are stable and both resolve to local time. That reverted attempt is not in this commit. The cause is still unknown. |
||
|
|
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. |
||
|
|
cb3ebe7870 |
feat(log): the counterpart grid can be typed
On FM satellites the grid is the exchange - it is what the other station sends you and what you send back. Until now it could only arrive by scraping QRZ, which needs a cookie the operator may not have pasted, and which returns nothing at all for a station with no locator on file. So the field that carries the actual content of an FM contact was the one field the operator could not fill in. It is the second field, optional, six characters, uppercased. A typed grid also skips the QRZ lookup entirely rather than racing it: what the operator heard on the air beats what a web page says, and letting the scrape overwrite it would silently replace good data with a guess. The value is captured before the field clears, so the QSO carries it and the next contact starts empty. WavelogQso.gridsquare already existed for the scraper to fill, so nothing about the stored shape changes and no migration is needed. This was the interaction study's second-ranked conclusion, after the editable time. Both come from the same observation: the screen was built for someone typing during a pass, and the operators it is for are working the radio instead. |
||
|
|
e2263668ce |
fix(log): the table grid was nearly invisible and the sent column vanished at night
Three contrast defects, each measured with a WCAG relative-luminance probe rather than eyeballed. GridLineColor was 0xFF3A3A3A: 1.65:1 against the navBar background and 1.36:1 against a card, where Material asks 3:1 for non-text elements. Every rule and column separator on the Log page is drawn with it, so the grid the page is built around was barely there - and gone in sunlight. 0xFF6D6D6D is the lowest grey clearing 3:1 against both (3.62:1 and 3.00:1). The upload column was a green tick and nothing else. This app applies a night filter that zeroes green and blue, under which CheckGreen computes to 1.17:1 - the column disappeared entirely. Changing the colour does not fix it, because colour was also the only thing separating sent from waiting, which is the case Material calls out directly. The cell now reads OK or an ellipsis, so the state survives both the filter and colour blindness, and a contact that has not been tried is finally distinguishable from one that has. The linear-transponder passband range was 11sp, below Material's body-small floor of 12sp. That is the frequency an operator reads mid-pass to know where the transponder ends. The session group header stays at 11sp: it is a label, not information. Yellow survives the night filter at 4.69:1 because it is red-dominant, so the swipe and undo affordances needed nothing here. |
||
|
|
91263674a9 |
fix(log): a screen reader could not delete a contact at all
A Material 3 conformance audit measured three real defects on the logging screen. Deleting was reachable only by dragging. SwipeDeleteRow declared no semantics, so TalkBack saw a row of text with no actions - a switch or Voice Access user could not delete a record, not with difficulty but at all. The arming threshold was 75% of row width, roughly 249dp of continuous travel on a 360dp phone, against 120dp in this project's own SwipeableItem. Delete and undo are now custom accessibility actions on the row, which is the case the Compose accessibility guide names explicitly: swipe gestures should be exposed this way because they are hard or impossible for users with motor impairments. The undo affordance was a 29dp target with a five-second countdown running behind it - the worst place in the screen to be hard to hit, because a miss is unrecoverable. Now 48dp by 64dp, matching the mode and time rows. The trash glyph was the emoji U+1F5D1, which renders differently on every device and font and which this project forbids as an icon. ic_delete.xml already existed and is used in three other screens. Not addressed, and worth recording from the same audit: the table grid line at 0xFF3A3A3A computes to 1.65:1 against its background where Material asks 3:1, so the grid the Log page is built around is nearly invisible and gone in sunlight; and under the app's night filter the green upload tick collapses to 1.27:1 while being encoded in colour alone. |
||
|
|
9576607fc6 |
fix(log): mark a held clock in words, not only in colour
Three corrections to the editable-time commit. The held clock was distinguished only by colorScheme.primary. Material is explicit that colour must not be the sole carrier of meaning, and roughly one man in twelve cannot reliably separate that colour from the default text. Missing it costs every remaining contact the wrong time and, for a pass across midnight UTC, the wrong day. The row now reads "Held at 23:58" rather than just showing it in a different colour. The comment on the state claimed rememberSaveable survives rotation but not process death. Official documentation says the opposite: it goes through the saved instance state and does survive system-initiated process death. A probe traced the one case that genuinely loses the hold - the user swiping the app away - and not restoring it there is correct, since a clock pressed hours ago would put the next session's contacts on the wrong day. The comment says that now instead of something false. MenuAnchorType is deprecated in favour of ExposedDropdownMenuAnchorType. Surfaced by a subagent's build log rather than mine, because my grep filter was hiding warnings. |
||
|
|
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. |
||
|
|
a3e3932f73 |
fix(log): TalkBack could not find the mode row
The tappable mode row used a bare `clickable`, which declares no role. TalkBack read it as two pieces of text with nothing to say it could be activated, so the only way to correct a wrong mode was invisible to anyone using a screen reader - and the row had just become the only way to reach that field. Role.Button plus an onClickLabel naming the action. The label lives in the resource files like every other user-visible string. Caught by self-review against the project's own accessibility pattern in Components.kt rather than by a test; Compose UI is not unit-tested here, so this class of defect is only ever found by reading. |
||
|
|
3d3db784a3 |
fix(log): the mode field kept the previous transponder's value
Two things about the mode on the logging surface. It was held in `remember` with no key, so switching transponder mid-session kept the mode from the transponder before it. The operator saw the old value in the field and it went out with the upload - a wrong mode nobody chose. It is now keyed on the transponder uuid. It was also a text field the operator had to look at on every contact, when the value comes from the transponder record anyway. A pass lasts eight minutes; the study on satellite logging is blunt that the fast surface should carry one typed field, not two. The mode is now shown as a row and the field appears when the row is tapped, because a transponder record can be wrong and the operator still has to be able to say so. The row is 48dp tall, which is the Material Design minimum for anything tappable. Vertical padding alone had left it around 20dp - visually fine, awkward to hit, and a real problem for anyone with reduced dexterity. |
||
|
|
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. |
||
|
|
6db10b5b72 |
fix(sources): a dead custom URL no longer counts as a successful update
Found by an audit of the replace-semantics commit rather than by the change itself. The success count added orbital and transceivers sources together, so one could stand in for the other. With the built-in sources replaced there are two requests instead of 28: if the operator's TLE URL was down and SatNOGS answered, the count was 1, no exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update that refreshed no orbital elements at all. That also suppressed the 48-hour auto-update retry, which keys off that timestamp - so the operator was left with stale orbits, a screen saying the update worked, and nothing scheduled to correct it. The failure existed before this rebuild, but 26 other sources masked it. Narrowing the source set made it easy to hit, which is why it belongs with these commits rather than in a backlog. Orbital sources are now counted on their own. A test covers the exact case: transceivers answers, the custom TLE URL does not, and the update must raise rather than record success. Also: an upload that found nothing waiting said "Uploaded 0 QSO". Accurate, but it reads oddly when the queue was already clear, so that case has its own wording now. |
||
|
|
758dc6d567 |
fix(wavelog): the auto-upload switch had nothing behind it
Deleting the ten-minute polling loop left the "auto upload" switch in settings with no consumer - the operator could turn it on and nothing would ever act on it, which is worse than the loop it replaced. Uploading now happens when a contact is saved. That is what the switch always meant, and doing it at that moment means somebody is present to see the outcome: a partial failure says so, and the QSOs that did not go stay in the queue for a manual upload from settings. The loop reported nothing at all - a grid mismatch hit an empty if block and every other failure retried forever in the background. The upload goes through the view model rather than the composable, so the log screen still touches no repository. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
96bbb022e8 |
fix(cw): follow the transcript reliably, and keep the AMSAT grid dense
Two corrections to |
||
|
|
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. |
||
|
|
5a45aab2b1 |
fix(cw): make the out-of-window tone marker actually visible
The edge marker was drawn outward from the canvas edge, so every one of its three line segments fell outside the clip and nothing rendered. Measured at a typical 320 px width: 0 of 3 segments visible on either side. That is the one case the marker exists for - an out-of-window tone is absent from this picture by definition, so with the marker clipped away the operator has no signal at all that a shift is happening. Which is what was reported. It is now a solid bar along the edge the tone lies beyond, plus a chevron whose arms open inward from it, so the whole marker sits inside the clip while still reading as pointing off-picture. Three further defects in the same code: The frequency label was pinned to TopStart while its background rect tracked the tone's frequency, so at 1500 Hz the rect sat at x=278 and the text at x=11. The rect is gone and the label now sits on whichever side the marker is on. Markers were drawn after two early returns that fire on an empty or silent spectrum. A shift is deliberately held through key-up gaps, so the markers were blinking out during the very silences the shift survives. They now draw unconditionally, after the spectrum so it cannot bury them. dashCount floored, leaving up to 8 px of the column undrawn at the bottom. Also extracts the marker drawing into a DrawScope extension, hoists the shared colours and the target frequency to file-level constants, and rounds the label instead of truncating it. |
||
|
|
9367878702 |
fix(cw): show the correct original tone frequency in the waterfall label
The Canvas marker was fixed to draw at estimatedPitch, but the overlay Text still computed its label from estimatedPitch + toneShiftHz, which showed the shifted position (800 Hz) instead of the original tone (e.g. 1500 Hz). |
||
|
|
8fbc639a82 |
fix(cw): draw the original-tone marker at the correct waterfall position
estimatedPitch is already corrected back to the original tone frequency (the spectrogram computes from shifted audio, and updateSignalMetrics undoes the shift), so adding toneShiftHz to it again placed the orange marker at the shifted position - right on top of the green target line, making them indistinguishable. The orange marker now goes directly on estimatedPitch. When the original pitch is outside the visible 400-1200 Hz band, an arrow at the nearest edge points toward it instead. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 (
|
||
|
|
16c746f552 |
fix(mutual): advance the search when refine collapses a pass window
findMutualPassesFallback skips a candidate with `if (refinedLos <= refinedAos) continue` but left searchStart untouched, so the next loop iteration called findNextMutualPass with the same start time and received the same pass again. Today that branch is theoretically unreachable: refineEdge's 70 s window always spans findNextMutualPass's 60 s sampling step, so the refined AOS/LOS can only move inward and never cross. But the invariant is fragile - any future change to the search step or the refine window (or a near-horizon pass whose crossings land at the window edges) makes the loop spin forever on one pass, freezing the query coroutine. Advance searchStart past the collapsed pass before skipping, breaking the cycle regardless of how the windows shift. Behaviour for the current reachable paths is unchanged. |
||
|
|
8f56ea05ec |
refactor(roaming): reuse positionToQth instead of the ported grid tables
RoamingScreen carried ~170 lines of decompiled range-lookup tables (encodeLon/encodeLat) that re-implemented exactly what core:domain's positionToQth already does. A probe calling both over 16,471 sampled coordinates (every 2 degrees across the full globe) found byte-identical 8-char locators, so the tables were pure duplication - two implementations of the same Maidenhead encoding that had to be kept in sync (the earlier boundary fix had to be applied twice). Replace them with positionToQth and split its standard-ordered output (lonField latField lonSquare latSquare lonSub latSub lonSubsub latSubsub) back into the per-axis segments the UI consumes: the 3x3 ring (first 4 chars), markerLeft (lon subsquare), markerTop (lat subsquare). Out-of-range input keeps the old blank-segment behaviour: positionToQth returns null, the locator becomes spaces, qthNeighbors returns an empty ring, and the marker lookups fall back to 0. Net -160 lines. All existing RoamingState tests and the new equivalence probe pass; :feature:roaming compiles. |
||
|
|
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. |
||
|
|
aac1fa0da5 |
fix(map): pick the pass by time instead of a field that is never set
The map info panel chose which pass to describe with
allPasses.find { it.catNum == catnum && it.progress < 1 }
but OrbitalPass.progress defaults to 0 and nothing in the repository or the
prediction layer ever assigns it - PassesViewModel computes progress on its own
local copy of the list and never writes it back. The predicate was therefore
always true, so the lookup returned the satellite's *first* pass forever.
Simulated over an ISS timeline with three passes (10:00, 12:00, 14:00), 4 of 6
sampled instants were wrong: from 10:30 onwards the panel still pointed at the
10:00 pass with its countdown frozen at 00:00:00, instead of counting down to the
12:00 and 14:00 passes. Only re-fetching the pass list refreshed it.
Select by time now: the pass currently in progress, otherwise the earliest one
still upcoming. Extracted as the pure internal selectCurrentOrNextPass so it is
testable, with the reasoning recorded so the progress field is not reintroduced
as a filter here.
Restoring the old predicate fails 5 of the 6 new tests; with the fix
:feature:map:testDebugUnitTest, :core:domain:test, :core:data:testDebugUnitTest
and :feature:roaming:testDebugUnitTest are all green.
|
||
|
|
c7bb253981 |
fix(map): close both sides of an antimeridian crossing
The ground track is cut into polylines so none of them spans 180 degrees, but the cut only ever appended the outgoing edge point. The next polyline therefore began at the first sample past the meridian - typically around -178 - so the drawn track stopped at the edge on one side and reappeared inland on the other, leaving a visible gap on every orbit that crosses the Pacific. The edge point also reused the *next* sample's latitude, so the closing leg jumped: for 179 -> -178 spanning 14 -> 16 degrees latitude the edge was placed at 16.0 instead of the true crossing at 14.667. Now a crossing closes the current polyline on the edge it leaves through and opens the next one on the opposite edge, both at the interpolated crossing latitude, so the seam is continuous. The split is extracted as the pure internal splitAtAntimeridian/crossingLatitude pair, which also gives feature:map its first unit tests. Verified against a standalone Java probe first (eastward, westward, repeated crossings, and a track hugging the edge without crossing), then as Kotlin tests: restoring the old single-point behaviour fails four of them, and the current code is green alongside :core:domain:test and :feature:roaming:testDebugUnitTest. Also drops the misleading "left/right terminal position" comments: the branch that fires when the previous sample sat near +180 is the eastward crossing, and it correctly closes on +180. |
||
|
|
2da7127fd3 |
fix(roaming): derive the 3x3 grid from qthNeighbors
The decompiled per-edge branches computing the surrounding nine squares had two
independent defects.
1. Field letters stepped past the alphabet
Every branch moved a field with raw character arithmetic (`str[0] - 1`,
`str5[0] + 1`) and Maidenhead fields only run A..R, so coordinates near the
edges of the world produced squares outside the alphabet:
(-89.9, -179.9) -> [@A91, AA01, AA11, @A90, AA00, @A10, @@99, A@09, A@19]
( 89.9, 179.9) -> [RS80, RS90, SS00, RR89, RR99, SR09, RR88, RR98, SR08]
2. Some moved cells kept the old field letter
The north-edge branch advanced the latitude field for the top-centre cell only,
leaving the two top corners in the previous field:
centre AA19 -> ported [AA00, AB10, AA20, ...]
correct [AB00, AB10, AB20, ...]
Cross-checked against the shared qthNeighbors helper, which is already covered
by QthConverterTest including the AA00 and RR99 wrap cases:
before: 64,800 sampled coordinates, 6,480 disagreed (all with centre square
digits 00 or x9, i.e. the north edge and the 00 corner)
after: 64,800 sampled coordinates, 0 disagree
The ring is plain Maidenhead arithmetic with no QTH-Locator-specific behaviour,
so call qthNeighbors instead of keeping a second, wrong implementation. The
now-unreferenced buildGrids branches are removed (grep confirmed the definition
was the only remaining occurrence). The existing OL42 reference grid and the
four ported edge-case tests still pass unchanged.
Reverting the fix fails both new regression tests; with it
:feature:roaming:testDebugUnitTest and :core:domain:test are green.
|
||
|
|
fed9fe188e |
fix(roaming): assign exact grid boundaries to the correct cell
The QTH Locator port keeps the decompiled range tables, which close both adjacent cells (`-20.0..0.0` then `0.0..20.0`). Kotlin's `when` takes the first match, so any coordinate landing exactly on a field, square or subsquare boundary was attributed to the previous cell: (0, 0) II99xx99 should be JJ00aa00 (1, 1) JJ00lx99 should be JJ01ma00 (22, 108) OL31xx99 should be OL42aa00 (22.5, 108.5) OL42fl99 should be OL42gm00 (22.25, 108.25) OL42cf99 should be OL42dg00 At the field level the locator is wrong by a whole 20 deg x 10 deg field, and the 3x3 neighbour grid plus the red position marker are derived from the same characters, so the whole Roaming screen pointed at the wrong square. Cross-checking the port against core/domain positionToQth over the grid: before: 65,341 sampled points, 4 agreed after: 65,341 sampled points, all agree The independent converter was confirmed correct first: it reproduces the user-verified reference sample OL42ih45, and hand-computing lon=-179.75 (0.25 deg into the field, x12 -> subsquare index 3 = 'd') and lat=-90 (subsquare 'a', extended digit 0) matches it rather than the port. Rather than rewriting the faithful lookup tables, nudge the input by 1e-10 so the closed ranges behave like the standard half-open [low, high) cells, keeping +90/+180 inside the final R cell. Seven real-world city samples and all existing ported-behaviour tests, including (90, 180) -> RR99xx99, are unchanged. Regression tests added for the boundary cases and for cross-implementation agreement. Reverting the fix fails both; with the fix :feature:roaming:testDebugUnitTest is green. |
||
|
|
19ca5205fb |
fix(cw): make waterfall revision atomic and keep clear from resurrecting old data
Two races shared the same cause: pushSamples runs on the audio capture thread
while clear() runs on the Compose main thread.
1. Lost redraw notifications
Both paths did `_revision.value += 1`. That expands to get -> add -> set and is
not atomic. A controlled two-thread probe (20k increments each, five runs)
lost up to 6,402 increments / 16%; using StateFlow.update lost zero. Since
revision is the Canvas's only redraw signal, every lost update can leave the
waterfall showing stale rows. If both writes land on the same number, StateFlow
sees no value change and notifies nobody.
Use `_revision.update { it + 1 }` in both paths.
2. Clear resurrected pre-clear audio
pushSamples copies pending audio under the lock, deliberately performs FFT
outside it, then reacquires the lock to append rows. The exact interleaving:
audio thread: take old audio, start FFT
main thread: user taps Clear -> rows/pending empty
audio thread: old FFT completes -> appends old rows again
The display becomes empty then immediately redraws the audio the user cleared.
A deterministic thread probe reproduced old rows after clear. Add a generation
counter protected by the same lock: pushSamples records it before FFT and drops
the computed rows when clear incremented it meanwhile. Fixed probe remains empty.
Verification: :feature:cw:compileReleaseKotlin + full :core:domain:test BUILD
SUCCESSFUL; grep confirms no non-atomic revision increments remain.
|
||
|
|
a7a6d70d40 |
fix(aprs): keep enabled switch consistent with actual service state
AprsForegroundService refuses to run when callsign is blank and calls stopSelf(), but it never writes enabled=false back to AprsStore. AprsCard did the opposite: toggling Enable first persisted enabled=true, then started the service. On a fresh install with no callsign this produced a permanent lie: UI switch: ON SharedPreferences: enabled=true service: stopped Leaving and reopening settings still showed ON even though APRS had never sent a packet. Startup/restore code could then repeatedly try to launch a service that immediately stops itself. There were two entry paths with the same root cause: 1. Turning the switch on before entering a callsign. 2. Erasing an existing callsign in the dialog while APRS was already enabled. The switch now opens the configuration dialog without persisting or starting anything when callsign is blank. Saving the dialog also forces enabled=false when the callsign was erased. State-machine simulation: old state ends ON/stopped; both fixed paths end in a consistent OFF/stopped state. :feature:settings:compileReleaseKotlin and full :core:domain:test BUILD SUCCESSFUL. |
||
|
|
828f720955 |
fix(status): show gray cell instead of crashing on an empty day
AmSatParser deliberately uses getOrNull + mapNotNull while reading each day's 12 slots, so a shortened HTML row can legitimately produce SatDay(slots=[]). StatusRow then selected the first non-gray slot and fell back to slots.first(), which throws NoSuchElementException and crashes the entire AMSAT status screen. Use firstOrNull for both lookups and render a zero-count gray placeholder when no slot exists. Real amsat.org HTML currently has all 41 satellite rows at the full 73 cells, but the parser's own tolerance contract means the UI must handle what it can emit. Verified against the live page: parser matches 41/41 rows and 477/477 reports; :feature:status:compileReleaseKotlin BUILD SUCCESSFUL. |
||
|
|
aedb3fee19 |
fix(radar): SwipeDeleteRow missing key() deletes wrong QSO
Without key(entry.id), Compose reuses component state by position. When the list reorders mid-countdown (new QSO inserted at index 0, or QRZ grid backfill triggers refreshTick++), the pending deletion transfers to a different record and removes the wrong one. Affected screens: LogTab and WavelogLogScreen. |
||
|
|
77314e824e |
fix(radar): make pass auto-advance and duplicate-QSO guard actually work
自查上一轮修复时发现两处改动根本没生效, 属于我上一轮的误判, 这里改成真修复。
## 1. 过境结束不切换 (上一轮改动无效)
上一轮在 tick 循环里加了"过境结束就重新 findCurrentPass()"。但
findCurrentPass() 的第一级匹配是:
passes.find { it.catNum == catNum && it.aosTime == aosTime }
其中 (catNum, aosTime) 来自 satelliteRepo.selectedPass, 而 selectPass()
只在 MainScreen 用户点击过境时调用 (grep 全仓库确认 3 处调用点全在
MainScreen), 雷达页运行期间该值不变。所以过境结束后重新查询仍然精确命中
同一个已结束的过境, nextPass != pass 恒为 false, 一次都切不过去。
模拟脚本复刻 findCurrentPass 四级回退 + tick 循环验证:
修复前 ISS(10:00-10:10) 结束后, 到 10:19 仍在 tick ISS, 切换 0 次
修复后 10:11 切到 NOAA-18(10:15-10:25), 切换 1 次
边界: 最后一个过境结束后无下一个, 保持当前不崩溃
改为新增 findNextPassAfter(): 取 aosTime > current.losTime 的过境, 优先
同一颗卫星的下一圈 (雷达继续跟这颗星), 没有则退回任意卫星最早的那个。
## 2. 重复提交 QSO (上一轮改动无效)
上一轮加的 submitting 标志位没有任何作用: submit() 全程同步, 进入时置
true, 返回前置 false, 中间没有挂起点。两次 IME onDone 是两个独立事件,
第二次进来时标志早已复位。
改为记录上次入库的呼号与时间戳, 同一呼号在 2 秒内重复提交直接忽略。
这才是实际要防的场景 (误触两次回车存两条同样的 QSO)。
验证: :feature:radar:compileReleaseKotlin BUILD SUCCESSFUL
|