3810e76ea2a0f0889d14f2596bf76157e90d82ab
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
50a644f417 |
build: bump to 4.5.8 (versionCode 465)
AMSAT status page: 12 two-hour stripes per day, UTC calendar days, two distinct greys for no-report vs no-data, and a data-coverage marker from the summary endpoint that flags satellites crowded out of the global 500-record pull. |
||
|
|
7a2bbb8701 |
chore(amsat): update User-Agent to match the current release version
All three AMSAT endpoint calls still declared Look4Sat/4.5.5 while the project has been at 4.5.7 for several releases. The API does not appear to validate the header, but it misrepresents the client version in server logs. |
||
|
|
018a3afd2b |
fix(amsat): mark satellites whose reports were crowded out of the global pull
The API caps at 500 records regardless of the hours requested. With 88 catalog satellites, eight of them more active than 50 reports per 72 hours, quieter satellites get crowded out. Measured live: the global pull returned 500 reports covering 36 satellites, while the summary endpoint reported 743 reports across 38 satellites. 26 of 38 satellites had incomplete data, and two (PO-101_[FM] and TEVEL2-6_[FM]) had zero reports in the global pull despite having reports in the summary. The summary endpoint (api/v1/summary.php) returns per-satellite report counts in one request, so the fix adds one extra call rather than the 88-request alternative of per-satellite pulls. A satellite whose global pull is incomplete gets a subdued "68 / 116" marker next to its name, telling the operator the page knows there is more data it could not fetch. The marker is silent when the summary is unavailable or the counts match, so the feature degrades gracefully. The earlier no-data grey (0xFFE8E8E8) already prevented the worst case: slots crowded out of the global pull were marked as "we never looked" rather than claiming "nobody reported". The marker now closes the remaining gap: the page can honestly say "we know there are 116 reports for this satellite but we could only show you 68 of them". Also fixed a subagent mutation-testing residue: the coverage floor had been moved from global (reports.minOfOrNull) to per-satellite (satReports.minOfOrNull) and left in the tree. One test caught it (coverage is judged from all reports, not one satellite's), proving the test has teeth. Adds getAmSatSummary to IRemoteSource and RemoteSource, parseSummary to AmSatRepository, and summaryCount to SatStatus. All eight test-file implementations of IRemoteSource were updated for the new method. |
||
|
|
3612e662e7 |
fix(amsat): distinguish slots we have no data for from slots nobody reported
Grey meant two different things. The API caps at 500 records however many hours are requested: measured against the live endpoint, a 72-hour request returned 500 reports spanning only 49 hours, so the oldest 9.5 hours of the third day had no data at all. Those cells were painted the same grey as "nobody reported", which claimed knowledge we did not have - 352 of 3168 cells on a real page, a third of the third day's column. Slots entirely older than the earliest report in the response now use a lighter grey. Coverage is judged from all reports rather than per satellite: a quiet satellite has no reports of its own, but the slots it shares with the rest of the response were still covered, so it must read as "not heard" rather than "unknown". The two greys are now in the legend, which previously listed only the four active states. That matters more than it sounds: on the live page 81% of cells are "nobody reported" and 11% are outside our data, so a user looking at a mostly-grey row had no way to tell a dead satellite from a gap in what we fetched. The legend chips use a solid dot, so the two greys stay distinguishable despite the 25% alpha background. Strings added to all nine locales. Three tests cover it: a day entirely before the data starts, a day straddling the boundary, and an empty response marking nothing as covered. |
||
|
|
79215e7623 |
test(amsat): pin the slot arithmetic against hostile dates and boundaries
The UTC alignment landed with tests covering the normal cases; these cover the ones that would have made it wrong quietly. Midnight arithmetic is exercised at exactly midnight, a second either side, every leap-day combination around 2028-02-29, both year boundaries, and the first of all twelve months in a leap and a non-leap year. Since the code steps back a day by subtracting 86400 rather than using Calendar arithmetic, those dates are where a naive step would drift. Every slot edge across all three days is probed at the boundary and one second either side, asserting each instant occupies exactly one cell and that the cell's day matches the report's UTC date - `until` versus `..` on the slot range is a one-character mistake that would double-count edge reports. Also pinned: the shared Calendar is not re-read after the labels loop (it points at the oldest day by then), repeated calls are idempotent, duplicate catalogue names produce duplicate rows carrying the same report, reports for names absent from the catalogue are dropped, and the build stays linear in reports rather than quadratic. Adds a comment recording why reusing that Calendar is safe: each pass assigns timeInMillis outright instead of adjusting fields. 235 tests pass. |
||
|
|
8f646d76f9 |
fix(amsat): align the status grid to UTC calendar days, one stripe per slot
Two defects in our own AMSAT page, both found by auditing the change that exposed them. The day columns claimed to be dates but were a rolling window anchored on the fetch time. Fetching at 06:07 UTC put 17.9 hours of yesterday into the cell labelled today; measured against a live amsat.org page of 1021 reports, 73% of them landed in the wrong day column and none matched the official cell. Days are now UTC calendar days and slots are fixed UTC bands - slot 0 is 22:00-24:00, slot 11 is 00:00-02:00 - so a cell's contents match its label whenever it is fetched. The day cell painted one colour for the whole day, taken from the first slot that had a report, so a satellite that worked all morning and failed all afternoon looked identical to one that worked once - the reported symptom. It now draws one stripe per two-hour slot in the same 64x28 dp footprint. Twelve stripes are about 5 dp each, roughly 15 px at 440 dpi, and runs of the same status merge visually, so a day reads as a few blocks rather than twelve lines. Every density from ldpi up allocates all twelve without dropping one, and the 4 dp corner radius leaves 95% of the end stripes visible. The report count text is gone; tapping a day still lists every report from it, which was already the richer view. buildStatuses and ApiReport are internal rather than private so the grid contract can be tested. AmSatSlotBuildTest drives it directly: fetchStatus cannot be tested here because the parsing around it uses Android's JSONObject, a JVM stub that makes every call return null - eight of nine tests written against it failed for that reason before being rewritten. Also corrects three KDoc comments claiming 5 days when the code builds 3, and records in AGENTS.md that the status colours are ARGB literals in core:data, duplicated in MainTheme, which anything needing themeable or colour-blind-safe colours has to fix first. |
||
|
|
ea125d7db4 |
refactor(cw): move the shift decision into core:domain so tests can reach it
Mutation testing found the decision rule was effectively untested. Four defects injected into it - removing the silence guard, comparing shifts instead of tones, never setting the hysteresis anchor, and inverting the comparison - all left the entire suite green. The rule lived inside CwDeepDecoder, which needs an Android Context and a loaded ONNX session, so tests could only restate it, and a restated rule cannot fail when the real one is wrong. CwShiftDecider now holds the rule as a pure class that both the decoder and the tests drive. Its outcome is reported as an enum so the decoder's logging is a presentation concern rather than a second copy of the logic. CwShiftDeciderTest targets each of the four surviving mutants directly. MIN_PROMINENCE lowered from 8.0 to 4.5. Raising it to 8.0 last round overshot: measured on 400 ms windows of keyed CW in noise, a comfortably copyable signal reaches only 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB, so 8.0 silently refused to shift weak out-of-window signals - the exact failure the feature exists to prevent. Pure noise peaks at 2.2-3.4, so 4.5 keeps zero false positives across 40 noise windows while retaining the weak end. A false tone is worse than a missed one: it moves a good signal out of range, whereas a miss leaves the audio alone until a stronger window arrives. Windows dominated by keying gaps measure 2.4 and are indistinguishable from noise at any threshold; those are skipped. Test files reorganised to match: the decision rule is covered by CwShiftDeciderTest against real code, signal-level properties by CwToneShiftSignalTest, and the restated-logic file it replaces is gone. 80 CW tests pass, golden vectors included. |
||
|
|
fdb44af9ff |
fix(cw): stop silence and edge estimates from defeating the tone shift
Two audit findings, both measured, both able to silently disable the feature. A detection window landing in a keying gap used to collapse an established shift to zero. CW is keyed, so gaps are normal: over 180 s of keyed audio at 1400 Hz, 11 of 90 detections saw no tone, and each one wiped the decode window and left the next ~2 s buffered unshifted - outside the model's range and therefore invisible to it. Absence of a tone is now absence of evidence and the active shift is retained. Hysteresis moved from shift space to tone space, anchored on the pitch that produced the active shift. The old rule required a non-zero previous shift and a needed shift, so it lapsed exactly where the jump is largest: at the 1200 Hz edge one 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large shift). Measured 35 window drops in 60 detections for a 1205 Hz tone, and 10 in 10 for a bare one-bin hop. A shift of zero is a real state, not the absence of one. Slow drift still catches up, since the anchor bounds staleness at the margin rather than letting it accumulate. Detection prominence raised from 3.0 to 8.0. Pure noise peaks at 2.0-3.3 times its own spectral mean, so 3.0 admitted roughly one noise window in five as a "tone" - and a false tone is worse than none, since it moves a good signal out of range. Keyed CW measures 47-51, so the gap is wide. Shifted output is clamped to the +/-1.0 range the spectrogram assumes. The Hilbert kernel's L1 gain is 2.51, so mixing overshoots: a full-scale square wave measured 2.35 and even a plain sine 1.05. The detection pool moved to core:domain as CwDetectionPool so its ring behaviour can be tested directly - mutation testing showed the previous private implementation was unreachable from any test. Its chronological-order contract now has 11 tests driving the real class. Removed the write-only detectedToneHz field. 74 CW tests pass, golden vectors included. |
||
|
|
f6db55b35c |
perf(cw): pool detection samples in a ring buffer
The detection pool shifted its whole array down one slot per incoming sample once full. Detection is throttled to 2 s but the pool fills in 400 ms, so for the remaining 1.6 s of every cycle each chunk arrived at a full buffer: 320 copies of 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio, all on the capture thread. Writing to a ring index is O(1) per sample. Draining walks the ring from the oldest slot so the analyser still receives the most recent audio in chronological order - a test feeds a ramp past capacity and asserts the exact contents, since getting the wrap wrong would splice the waveform and corrupt every estimate silently. |
||
|
|
1b8f8c46f6 |
fix(cw): drop stale audio on a tone-shift change, and damp detector jitter
Follow-up to the tone-shift feature, closing gaps the audits surfaced. Toggling the setting, or the detector settling on a materially different shift, now discards the buffered audio. Without it the 20 s decode window kept feeding the model samples moved by the old amount for up to 20 s after the user acted, and updateSignalMetrics corrected the pitch readout by an offset that no longer matched the window. Text already committed to the history is kept: it was correct when it was decoded. The previous-state flag is nullable and seeded from the current setting on the first chunk, so a decoder created while the setting is already on does not report a spurious change and wipe an empty buffer. reset() clears it back to null for the same reason. Two decoders can be live at once (the CW screen and the Radar panel) and each tracks its own state. Re-shifting is now gated by a 40 Hz hysteresis. Detection resolution is 12.5 Hz and a real tone wanders, so without it an estimate hopping between adjacent scan bins would drop the window every 2 s - costing far more decoding context than re-centring gains. 40 Hz absorbs two bins of jitter while still following a genuine retune; a test pins both halves of that trade-off. |
||
|
|
9798107d37 |
feat(cw): optionally shift out-of-window CW tones into the model's range
DeepCW only analyses 400-1200 Hz - its input tensor is 65 bins wide, fixed at training time - so a CW note outside that range is invisible to the decoder. This adds an opt-in preprocessing step that moves such a tone to 800 Hz, the window centre, extending the usable pitch range without touching the model. Single-sideband mixing via a 63-tap Hilbert transformer. Plain real mixing was measured and rejected: shifting 1500 Hz to 800 Hz left a fold-back image at 1000 Hz at 0.999 of the wanted amplitude, inside the window. Zero-stuff upsampling plus lowpass handled downward shifts but left a 0.996 image when shifting 300 Hz upward. The Hilbert approach measures clean on nine tones from 150 to 1550 Hz: one peak at the target, nothing above 0.3 relative amplitude. In-window energy for a 1500 Hz input goes from 6.8% to 94.6%. Only out-of-range audio is processed. A tone already inside 400-1200 Hz is returned untouched (same array instance, no copy), and with the setting off the audio path is exactly what it was before. CwToneShifter.Streaming carries the Hilbert filter history and mixer phase across capture chunks. Shifting each chunk in isolation left 62 of every 320 samples convolving against zeros, inflating envelope ripple to 8.7x the whole-buffer baseline. A residual difference in the last ~3 samples of each chunk is causal and documented: those output samples would need input that has not been captured yet. Detection pools chunks rather than gating on one. A capture chunk is 4410 samples at 44.1 kHz but only 320 after resampling to 3200 Hz, so requiring 1280 samples in a single chunk would have made the feature dead code - the two independent audits both found this before it shipped. Detection now runs on a pooled 0.4 s window, at most every 2 s. Toggling the setting or a change in the detected shift drops the buffered audio: the 20 s window would otherwise keep decoding samples moved by the old amount, and the pitch readout could only be correct for one of them. The readout itself subtracts the active shift so it shows the pitch on the radio, not the shifted one. Settings: OtherSettings.cwToneShiftEnabled, off by default, persisted and read back in SettingsRepo, toggled from the Other card in Settings with a help line explaining the 400-1200 Hz limit. Strings added to all nine locales. The decoder reads the flag per chunk, so the toggle applies without restarting capture. Debug: the enabled-state transition, each detection verdict (no tone / inside window / shifting by N Hz), and every shift change are logged, with the noisy paths throttled to the 2 s detection interval. CwProbe records shift changes only, keeping well inside its 1 MiB cap. Tests: 8 shifter tests (detection sweep, noise rejection, pass-through identity, image-free shifting across 8 tones, end-to-end spectrogram energy), 8 streaming tests (chunk continuity, history retention, reset semantics, chunk sizes above and below the history window), and 6 gate tests including a regression guard that a 320-sample chunk must be able to reach the detection threshold. All 53 CW tests pass, golden vectors included. |
||
|
|
e3d7238721 |
chore(release): bump build number to 464 for the v4.5.7 rebuild
Version name stays 4.5.7 so the release is overwritten in place; the build number must increase for Android to accept the update. This rebuild carries the upstream rt-bishop merge (18 commits) on top of the 30 audit fixes. |
||
|
|
40ba3fecdf |
chore(amsat): remove the HTML scraping path superseded by the JSON API
The merged upstream AMSAT implementation fetches status data from AMSAT's JSON endpoints (getAmSatCatalog / getAmSatReports), so the fork's HTML scraping path no longer has a caller: - core/data/.../source/AmSatParser.kt (136 lines): parsed the amsat.org status table, deriving state from the page's inline colour codes. - IRemoteSource.getStatusHtml() plus its RemoteSource implementation and the DatabaseRepoTest fake override. Verified zero references repo-wide before removing, and again afterwards. Request / CancellationException imports in RemoteSource remain in use by the other fetchers. compileReleaseKotlin plus core:domain / core:data / feature:map / feature:roaming unit tests stay green. |
||
|
|
57d6f9d7ed |
chore(merge): drop dead leftovers from the upstream merge
Post-merge audit found code the merge left unreferenced: - SettingsRepo: keySatelliteUrls / keyTransceiversUrls / separatorUrl were upstream's list-shaped data-source keys. The merge kept the fork's map-shaped DataSourcesSettings, so these three had a definition and zero uses. - feature/status/res/drawable/ic_refresh.xml: SatStatusScreen imports core.presentation.R only, so its R.drawable.ic_refresh resolves to the core copy; the feature-local copy was never addressable. It was the only file under feature/status/src/main/res, so the directory goes with it. Verified zero references with a repo-wide grep before removing each symbol. compileReleaseKotlin plus core:domain / core:data / feature:map / feature:roaming unit tests stay green. |
||
|
|
a654735337 |
merge: upstream rt-bishop main (18 commits) with conflict resolution
Merges rt-bishop/Look4Sat main (
|
||
|
|
c547a126ee |
chore(release): bump build number to 463 for the v4.5.7 rebuild
Version name stays 4.5.7 so the existing release can be overwritten in place; the build number must still increase for Android to accept the update. |
||
|
|
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. |
||
|
|
b749733289 |
fix(audio): keep cleanup from masking start failures or skipping release
AudioCapture.audioFlow's finally ran recorder.stop() then release() naked. If startRecording() threw - permission revoked mid-request, audio device error - the finally's stop() threw IllegalStateException (stop on an uninitialized recorder), which replaced the original error AND skipped release(), leaking the AudioRecord. The flow's caller saw "recorder failure" instead of "no permission" and the native recorder was never freed. Wrapping each cleanup step in runCatching preserves the original exception while guaranteeing release() runs. Probe: a start failure previously surfaced as RuntimeError with released=false; it now surfaces as the original PermissionError with released=true. |
||
|
|
b4cfb16159 |
fix(radio): don't record frequencies the radio rejected
RadioTrackingService wrote lastSetTxFreq/lastSetRxFreq unconditionally after calling setFrequency, ignoring its Boolean result. When the radio rejected the frequency - the FT-817 CAT limit added in the previous commit, a dropped Bluetooth link, or a failed ack - the remembered value no longer matched what the radio actually holds. The manual-tuning detector then saw a phantom dial change on the next read-back (read is the real frequency, lastSet is the one that never landed) and entered tuning mode: it locked onto the wrong base and kept rewriting the radio. Probe of the state machine: before the fix, a rejected 1.26 GHz write against a radio sitting on 145.5 MHz left lastSet at 1.26 GHz, so every subsequent cycle read a 1.1 GHz gap and flagged manual tuning forever. After the fix the lastSet is only updated on success, so the detector sees no change and the loop keeps applying the next valid frequency. Same fix applied to the split IC-705 path (setWorkingFrequency/setTxVfoFrequency). |
||
|
|
7319cf8f5b |
fix(coroutines): propagate cancellation in remote source and status view model
RemoteSource's five suspend functions and SatStatusViewModel's two fetch paths caught bare Exception, which also swallows CancellationException. When the owning scope is cancelled (screen leaves, app closes) a cancelled network call was reported as a null/error result instead of stopping: the caller kept running until the next suspension point, and SatStatusViewModel wrote state updates into an already-cancelled scope. Correct coroutine hygiene is to let cancellation propagate - rethrow CancellationException before the generic catch. Verified semantically with an asyncio probe: a swallowed cancel returns a normal-looking null and the caller continues; a propagated cancel stops the coroutine immediately. No behaviour change for real errors; :core:data and :feature:status compile. |
||
|
|
baf2a7022d |
fix(aprs): hold the client lock across the response read in sendPacket
sendPacket wrote the packet under the lock but read the server response outside it. disconnect() - called concurrently from stop() and from the reconnect path in AprsReporter.reportOnce's catch - nulls and closes writer/reader/socket under the same lock, so the lock-free read raced with it. A probe interleaving 5,000 sends with repeated disconnects produced a mix of 744 OK and 4,256 exception results: the response read hit a just-closed socket and the swallowing runCatching reported Pair(true,"OK") for a packet that may never have left, or read through a stale reference. The tracker believed the beacon was heard while APRS-IS never received it. Holding the lock across write+read serialises against disconnect: either disconnect got the lock first and sendPacket returns null (writer cleared), or sendPacket runs to completion and disconnect waits, bounded by the 3 s read timeout. Re-ran the interleaving probe: 3,000 sends, zero inconsistent results. Compiles and :core:data tests stay green. |
||
|
|
b95a86c97f |
fix(radio): reject FT-817 frequencies the CAT protocol cannot express
The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution, so the largest representable value is 999,999,990 Hz. encodeFrequencyBcd is exact below that, but for anything above it the %08d formatting silently drops the leading digit: 1,267.6 MHz encodes as 126.76 MHz. Verified against the release bytecode - 1,000,000,000 Hz -> [10 00 00 00] -> 100,000,000 Hz, ten times lower - and the SatNOGS catalogue has 17 transmitters with uplinks over 1 GHz (QO-100 at 2400.05 MHz, several 23 cm links), so the wrong value is reachable via RadioTrackingService when an FT-817 is mis-configured as the TX radio. The tracking loop's read-back then locks onto the wrong band with no warning. Reject out-of-range frequencies at setFrequency with a log and return false instead of sending a corrupted command. In-range values are unaffected (probe: 7.074/145.5/435.1 MHz and both 999,999,98x/99x MHz round-trip exactly; every value above the limit is refused before the encoder runs). |
||
|
|
ed1fe66892 |
fix(geo): replace the clipLon while-loop with a modulo reduction
clipLon reduced longitudes by looping += 360 until in range. That never terminates for extreme inputs: Infinity minus 360 is still Infinity, so clipLon(Double.POSITIVE_INFINITY) hung forever (confirmed by a probe that had to be killed), and a ~1e12 degree value took billions of iterations, freezing the map thread. NaN came back as NaN either way. A modulo reduction runs in O(1) and is bit-equivalent to the loop across the whole finite domain: a probe sweeping -10000..10000 at 0.01 degree steps (2 million points) plus the boundary values -180/-179.999/0/179.999/180/180.001/ ±360/±540 shows zero mismatches. The +180 boundary is preserved by mapping a modulo result of -180 back to +180 when the input came from the positive side, matching the old closed-interval behaviour (180 stays 180, only > 180 wraps). Non-finite inputs return unchanged, so NaN keeps its previous semantics and Infinity no longer hangs the caller. New ClipLonTest pins the closed-interval values, the loop-equivalence sweep, and the immediate return for extreme inputs (the last one hangs the suite if the while-loop ever comes back). |
||
|
|
1e24673632 |
fix(network): close sockets on setup failure and on write failure
Two leaks in NetworkReporter, the same family as the Bluetooth/APRS/radio socket leaks fixed earlier: 1. ensureRotatorConnected/ensureFrequencyConnected assigned the field directly, so a channel that opened but threw during the rest of setup was never closed and remained referenced. Use a local `opened` and close it in the catch, matching the pattern used in AprsIsClient/Ic705Controller/ Ft817Controller/BluetoothReporter. 2. write() only flipped connected=false on failure. The broken channel stayed in the field, the next ensure* reconnected and overwrote it, and the old channel was never closed. Now a failed write closes the channel and nulls the field. The null check is identity-based (socket === field) so a stale reference from a concurrent report can never close a newer channel. State-machine probe: normal write keeps the socket, failed write closes and nulls, next report reconnects fresh, and passing a stale reference does not close the newer socket. |
||
|
|
ed0f5678b3 |
fix(cw): cap the crash-probe log file at 1 MiB
CwProbe.step() appended one line per call with no size limit, rotation or cleanup, and it runs on every build: CwDeepDecoder is the only ICwDecoder implementation and writes infer_begin + infer_done every 1.5 s inference tick. Measured against the actual line format that is ~170 KB/hour, ~4 MB/day of unbounded growth in files/probe_cw.txt while CW audio is monitored, plus synchronous disk I/O on every inference. Truncate when the file exceeds 1 MiB instead of deleting, so the probe keeps the most recent diagnostics (the reason it exists: the last lines show where a flash-crash died). Simulated 10 h of continuous use: 2.7 MB written in total, file stays bounded around ~640 KB; previously it would have kept all 2.7 MB and grown without limit. |
||
|
|
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. |
||
|
|
af96fe1cf0 |
test(predict): pin the Moon hour angle to a reduced range
MapViewModel converts MoonPosition.gha into the sub-lunar longitude with `if (gha <= 180) -gha else 360 - gha`, which is only a valid longitude while gha stays inside 0..360. Nothing enforced that: getMoonPosition relies on `while (teg > 360) teg -= 360` reducing GMST before the single `if (gha < 0) gha += 360` correction, and the raw GMST polynomial is about 3.5e6 degrees today, so losing that one line silently pushes the Moon marker millions of degrees off the map instead of failing loudly. Sweep a synodic month at 37-minute steps (1,167 samples) asserting gha stays in 0..360 and the derived longitude in -180..180, plus a check that the hour angle advances 10-20 degrees per hour. Verified the test has teeth: deleting the teg reduction makes both cases fail; with the current implementation :core:domain:test is green. No production change - the existing code is correct. |
||
|
|
27d41eb2ee |
fix(amsat): render only the days the API actually returned
The status grid always drew six day columns, but the AMSAT reports endpoint cannot supply six days for the full catalogue. Measured against the live API: limit=500 -> meta.count=500, covers 4 days (Aug 11..Aug 14) limit=1000 -> meta.count=500, same 4 days (server clamps the limit) hours=336 -> meta.count=500, same 4 days (window size does not help) before/offset/page -> ignored, same 500 newest rows With ~90 catalogued satellites the 500 newest rows only reach about four days back, so the two oldest columns were guaranteed to be uniformly gray. Gray means "no report" in this UI, so the screen asserted nobody reported those days when the truth was that the data was never fetched. Derive the column count from the oldest report actually received, capped at six. On live data that yields four columns labelled Aug 14..Aug 11 instead of six with Aug 10 and Aug 9 blank. The UI already renders whatever days it is given, so no UI change is needed. Also name the request constants and record what was measured about the endpoint, so the 500 is not mistaken for an arbitrary choice that can simply be raised. Note for a future change: the per-satellite form of the endpoint (reports.php?name=...) is not affected by the cap - sampling eight satellites returned 926 rows spanning eight days, i.e. full six-day coverage - but it needs one request per satellite (~0.8 s each, ~68 s for the whole catalogue), so switching to it is a deliberate trade-off rather than a bug fix. :core:data:compileReleaseKotlin, :core:data:testDebugUnitTest and :core:domain:test all BUILD SUCCESSFUL. |
||
|
|
2bac6655f3 |
fix(amsat): align status slots to their UTC calendar-day labels
AmSatRepository labelled columns by calendar date but filled them by slicing a
rolling 72-slot window ending at fetch time. The two timelines coincide only
near 23:59 UTC. At common fetch times the status grid lied about dates:
UTC 00:00: 72 / 72 slots under the wrong label
"today" column contained all of yesterday
UTC 12:00: 36 / 72 wrong; every column straddled two dates
UTC 13:37: 30 / 72 wrong
UTC 23:59: 0 / 72 wrong (the accidental alignment case)
Anchor the six columns on UTC midnight instead. Every SatDay now covers exactly
[day 00:00, next day 00:00), split into twelve 2-hour slots newest-first so the
UI's existing first-non-gray lookup still chooses the latest daily report.
A standalone Java probe porting the old arithmetic reproduced the 72/72,
36/72 and 30/72 mismatches. Porting the new formula gives 0/72 mismatches at
00:00, 12:00, 13:37 and 23:59 UTC.
Also restore core:data's unit-test compilation. DatabaseRepoTest's fakes were
stale after IRemoteSource gained AMSAT methods and ISettingsRepo's zero-arg GPS
setter became suspend; the whole data test suite previously could not compile,
so data-layer regressions were untestable. Updated the fake members and verified
:core:data:testDebugUnitTest plus :core:data:compileReleaseKotlin BUILD
SUCCESSFUL. The product code does not use org.json in JVM tests because Android
org.json stubs throw there, so the date math remains verified by the standalone
same-JVM probe rather than a misleading mocked parser test.
|
||
|
|
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. |
||
|
|
d5230b3bc6 |
fix(qth): correct Maidenhead boundaries and longitude wrapping
A full-domain round-trip probe found three related boundary bugs.
1. Exact positive limits wrapped the square/subsquare terms to zero
positionToQth clamped only the A-R field index. At +90 latitude / +180
longitude the field saturated at R, but all later terms used modulo and wrapped
to square 0 / subsquare a:
(90, 180) -> RR00aa00 -> (80.002083, 160.004167)
error: -9.998 deg latitude, -19.996 deg longitude
The existing test incorrectly asserted RR00aa00 and had therefore fossilised
the defect. Clamp shifted coordinates just inside the half-open upper bound so
the limits land in the final cell RR99xx99.
2. isValidPosition allowed longitude through +360
Maidenhead covers -180..180, but 181..360 was accepted and produced plausible
locators that decoded 20-200 degrees away:
lon 181 -> decoded 161.004167 (error -19.996)
lon 270 -> decoded 170.004167 (error -99.996)
lon 360 -> decoded 160.004167 (error -199.996)
Restrict the converter contract to -180..180.
3. Locator validation allowed S-X as field letters
The first pair has 18 fields A-R, while only the later subsquare pairs use
A-X. The shared [A-X]{2} regex accepted SS00aa / XX99xx and decoded them past
the poles (up to lat 149.98, lon 299.96). Use A-R for the field pair.
The SettingsRepo caller had a separate wrapping bug that masked part of this:
it mapped longitude>180 by subtracting 180 (270 -> +90, wrong hemisphere)
instead of modulo 360 (270 -> -90). Fix that at the writer too.
Verification:
- standalone JVM sweep: 519,841 points, old code had 1,441 large-error points
with max drift 9.997917 deg lat / 19.995833 deg lon
- new Kotlin regression sweep requires every 8-char round trip <=0.01 deg
- QthConverterTest BUILD SUCCESSFUL
- full :core:domain:test + :core:data:compileReleaseKotlin BUILD SUCCESSFUL
|
||
|
|
7d7abeb31e |
fix(aprs): prevent duplicate reporters on repeated service starts
Every ACTION_START - and every null intent delivered by START_STICKY - called startReporting(), which always constructed a new AprsReporter and overwrote the field without stopping the old one. AprsReporter owns an independent SupervisorJob + periodic while(isActive) loop, so every overwritten instance kept reporting forever and could no longer be reached by ACTION_STOP. Simulation: five ACTION_START events: 5 running reporters, 4 leaked -> fixed: 1 / 0 START + 3 sticky restarts: 4 running, 3 leaked -> fixed: 1 / 0 mixed real sequence: 4 running, 3 leaked -> fixed: 1 / 0 At the default 10-minute interval, four leaked reporters send 24 duplicate position packets per hour and open 24 needless connections; this also amplifies the connect-time socket leak fixed earlier. startReporting now returns when the current reporter is active. Config changes remain correct: AprsCard explicitly sends ACTION_STOP before ACTION_START, so the old reporter is stopped and nulled before the new configuration starts. :app:compileReleaseKotlin 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. |
||
|
|
5f1f90067f |
fix(aprs): clamp altitude and wrap course to keep fixed-width fields
Both extensions are fixed-width decimal fields, but neither value was range
checked before formatting:
formatAltitude(-50.0) -> /A=-00164 ('-' eats a digit slot)
formatCourseSpeed(_, 360f) -> /360/... (course must be 000..359)
formatCourseSpeed(_, -1f) -> /-01/... (widens the field)
A negative altitude is reachable from a below-sea-level position or a poor GPS
fix, and the malformed extension corrupts everything after it in the comment
field. Altitude now clamps to 0..999999, course wraps modulo 360, and speed
clamps to three digits.
Found by the same locale probe that produced the previous commit.
:core:domain:test BUILD SUCCESSFUL.
|
||
|
|
f4e188261d |
fix(aprs): format packets with Locale.ROOT so they stay ASCII
All nine String.format calls in AprsPacket used the JVM default locale. On a device set to Arabic, Persian or Bengali the digit shapes come out as Eastern Arabic / Bengali numerals, so every position report was malformed: ar_EG lat=٣٩٥٤.٢٥N lon=١١٦٢٤.٤٤E alt=/A=٠٠٠٣٢٨ fa_IR lat=۳۹۵۴.۲۵N lon=۱۱۶۲۴.۴۴E alt=/A=۰۰۰۳۲۸ bn_BD lat=৩৯৫৪.২৫N lon=১১৬২৪.৪৪E alt=/A=০০০৩২৮ APRS-IS is an ASCII line protocol, so aprsc rejects these packets outright: APRS reporting simply never worked for those users, with no clear error. A locale using ',' as the decimal separator would corrupt the range filter the same way. Affected: getDMS position encoding (all five ambiguity branches), the DDMM.MM/DDDMM.MM assembly, formatAltitude, formatCourseSpeed and formatRangeFilter. TDD proof: without Locale.ROOT: 4 of 4 AprsPacketLocaleTest cases FAILED with Locale.ROOT: BUILD SUCCESSFUL, full :core:domain:test green Ruled out by the same probe (no change made): getDMS degree/minute split matches an independent DDMM.MM reference implementation over 1,800,000 sampled latitudes with zero divergence; the passcode loop dropping the trailing NUL on even-length callsigns is the standard algorithm's behaviour. |
||
|
|
e1233dceaa |
fix(wavelog): preserve six-character grids in v1 ADIF upload
WaveLog v1 truncated every grid to four characters with gridsquare.take(4), while v2 sent the same grid at full precision. QRZ backfill provides six-character locators (e.g. OM89ab / FN31pr), so the v1 path - the one used by the user's server in practice - degraded position precision from roughly 4.6 km to around 100 km and stored different data depending on which API version answered. Send the complete grid through v1 as well. The ADIF length field is already computed from the actual value, so six/eight-character locators need no special handling. TDD proof: old take(4): v1_adif_preservesSixCharacterGrid FAILED fixed: WaveLogApiPayloadTest BUILD SUCCESSFUL full suite: :core:domain:test BUILD SUCCESSFUL |
||
|
|
09e728fa1b |
fix(data): close sockets when connect() fails after the handshake
All five connect paths opened a socket, completed the TCP/RFCOMM handshake,
and only afterwards stored it in a field. Any exception in between leaked the
socket: the catch block just flipped a boolean, and disconnect() can only close
what already reached the fields.
Leak windows (statements that can throw after the handshake succeeded):
AprsIsClient.connect soTimeout / tcpNoDelay / getOutputStream / getInputStream
Ic705Controller.connect outputStream / inputStream / sendAndWaitAck
Ft817Controller.connect outputStream / inputStream
BluetoothReporter x2 outputStream
AprsIsClient is the worst case because AprsReporter retries on a timer
(intervalMin, minimum 1 minute) and nulls out the client after each failure,
so every failed attempt permanently loses one fd:
failure rate leaked fds/hour time to exhaust 1024 fds
5% 3.0 ~14.2 days
20% 12.0 ~3.6 days
50% 30.0 ~1.4 days
100% 60.0 ~17 hours
Typical trigger is a weak link where the TCP handshake succeeds but the peer
immediately RSTs (overloaded or rate-limiting APRS-IS server). Once fds run out
nothing in the process can open a socket or file any more: TLE updates, AMSAT
status and WaveLog uploads all start failing with no obvious cause.
Each path now keeps a local reference to the socket it opened and closes it in
the catch block, also clearing the stream/socket fields so a half-initialised
connection is not mistaken for a live one.
Verified: :core:data:compileReleaseKotlin BUILD SUCCESSFUL; grep confirms all
five close calls are present.
|
||
|
|
c1895506e0 |
fix(cw): flush archive buffer inside loop to stop dropping audio
CwDeepDecoder appended evicted samples with a bare bounds check:
for (v in overflow) {
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
}
if (archiveSize >= ARCHIVE_THRESHOLD) { flush() }
Once archiveBuffer (64000 samples / 20 s) filled up mid-batch the remaining
samples were silently discarded, because the flush only ran after the loop.
Worst measured case: 47999 samples already accumulated (just under the 48000
flush threshold, so no flush) plus a 64000-sample overflow batch means 111999
samples pushed into a 64000 buffer -> 47999 dropped, i.e. 15 s of audio missing
from the permanently archived CW history.
Now the buffer is flushed as soon as it is full and before appending, so every
sample reaches archiveDecode. Simulation over five batch patterns: dropped
count goes 47999 -> 0 for the worst case and all 111999 samples are archived.
Bounds: single append() can evict at most capacity samples, so drainOverflow()
returns at most 64000 - archiveBuffer never needs to grow.
|
||
|
|
066fafbb81 |
fix(data): use Mutex to queue calculatePasses, not drop calls
The previous guard (if (_isCalculating.value) return) silently dropped
concurrent calls. Every call carries filter settings the user just applied,
so a dropped one left the list showing results for the previous filter:
User clicks 'Apply' with elevation>=5
-> UI updates to show elevation>=5
-> calculatePasses(elevation>=5) called
-> but if _isCalculating=true, return immediately
-> list still shows elevation>=30 results
The guard window is wide: delay(1000) + real calculation time (hundreds
of ms to seconds), exactly when the progress indicator spins and users
naturally interact again.
Mutex serializes calls instead: the second one queues and eventually runs
with its own parameters. This also fixes the original concurrency issue
(duplicate parallel calculations) and adds finally {} so a thrown exception
cannot leave isCalculating stuck at true (frozen progress indicator).
Reverts the regression introduced in the previous attempt to add concurrency
protection.
|
||
|
|
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. |