Commit Graph
104 Commits
Author SHA1 Message Date
mckero 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.
2026-08-22 15:32:33 +00:00
mckero 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.
2026-08-22 12:34:53 +00:00
mckero 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.
2026-08-22 11:47:42 +00:00
mckero 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.
2026-08-22 10:27:44 +00:00
mckero 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.
2026-08-22 09:15:23 +00:00
mckero 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.
2026-08-22 06:59:39 +00:00
mckero 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.
2026-08-22 04:02:28 +00:00
mckero 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.
2026-08-22 02:35:58 +00:00
mckero 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.
2026-08-22 01:37:16 +00:00
mckero 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.
2026-08-22 01:15:11 +00:00
mckero 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.
2026-08-22 01:07:38 +00:00
mckero 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.
2026-08-20 15:00:13 +00:00
mckero 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.
2026-08-20 13:24:45 +00:00
mckero a654735337 merge: upstream rt-bishop main (18 commits) with conflict resolution
Merges rt-bishop/Look4Sat main (a42a5f1f, 18 commits: AMSAT status page,
customizable data sources, Doppler calculator, radar compass offset, per-sat
offset memory, localized date formats) into the fork's 30-commit audit
baseline.

Conflict resolution policy (user-directed):
- AMSAT feature (AmSatRepository, SatStatusScreen/ViewModel, SatStatus model):
  upstream version, which the user judged better built. MainScreen routes
  Screen.AmSat through SatStatusDestination().
- Localization: our values-zh/values-tr restored (upstream's merge dropped the
  fork-only strings); new upstream strings (sat_group counts, compass offset,
  frequency offset help) added in EN + ZH.
- fork-only features kept (CW decoder, Mutual/Roaming, WaveLog, APRS, Log tab,
  custom TLE/transceiver source switches): ours.
- Both sides' additions merged where independent: radar compass offset fields
  (Settings/SettingsRepo/RadarState), calculatorOffsetKHz action, wider linear
  transponder detection, deduplicateTransponders + its tests, sunrise/sunset
  tests merged with the moon hour-angle test.
- Sources kept as the fork's map structure (DatabaseRepo depends on it);
  satelliteModes list re-added for SelectionRepo. getSatelliteTypesIds /
  setSatelliteTypeIds re-added to ISettingsRepo+SettingsRepo; SharedDialog
  re-added to Components; providePairedBluetoothDevices added to
  IMainContainer/MainContainer.
- Icons renamed upstream (ic_satellites->ic_sputnik, ic_radar->ic_satellite)
  applied to Navigation/MutualScreen.

Build verified: all modules compileReleaseKotlin + unit tests
(core:domain, core:data, feature:map, feature:roaming) green.
2026-08-20 12:35:01 +00:00
mckero 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.
2026-08-17 16:57:33 +00:00
mckero 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).
2026-08-17 16:54:23 +00:00
mckero 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.
2026-08-17 16:46:09 +00:00
mckero 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.
2026-08-17 16:33:12 +00:00
mckero 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).
2026-08-16 09:47:26 +00:00
mckero 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.
2026-08-16 09:36:08 +00:00
mckero 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.
2026-08-16 09:33:44 +00:00
mckero 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.
2026-08-14 18:30:31 +00:00
mckero 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.
2026-08-14 18:08:46 +00:00
mckero 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
2026-08-14 15:57:32 +00:00
mckero 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.
2026-08-14 14:09:42 +00:00
mckero 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.
2026-08-14 13:28:55 +00:00
mckero 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.
2026-08-14 13:07:53 +00:00
mckero 29ca4c29bb fix(data): 拒绝 calculatePasses 并发调用防止重复计算
根因:
SatelliteRepo.calculatePasses() 在耗时计算期间如果被重复调用,
会启动多个协程同时遍历卫星列表,导致:
1. 重复的 SGP4 轨道计算(CPU/电池浪费)
2. passes MutableStateFlow 被多次更新,触发下游 UI 重组风暴
3. _isCalculating 标志被后续调用覆盖,可能提前置 false

修复:
在 _isCalculating.value = true 之前检查当前值,如果已在计算中则
直接 return,拒绝并发调用。

影响:
防止用户快速切换过滤条件、旋转屏幕等场景下的重复计算。
_isCalculating 现在是真正的互斥信号量(简化版,无等待队列)。
2026-08-14 11:22:07 +00:00
mckero 26c714fc24 fix(cw): stop swallowing coroutine cancellation on pause/resume
用户反馈: 暂停再恢复时经常弹出 "CW decode failed: The coroutine scope
left the composition"。

根因: 暂停 (isListening=false) 使 LaunchedEffect 重启、旧的采集协程被
取消。若此刻 decodeWindow 正在 withContext(Dispatchers.Default) 里做 ONNX
推理, 取消传播时会抛出 LeftCompositionCancellationException
(message 即 "The coroutine scope left the composition", 是 CancellationException
的子类)。processBuffer 的 catch (Throwable) 把它当成解码失败吞掉, 既误报了
错误横幅, 又破坏了协程取消的正常传播。

修复:
- processBuffer 的 decodeWindow catch 里, CancellationException 直接
  rethrow (暂停导致的中断是正常流程, 不是错误)。
- archiveDecode 调用同样包 try-catch, CancellationException rethrow,
  其余异常只记日志不崩溃。

这是协程的标准纪律: 永远不要把 CancellationException 当业务异常吞掉。

版本: 4.5.5 -> 4.5.7 (versionCode 460)。此前多次删 v4.5.5 tag 重打导致
release 页面累积 12 个 draft 草稿, 已全部清除。
2026-08-13 09:55:15 +00:00
mckero 2e1d8b9c00 feat(cw): archive decoded history, polish the waterfall, document licensing
三个用户反馈一并解决:

1. 解码文字不再消失 (核心)
   旧实现: 20 秒环形缓冲满了就静默覆盖最旧样本, 文字随之从屏幕消失。
   新实现: CwDeepBuffer 新增 overflow —— 满时被覆盖的旧样本先进 overflow,
   解码器累积到 15 秒就单独解码一次, 结果追加到 historyText (只增不减)。
   UI 记录区显示 historyText + decodedText (历史稳定 + 当前窗口实时)。
   ICwDecoder 接口新增 historyText StateFlow。

   归档音频已离开主窗口, 不再被 CTC 修正, 故其文本是"最终版", 追加安全。
   归档窗口 15 秒: 内容已在 20 秒窗口里解过多次, 短一点几乎无损, 且推理
   开销小。

2. 瀑布图更好看
   配色从"深蓝->青->黄"换成 matplotlib inferno (黑->紫->品红->橙->黄),
   与静态频谱图保持一致。相邻 bin 之间用水平渐变做线性插值, 消除 65 列
   离散方块的像素感。

3. AGPL 合规补漏 (用户提醒: 仓库许可证没体现 AGPL 组件)
   README 新增 License 章节: 声明项目主体 GPL-3.0 + feature/cw 的 DeepCW
   模型 AGPL-3.0-only, 并说明合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理。

验证:
- :core:domain:test => 31 个 CW 测试全绿 (CwDeepBufferTest 新增 3 个
  overflow 归档测试: 顺序/清空/reset)
- :core:domain:compileKotlin + :core:data + :feature:cw:compileDebugKotlin
  => BUILD SUCCESSFUL
2026-08-13 08:04:05 +00:00
mckero ac5cbbc49a fix(cw): add crash probes to localize adb-less flash-crash
闪回桌面且无任何 Java 日志 => 崩溃发生在 native 层 (ONNX 库内部段错误)
或进程被系统 LMK 直接杀掉 (内存不足) —— 两种情况 Java 的
uncaughtExceptionHandler 都不会触发, crash_log.txt 自然为空。

新增 CwProbe: 在关键节点向 files/probe_cw.txt 追加时间戳行:
  decoder_constructed -> load_begin -> load_session_ok / load_failed:<类名>
  -> infer_begin -> infer_done
进程若中途死亡, 最后一行的节点就是崩溃/被杀位置, 无需 adb 即可定位。

验证: :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-13 00:46:56 +00:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
mckero cf49dcfa01 fix(cw): persist decoder failures to files for adb-less diagnostics
在服务器上完整复现了手机端流程 (真实 20s 含噪音频 -> 重采样 -> 频谱 ->
ONNX -> 贪心 CTC), 解码逐字符正确:
  decoded: [CQ CQ DE BG7NTA BG7NTA K 5NN TU 73]   (与参考完全一致)
整条链路逻辑无问题, 闪退属环境/平台层。

应用内已有全局崩溃捕获器 (MainApplication.installCrashHandler) 会把堆栈
写入 files/crash_log.txt —— 无需 adb 即可取到崩溃原因。

本提交为加载与推理两处异常补充落盘:
- 模型加载失败 -> files/deepcw_load_error.txt
- 推理异常 -> files/deepcw_infer_error.txt

用户可在文件管理器按
Android/data/com.rtbishop.look4sat.bg7nta/files/ 路径取回这些日志。

验证: :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 16:43:52 +00:00
mckero 5f297f9233 fix(cw): stop the waterfall crashing and cut APK size by 87MB
三个真机实测暴露的问题:

1. 闪退 (给权限后 3-4 秒必崩, 且注入日志抓不到堆栈)
   CwWaterfallState 用 mutableIntStateOf 记录重绘版本号, 却从音频采集线程
   写入。Compose 快照状态只能在合成线程修改, 从后台线程写会在运行时崩溃 ——
   崩在 Compose 内部, 所以业务类的日志注入抓不到。
   改用 MutableStateFlow (本身线程安全), Canvas 侧 collectAsState 读取。

2. 同一状态的跨线程数据竞争
   pushSamples 在采集线程写 rows/pending, snapshot() 在绘制线程读, 而
   ArrayDeque 非线程安全 —— 并发 removeFirst()/toList() 会抛
   ConcurrentModificationException 或 IndexOutOfBoundsException。
   两侧统一加锁; FFT 计算放在锁外, 只有队列追加持锁。

3. APK 从 8.4MB 暴涨到 135MB
   onnxruntime-android 的 AAR 自带 4 个架构原生库: arm64 28M + armv7 20M +
   x86 33M + x86_64 34M = 115MB。上次提交移除 abiFilters 时把 x86 系列也
   打包了进去 (仅模拟器需要)。
   重新加上 abiFilters, 保留 arm64-v8a + armeabi-v7a 两个真机 ABI,
   预计降至约 43MB。注意这与上次"恢复 64 位"不冲突: 那次删的是只留
   armeabi-v7a 的限制, 这次是排除 x86 系列。

另外两处加固:
- CwDeepDecoder 的模型加载从 init{} 移入惰性 ensureLoaded(): 加载会触发
  ONNX Runtime 原生库装载, 失败时抛 UnsatisfiedLinkError; 在构造函数中抛出
  会连带崩掉创建它的 composable, try-catch 也救不回来。移到首次使用时执行,
  失败经 errorMessage 上报给 UI。
- ONNX 会话限制 intraOp 线程数为 (核数-1) 且上限 4, 给音频采集和 UI 留出
  余量, 默认行为会铺满所有核心。

验证:
./gradlew :feature:cw:compileDebugKotlin :core:data:compileDebugKotlin => 通过
./gradlew :core:domain:test => 79 个测试全绿
2026-08-12 13:55:03 +00:00
mckero 9b0d543f12 refactor(cw)!: replace legacy CW engine with DeepCW in both entry points
DeepCW 成为唯一 CW 解码内核, 不保留旧引擎作兜底 (用户决定: 完全移植)。

删除旧内核 (1298 行):
- CwDecoder.kt / CwBayesianDecoder.kt / CwChannelTracker.kt / CwSpectrogram.kt
- CwDsp / CwFFT / CwSTFFT / CwGoertzel / CwFilter / CwResampler.kt
- CwDecoderTest.kt
旧内核的自动定频与 squelch 门控由 DeepCW 的 400-1200Hz 固定频窗替代 ——
模型自带定频, 不再需要频谱峰值跟踪和噪声门。

两个 CW 入口都改接 DeepCW:
1. feature:cw 独立整页 CwDecodeScreen.kt 重写为纯 Compose (瀑布图 -> 实时行
   -> 历史区), 移除 AndroidView/LayoutInflater 和被删的 activity_main 布局;
   删除 CwSettingsDialog.kt (旧引擎的手动音调/带宽面板, DeepCW 全自动无需)
2. feature:radar 内嵌可折叠面板改走 ViewModel 的 CW state/action, 移除
   Morse Expert 控制器与 cw_panel_main 布局
   (该面板此前注释写明 "no longer feeds it", ViewModel 里的 CW 通路是死代码)

新增 CwWaterfall.kt: 复用 CwDeepSpectrogram 绘制瀑布图, 显示的正是模型
分析的 400-1200Hz 频段与同一批幅值。

接口接线:
- IMainContainer.provideCwDecoder() 提供 ICwDecoder (实现需 Context 读 assets)
- RadarViewModel 构造新增 cwDecoderFactory, onCleared() 中 close() 释放
  OrtSession 避免原生内存泄漏
- 删除已无调用方的 RadarAction.CwSetToneFreq (DeepCW 定频固定, 无参数可调);
  CwSubState.cwToneFreq 语义改为只读显示检测到的音调

依赖清理:
- feature:radar 不再依赖 feature:cw 与 constraintlayout
- feature:cw 不再依赖 constraintlayout

字符串: feature:cw 清理 Morse Expert 遗留串 (premium/rate/help/
tap_back_again_to_close/purple_500), 按 en/zh/tr/in/id 五语补全新串;
core:presentation 补 radar_cw_tone / radar_cw_waiting 五语。

验证:
./gradlew :core:domain:test => 4 个测试类全绿
./gradlew :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :feature:radar:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 13:00:25 +00:00
mckero 57762613a8 feat(cw): add CwDeepDecoder wiring ONNX Runtime to the CW pipeline
组装完整解码链路: 麦克风 PCM -> 重采样 3200Hz -> 20 秒滚动缓冲 ->
频谱图 -> ONNX 推理 -> 贪心 CTC -> 文本。

ICwDecoder 放 core:domain (纯 Kotlin, UI 可直接依赖), 实现放 core:data
(需要 Android Context 读 assets 及 ONNX Runtime 原生库)。

关键设计:
- decodedText 是替换语义不是追加: 模型会随上下文增加改写先前字符, 追加
  会把中间态 (BM -> BG7 -> BG7NTA) 永久留在屏幕上
- 推理用 Mutex.tryLock 串行化: 上一次未完成时直接跳过本周期, 慢设备不会
  堆积任务导致 OOM
- 推理在 Dispatchers.Default, 不阻塞音频采集线程
- 模型元数据从 model.onnx.json 读取 (字符表/blank 索引/输入输出名), 不在
  代码里硬编码, 模型更新时无需改代码
- lastInferenceMs 打点: 手机实际推理耗时需真机实测 (估算系数待验证)
- estimatedPitch 由频谱峰值 bin 反算, 仅供 UI 显示 —— 模型 400-1200Hz
  固定窗自带定频, 不需要频谱峰值跟踪参与解码
- 模型加载失败时 errorMessage 置位 (旧内核已删, 无兜底可退)
- close() 释放 OrtSession, 否则原生内存泄漏

验证:
./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 12:26:42 +00:00
mckero b8113fc757 build(cw): add onnxruntime-android 1.28.0 to core:data
DeepCW 模型推理需要 ONNX Runtime。该依赖属 Android 平台依赖 (AAR 内含
各 ABI 原生库), 因此放 core:data 而非 core:domain —— 后者按 AGENTS.md
须保持纯 Kotlin/JVM 以留 KMP 迁移余地。

版本固定为 1.28.0 不使用范围区间: release 构建走 R8, 避免依赖意外升级
引入行为变化。

验证:
./gradlew :core:data:dependencies --configuration releaseRuntimeClasspath
=> \--- com.microsoft.onnxruntime:onnxruntime-android:1.28.0
=> BUILD SUCCESSFUL in 3m 48s
2026-08-12 11:52:47 +00:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 3f5b48f270 Integrated the AMSAT status page created by MCKero6423 2026-08-11 14:05:55 +02:00
atsunatsuandatsunatsu 060fa2dfcd Added remembering per-satellite doppler offset (#237)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-11 14:02:15 +02:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
mckero 284183836e refactor(amsat): replace hand-rolled date parsing with SimpleDateFormat
Addresses @AlanCui4080's review feedback about the manual date calculation.
Uses SimpleDateFormat to eliminate the hand-written calendar math (regex +
days-from-epoch calculation). Avoids java.time since it requires desugaring
on minSdk 24, keeping dependencies minimal.

Before: 23 lines of manual day-from-epoch calculation
After: 7 lines using SimpleDateFormat

Ref: https://github.com/rt-bishop/Look4Sat/pull/233#discussion_r1868599947
2026-08-05 17:02:01 +00:00
mckero de85aa1e8b refactor(amsat): align with Material3 conventions per PR #233 review
Addresses feedback from rt-bishop/Look4Sat#233:

1. Migrate hardcoded colors to MainTheme colorScheme
   - Extend darkScheme: tertiary (Active 0xFF648FFF), tertiaryContainer (Telemetry 0xFFFFB000)
   - Extend lightScheme: tertiary (0xFF3C6FE0), tertiaryContainer (0xFFE09800) for contrast
   - Remove top-level Color() constants from SatStatusScreen.kt
   - Add statusColorOf() mapper using MaterialTheme.colorScheme

2. Move HTTP implementation from domain to data layer (Clean Architecture)
   - Delete AmSatApiClient.kt from core:domain (violates AGENTS.md: "Pure Kotlin, NO Android deps")
   - Migrate to IRemoteSource/RemoteSource in core:data (uses existing OkHttp3)
   - AmSatRepository now depends on IRemoteSource instead of AmSatApiClient

3. Inline JSON parsing (prepare for java.time migration)
   - Parse AMSAT API responses (names, reports) in AmSatRepository
   - Time parsing still uses manual logic (java.time desugaring in follow-up)

Before:
  - Hardcoded Color(0xFFXXXXXX) in UI + Repository (no theme support)
  - HttpURLConnection in domain layer (architecture violation)
  - AMSAT colors duplicated across modules

After:
  - MaterialTheme.colorScheme.tertiary/tertiaryContainer (light/dark adaptive)
  - HTTP via data layer RemoteSource (follows AGENTS.md architecture)
  - Single source of truth for AMSAT colors

Ref: https://github.com/rt-bishop/Look4Sat/pull/233#discussion_r1868599947
Ref: AGENTS.md "core:domain - Pure Kotlin (JVM). NO Android dependencies."
2026-08-05 15:26:42 +00:00
mckero f789a15338 fix(status): AMSAT page load failure, refresh spinner, retry button
- P0: fetchStatus() now runs on Dispatchers.IO - the previous
  synchronous URLConnection on the main thread threw
  NetworkOnMainThreadException and showed "load failed" on every open
- P1: refresh button rotates a vector icon (ic_refresh) instead of the
  "↻" text glyph, whose off-center font metrics made the spinner
  orbit around a shifted pivot
- P2: error state gains a Retry button (4 locales); amsat_refresh
  string added (5 locales)
- versionCode 456 (bump for reinstalling over 455), versionName stays 4.5.5
2026-08-05 08:50:01 +00:00
mckero 6221516893 feat(amsat): 4.5.5 official AMSAT API + edge-to-edge status page
- Replace HTML parsing with the official AMSAT Satellite Status API v1
  (catalog.php + reports.php, JSON): AmSatApiClient (pure JVM, hand
  rolled ISO-8601/epoch parsing for minSdk 24) + rewritten
  AmSatRepository (satellite list from catalog, reports slotted into
  6 days x 12 two-hour slots, status colors per report value)
- Fix edge-to-edge: status bar / navigation bar insets on the status
  page (refresh button and update time were unreachable)
- Version 4.5.5 (455), What's new in all 5 locales
2026-08-05 08:12:17 +00:00
mckero c9cab8457d chore(i18n): translate all code comments to English
All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
2026-08-05 07:59:35 +00:00
mckero 0e767f4250 feat(wavelog): QRZ grid lookup, LoTW satellite list refresh, RST 59
User-prioritized WaveLog fixes (4.5.5, commit-only per instruction):

- QRZ 对方网格爬虫: QrzGridClient (domain, pure JVM) fetches
  https://www.qrz.com/db/{call} with user-supplied cookies (parses
  EditThisCookie JSON or raw "k=v; k=v"), extracts Grid Square from
  the Detail table. Cookies NEVER built in - entered in settings.
- Settings: WaveLog card top-right gear opens QRZ cookie dialog with
  test query button (detects logged-in callsign from cookie, looks up
  its grid, shows result or failure in the dialog).
- LogTab: on Enter, async lookup of the other station's grid ->
  queue.updateGridsquare -> uploaded with QSO (postQso gridsquare).
- LoTW satellite list: 112 names embedded (lotw.arrl.org config.tq6),
  normalizeSatName maps Celestrak TLE names to LoTW names (SAUDISAT-1C
  -> SO-50, FUNCUBE-1 -> AO-73, DIWATA-2B -> PO-101, ZARYA/ARISS ->
  ARISS); "Update sats" button in WaveLog card downloads the live list
  (LotwSatellitesRepo, SharedPreferences persisted, never in build).
- RST: rst_sent/rst_rcvd = 59/59 in both v1 ADIF and v2 JSON.
- Grid mismatch dialog kept (cloud station grid vs station QTH);
  QSO gridsquare no longer uses station grid.
- New strings in 5 locales; check_strings 9 files OK.
Verified: core:domain/data + feature:settings/radar + app
compileDebugKotlin BUILD SUCCESSFUL.
2026-08-05 06:23:47 +00:00
mckero c9d87be289 fix(aprs): upload feedback, manual report reliability, station position
User testing round 3 (4.5.4): no success feedback on upload, aprs.fi
shows nothing, passcode calculator OK, notification present.

- Manual report now works even when service not running: ACTION_REPORT_NOW
  starts the service first (Toast "not configured" if missing callsign)
- Upload result feedback guaranteed: Toast always shows (short OK /
  long fail+reason), last result persisted (time/ok/detail) and shown
  in the settings card "Last report: HH:mm:ss OK/failed - detail"
- Position source: station position from settingsRepo (user decision)
  with live GPS last-known as fallback
- sendPacket reads the server confirmation line (short 3s timeout);
  server error text (Invalid/error) surfaces in Toast + card
- Strings EN/ZH/TR/IN/ID +4 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:29:36 +00:00
mckero 012ea1eeb6 fix(aprs): passcode compute button, crash log, notif permission, login verify
User feedback round 2 (4.5.4): no notification shown, report not
sending, no error visibility on crash. Diagnosis: APRS-IS port 14580
reachable (verified with real login test), 24580 SSL refused; login
format OK. Fixes:

- Settings dialog: "Compute passcode" button - fills passcode from
  callsign via the ported 0x73E2 algorithm (user can see the result)
- Global crash handler in MainApplication: stack trace appended to
  files/crash_log.txt so crashes are diagnosable (user: no crash logs
  were available before)
- POST_NOTIFICATIONS runtime permission requested when enabling APRS
  (Android 13+ otherwise silently hides the service notification)
- AprsIsClient: read the login response (aprsc "# logresp ...
  unverified"/"Invalid") and surface the server message as the error
- AprsForegroundService: Toast on manual report result (OK / failure
  with server reason)
- Strings EN/ZH/TR/IN/ID +3 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:06:09 +00:00