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.
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.
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.
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.
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.
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.
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.
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.
- 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
- 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
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.
User reported the app installed as "FT8CN" with the FT8CN icon and
FT8 not opening. Root causes (verified by aapt badging on the release
APK):
1. module values/strings.xml carried <string name="app_name">FT8CN
which won the resource merge -> application-label became FT8CN.
Removed it; dialogs that referenced R.string.app_name now use a
module-local ft8cn_app_name (HelpDialog/ClearCacheDataDialog).
2. module shipped its own mipmap ic_launcher* (8 files) which
overrode our launcher icon -> deleted all of them.
3. MainActivity extends AppCompatActivity but the module's
<application> lost android:theme when the conflicting attributes
were stripped -> startup crash. Re-added android:theme=Theme.Ft8CN
(MaterialComponents) on the module application element.
Verified: processReleaseManifest/Resources + ft8 javac + app kotlin
BUILD SUCCESSFUL. Plan: .hermes/plans/2026-08-05_150000-v455-fix-plan.md
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
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.
FT8CN 0.93 shipped malformed strings that old AGP tolerated but AGP9
rejects: 148 unclosed tags (</string>>), unclosed XML comments,
trailing garbage after tags, and non-positional multi-placeholder
formats. Fixed across all 8 locale files: closed tags/comments,
removed trailing garbage, numbered placeholders in argument order
(%1$s %2$d %.3$1f style). aapt2 compile of every values-* dir now
passes 0 errors; :feature:ft8 + :app compileDebugKotlin SUCCESSFUL.
CI build failed at :app:checkReleaseDuplicateClasses: local
osmdroid-android-6.1.14.aar (copied from FT8CN) clashed with the
project's osmdroid 6.1.20 (feature/map remote dependency). Switched
feature/ft8 to libs.other.osmdroid (6.1.20), removed the local aar.
Verified: compileDebugJavaWithJavac + :app:compileDebugKotlin
BUILD SUCCESSFUL.