main
1137
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
abb73ffef7 |
build: bump to 4.6.2 (versionCode 469)
Carries the CW record fixes: the record no longer deletes its own text while decoding, a drifting tone no longer wipes it instead of filling it, the archive path no longer races itself when capture stops, and the decoded text can finally be copied off the screen. Also stops the APRS version string drifting, which the comment on it had predicted and which had already happened again: it still read 4.6.0 while the app shipped 4.6.1, so every station on the network was told the wrong version. AprsReporter now takes the version as a parameter and the app module passes BuildConfig.VERSION_NAME, which required turning on the buildConfig feature - AGP 8 does not generate the class otherwise. The literal cannot fall out of step with the build again.v4.6.2 |
||
|
|
cc4f156c83 |
fix(cw): a drifting tone wiped the record instead of filling it
The record could go from a screenful of text to less, and then to nothing at all, while
decoding was still running. The cause is dropBufferedAudio(), which runs on every change
of shift - and the shift tracks the detected tone, which drifts across a pass.
The live window is the only route into the archive: audio gets there by being pushed out
by newer audio. Clearing the window therefore did not merely discard 20 s, it reset the
progress towards ever archiving anything. Modelled at 18 WPM, a drift every 20 s meant
five minutes of listening archived not one character, however long the operator waited -
the window was always wiped before the first sample could be evicted. With the record
still concatenating the live decode at the time, each wipe also cut the visible
transcript short, which is the text going backwards and then never accumulating.
Retiring the window instead of dropping it fixes both. Those samples cannot stay - one
spectrogram over two shift amounts smears the tone - but they were shifted consistently
and they are complete, so they decode fine on their own. They are queued and archived by
the normal path, keeping dropBufferedAudio() non-suspending: both callers sit on the
synchronous capture path, and decoding there would put an inference inside the tone
scan. Modelled over 300 s, a drift every 5 s goes from 0 characters archived to 449.
An earlier attempt at this - keeping archiveSize instead of zeroing it - was wrong and
the probe rejected it: with the window wiped before it ever overflowed, that buffer was
empty, so preserving it preserved nothing.
The queue is synchronised (written from capture, drained from capture and flush) and
capped at four windows, dropping the oldest when full: a tone drifting on every
detection scan would otherwise queue faster than the decoder can drain.
Also from an audit of the previous two commits:
- The copy button was gated on the record, which is empty for the opening half-minute
while the first batch accumulates. That greyed out the one control that rescues the
text over exactly the short exchange most likely to be lost. It now copies the live
window too, and the empty state says which surface updates sooner.
- cw_copy, cw_copied, cw_record_label and cw_record_empty were missing from values-id,
values-in and values-tr, which carry the full UI strings, so those users saw English.
- Dropped a KDoc block left dangling by
|
||
|
|
3810e76ea2 |
fix(cw): the archive path raced itself when capture stopped
flush() runs on a different coroutine from processBuffer - on pause from the screen's own effect, and from the app scope as the screen leaves - so both were appending to committedText concurrently. That append is a read, an inference lasting hundreds of milliseconds, and only then a write, so the window for interleaving is the whole inference: the later write wins and an entire batch of text is gone. Modelled over 40 trials the unlocked version lost 160 characters, averaging a full batch each time. Worse, both paths touch archiveBuffer and archiveSize. flush() zeroes the index after copying out a snapshot; the capture coroutine then writes from zero into slots that snapshot already covered, so the same audio decodes twice and the text appears twice. Both failures land at the moment the operator stops listening and starts reading, which is the worst possible time for the record to be wrong. archiveLock now serialises the archive path. It is a separate mutex from inferenceLock, which is a tryLock that drops work when contended - right for the live window, where another decode is 1.5 s away, and wrong here, where dropping a batch discards the audio for good. Mutex is not reentrant, so archiveDecode is split into a locking shell and archiveDecodeLocked for callers already holding it. reset() is left unsynchronised and now says so: an archive decode in flight can land after it returns, leaving a few characters behind. Making it suspend to close that window would push suspension onto every caller including a button handler, and the operator who asked to clear can ask again. |
||
|
|
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. |
||
|
|
e315c87f05 |
build: bump to 4.6.1 (versionCode 468)
34 commits since 4.6.0, 56 files, +4902/-365. Three areas that were diagnosed as broken and rebuilt: APRS beaconing, WaveLog upload, and the satellite data source URLs. The one that mattered most: 4.6.0 reported every APRS beacon as sent regardless of outcome, so nobody running it could tell whether their station had ever reached the network. That is fixed, and the login and refusal paths are verified against live APRS-IS servers - the old login line was malformed and euro.aprs2.net, noam.aprs2.net and rotate.aprs2.net all refused it, which the app read as success. WaveLog uploads now read the reply body. A rejected contact used to be marked uploaded and dropped from the queue, so the contact was lost while the screen said it went up. In-app release notes updated in the five locales that carry them. Turkish, Indonesian and Malay get the English text rather than the previous version's notes, which would otherwise describe the wrong release. What is NOT verified: no packet from this build has been confirmed on aprs.fi, and nothing about the foreground service, the Doze-proof alarm or any composable has been executed on a device - there is no emulator here. The transmit path needs a licensed callsign and a real passcode. A six-step checklist for that is in .hermes/plans/2026-08-26_aprs-verification-checklist.md.v4.6.1 |
||
|
|
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. |
||
|
|
cd70e3654c |
fix(sources): a custom URL no longer wipes the manual-import type index
The previous commit indexed custom-URL satellites under "Other", which is the key manual file import already writes. setSatelliteTypeIds overwrites rather than merges, so importing a file and then updating from a custom URL erased each other's type index - a probe confirmed it in both directions. The satellites were never at risk: database rows survive because insertEntries is REPLACE with no delete, and the selection is a separate id list. What was lost was their grouping in the type filter. Still worth a distinct key, since a URL and a hand-picked file are different things. Custom URLs now use "Custom". Manual import keeps "Other". The test asserts the new key and that "Other" stays untouched, so the collision cannot come back unnoticed. |
||
|
|
a25f0fe2cf |
fix(sources): a custom URL now replaces the built-in sources
Switching "Custom TLE URL" on used to mean "my source AND yours". The map overwrote only
the value keyed "All" and the other 26 built-in sources were still fetched, so pointing
Look4Sat at a mirror, a filtered subset, an offline server or a URL reachable on a censored
network did not stop it hitting Celestrak 26 more times. On a blocked link the real
behaviour was 26 failing requests.
This was a fossil rather than a decision: upstream has satelliteDataUrls as a plain list of
six URLs with no keys and no custom-URL concept, all fetched unconditionally. The fork
turned the list into a keyed map and bolted the override onto one key.
Three things made replacing safe to choose, all checked rather than assumed. Stored
satellites do not disappear, because insertEntries is OnConflictStrategy.REPLACE and
updateFromRemote deletes nothing first, so rows the new source does not mention survive.
The selection is a plain id list and is untouched. What degrades is the type filter for the
skipped keys, which goes stale rather than empty - the last known membership, not a claim
about the current fetch.
The key is now customSourceType ("Other"), not "All". setSatelliteTypeIds early-returns on
"All", so indexing there was always a no-op - satellites from a custom URL were never
reachable by the type filter at all. "Other" is what manual file import already uses, which
is the same meaning: satellites from a source the operator supplied. The existing test
asserted the "All" index, which means it was asserting a no-op; it now checks that no
built-in source is fetched and that the entries land somewhere the filter can see.
A second test covers the switch-off path, which must fetch every built-in source exactly as
before.
|
||
|
|
b1dce9c518 |
fix(wavelog): the upload count included QSOs sent days ago
Two smaller findings from the same audit.
Entries already confirmed by the server were added to the success total, so re-running an
upload reported "N uploaded" counting contacts that went up days ago. They are skipped and
no longer counted.
A bulk reply that stored nothing read as an acceptance. `{"imported":0}` has a success
status and would have cleared the queue. The count is checked now. Look4Sat posts one QSO
per request so this was latent, but it would have become real the moment that changed.
The count check deliberately looks only at `imported` and `adif_count`, never
`adif_errors`: v1 answers a successful upload with `adif_errors:0` beside `adif_count:1`,
and matching the wrong key would have rejected every stored QSO. A probe confirms the six
relevant shapes classify correctly.
|
||
|
|
13c5fc9584 |
fix(wavelog): the response reader lost contacts three more ways
An audit cloned the Wavelog server and read the QSO endpoints rather than the documentation. The previous commit had transcribed the wrong endpoint - `success`, `successful` and `dupe` come from create_station; the QSO path answers `created` on success and `abort` with a 400 when a record in a batch failed. Three defects followed, and the worst reintroduced the very failure the class was written to prevent. Matching the bare word "duplicate" anywhere in the body classified a hard rejection as a duplicate, which maps to success and drops the QSO from the queue. This is not hypothetical: the server's own rejection text is "Duplicate for <call>", built in Logbook_model::import, and Api_v2 puts strip_tags'd copies of those messages into validation_error bodies. Probed against real response bodies, five of ten lost the contact. Only the status field counts now, or a 409. An HTML body was accepted. A reverse proxy, a maintenance page or a PHP fatal answers 200 with HTML and no status token, so it fell through to Accepted and a misconfigured proxy ate contacts silently. A body starting with `<` is Unreadable, which keeps the QSO queued. A rejection from v2 stopped the upload. v2 refuses a legacy v1 key with 401 invalid_token - the app sends the v1 key as a Bearer token, and Api_v2::authenticate requires a wl2_ prefix - so a v1-only operator could not upload at all. That was a regression against the pre-fix code, which fell through on any non-2xx. Both v2 and the first v1 endpoint now always fall through; only the last one is final, and the failure message carries the most specific reason any endpoint gave plus all three status codes. The third was previously dropped from that message. Also: the v2 error envelope has no status key at all, so `"error":` is now recognised on its own. |
||
|
|
e8e67b74cd |
fix(sources): the custom-source switch stopped turning itself off
Two defects in how the data source settings were read. The switch reported a state nobody had chosen. `useCustomTLE` was ANDed with `tleUrl != Sources.defaultTleUrl`, so an operator who enabled custom sources and then typed the default URL by hand saw the switch flip itself off. It now reports what they set. The example.com placeholder rewrite ran on every read. A 4.4.7-era build could persist `https://example.com/tle.txt`, and the fix for that rewrote the value each time it was read - so the stored value and the returned value disagreed indefinitely and nothing ever settled it. It is now a one-time migration following migrateRCFormats, which writes the correction back and records that it has run. Not addressed here, and the reason the settings screen still misleads: a custom TLE URL replaces only the source keyed "All" and the other 27 hardcoded sources are still fetched unconditionally, so "use custom sources" actually means "my source plus 27 others". Which way that should go is a decision about intent rather than a defect to patch, and upstream fetches all of its sources unconditionally, which is where the behaviour came from. |
||
|
|
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.
|
||
|
|
fe6d0af8b7 |
fix: two regressions this rebuild introduced for non-APRS users
Both found by an auditor comparing behaviour against the released build rather than
against the intent of the change.
Prefix-first portable callsigns were rejected. CallsignEntry took the first segment -
`call.substringBefore('/')` - and required a letter and a digit in it. A portable call can
be written prefix-first, DL/W1AW or ZL/JA1ABC or OH/W1AW/MM, where the leading token is a
country prefix with no digit at all. Measured: five such forms were refused where the old
length-only check had accepted them. Any segment may now carry the callsign.
JSON cookie exports stopped working for QRZ. QrzGridParser.cookieHeader converts the JSON
array a browser extension produces into a Cookie header, and it had zero production
callers - the raw pasted text went straight into the header. The old client normalised it.
So an operator whose export had been working would see their cookie sent as a literal JSON
blob, QRZ would serve its signed-out page, and the app would tell them the cookie had
expired when it was perfectly good. A raw `k=v; k=v` paste was unaffected, which is why
this survived review.
Both are cases where a rewrite lost behaviour the old code had. Neither had a test.
|
||
|
|
3a8086eb05 |
chore: ignore Kotlin build caches outside the root module
The existing rule covered only /.kotlin/sessions/ at the repository root, so build-logic/.kotlin/ showed up as untracked after any build. |
||
|
|
e8ad51fd1b |
fix(aprs): the ack read disagreed with the login parser
sendPacket had its own idea of what a server response means: any leading `#` counted as harmless chatter. The login parser had just been taught that `# Port full` and `# Login by user not allowed` are refusals - the server announcing it is about to drop us - so the two paths reached opposite conclusions about the same line, and the send path was the optimistic one. Probed across the responses captured from live servers, they disagreed on four of six. classifyAck now shares AprsLogin's judgement. A greeting or keepalive still counts as sent, because APRS-IS does not acknowledge position reports and silence is the normal outcome; anything the server says that is not harmless fails the report. A late login verdict arriving here also counts as sent, since it is not about this packet and the login state already carries it. Two socket tests cover both directions: a `# Port full` after the write fails the report, and a real captured keepalive after the write does not. |
||
|
|
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.
|
||
|
|
271488a43e |
fix(aprs): the notification showed the previous cycle's verdict
updateNotification was called from onReport but read lastState, which onState only sets afterwards - so the persistent notification was rebuilt from the previous report's outcome. It now derives the state from the report in hand. This matters most where it is least visible: an alarm-driven report at 03:00 posts a Toast nobody sees, leaving the notification as the only surface, and that surface was showing a stale verdict. |
||
|
|
321cd8f2fa |
fix(aprs): the login line was malformed, and the refusal was invisible
An auditor ran the plan's own release gate against live APRS-IS servers. It failed at
the login step, on every server tried:
sent: user N0CALL pass -1 vers Look4Sat-4.5.4
got: # Invalid login: software name and version are not separated by a space
Reproduced on euro.aprs2.net and noam.aprs2.net, aprsc 2.1.21. `vers` takes TWO tokens,
a software name and a version. An earlier commit read the rule "softwarename must not
contain a space" as "the field must be one token" and hyphenated the space between them
- and the unit test asserted that as correct, so the mistake was frozen in place.
Worse than the malformed line was what happened next. `# Invalid login:` is a comment
but not a logresp, so parse skipped it as keepalive chatter; the login then timed out
into Unknown, which is deliberately treated as "may be working"; so `ok = sent &&
!refused` was true and the operator was shown "APRS: report sent OK" for a login the
server had refused. That is v4.6.0's defining defect - every send reported successful
regardless of outcome - still live on the exact path every operator takes. The rebuild
narrowed it rather than closing it.
Both halves are fixed: the name and version stay separate tokens with whitespace
collapsed within each, and a refusal comment is classified as a refusal before the
logresp test. A socket test now replays the server's actual bytes.
Three smaller things from the same review:
The foreground service type goes back to dataSync. The previous commit chose location
to escape dataSync's six-hour cap, but a location-typed service is refused outright
unless a location runtime permission has already been granted, and the settings card
requests only notifications - so it would have failed silently for anyone who declined
location access. The cap that prompted the switch applies only when targetSdk is 35 or
higher, which this project does not declare. A test now reads the manifest and the
service source and fails if they disagree, which is the only way this class of defect
is visible from a JVM test.
The version string in the login was 4.5.4 while the app was 4.6.0. Now split into name
and version and corrected, though it is still hardcoded - core:data has no BuildConfig,
so passing it in properly is a separate change.
The passcode hint said "empty = auto-computed from callsign" in all five locales. The
app stopped doing that two commits ago; it now connects receive-only, and the hint says
so. It was the first thing an operator read next to the field, promising the behaviour
that was deliberately removed.
Not fixed, and known: the notification body is rebuilt from the previous cycle's state
so it can show a stale verdict, a deliberate receive-only choice is still styled as an
error, and no last-success timestamp exists - so an operator still cannot establish
whether their station has ever reached the network.
|
||
|
|
0208a577c4 |
fix(aprs): do not tell a mistyped passcode it is in receive-only mode
The previous commit derived "wants receive-only" from AprsPasscode.canTransmit, which is a boolean over four cases. Both a deliberate -1 and a mistyped passcode return false, so fixing the mis-diagnosis in one direction introduced it in the other: measured against the shipped algorithm, three entries - a passcode off by one digit, an arbitrary number, and a non-numeric entry - were all told they were connected in receive-only mode, when what they needed to hear was that the passcode does not match the callsign. classify already distinguishes these; only its ReceiveOnly case counts as a deliberate choice. A test now pins the distinction, including the fact that all four entries are equally unable to transmit - which is exactly why the boolean was not enough. Found by probe before review, not by the suite, which had no test for the reporter's use of this and still does not. |
||
|
|
eb66a77cae |
refactor(qrz): move the grid lookup out of the composable
LogTab read the QRZ cookie straight out of SharedPreferences through LocalContext,
inside composition, on every submission - disk access in a composable, around the
repository layer, with the client referenced by fully-qualified name inline. And it
did `if (grid != null)`, so a lookup that failed for any reason left the QSO without
a grid and told the operator nothing.
IQrzGridLookup lives in core:domain, QrzGridLookup in core:data owns the cookie read,
and the view model exposes lookupGrid. The composable now takes a callback and handles
each outcome: a locator is attached, no locator on file passes quietly because nothing
is wrong, an expired cookie says to paste a fresh one, and an unreachable QRZ says so.
That is what the four-outcome QrzGrid type from
|
||
|
|
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.v4.6.0 |
||
|
|
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. |
||
|
|
07df22d287 |
build: bump to 4.5.9 (versionCode 466)
The v4.5.8 tag was already published against the version-bump commit alone, so the twelve commits of actual work had no release to land in - moving the tag made the CI job fail on an existing release rather than replacing it. A new version number is the right way round, per the project's own rule against re-cutting a tag. Release notes are unchanged: the five locales already describe exactly what these commits contain.v4.5.9 |
||
|
|
ac45ed0efb |
docs: describe the waterfall, transcript and screen-reader work in 4.5.8
Three lines the release notes were missing, across the five locales that carry them: the waterfall now spanning the whole audio band, the transcript following new text, and the CW waterfall and AMSAT day cells being readable by a screen reader.v4.5.8 |
||
|
|
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. |