Compare commits

..
78 Commits
Author SHA1 Message Date
mckero 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.
2026-08-21 11:25:26 +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
atsunatsuandatsunatsu a42a5f1f0d Tweaked sunrise/sunset calculations, added unit tests (#241)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-19 14:46:39 +02:00
mckero 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.
2026-08-18 03:12:45 +00:00
mckero 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.
2026-08-17 17:56:09 +00:00
mckero 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.
2026-08-17 17:17:19 +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 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).
2026-08-16 09:41:35 +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 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.
2026-08-14 19:49:32 +00:00
mckero 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.
2026-08-14 19:19:42 +00:00
mckero 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.
2026-08-14 18:54:26 +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 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.
2026-08-14 17:34:06 +00:00
mckero 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.
2026-08-14 16:59:30 +00:00
mckero 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.
2026-08-14 16:31:25 +00:00
mckero 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.
2026-08-14 16:10:18 +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 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.
2026-08-14 15:38:08 +00:00
mckero 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.
2026-08-14 15:27:43 +00:00
mckero 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.
2026-08-14 15:12:30 +00:00
mckero 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.
2026-08-14 15:09:18 +00:00
mckero 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
2026-08-14 14:56:23 +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 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.
2026-08-14 12:57:37 +00:00
mckero 77314e824e fix(radar): make pass auto-advance and duplicate-QSO guard actually work
自查上一轮修复时发现两处改动根本没生效, 属于我上一轮的误判, 这里改成真修复。

## 1. 过境结束不切换 (上一轮改动无效)

上一轮在 tick 循环里加了"过境结束就重新 findCurrentPass()"。但
findCurrentPass() 的第一级匹配是:

    passes.find { it.catNum == catNum && it.aosTime == aosTime }

其中 (catNum, aosTime) 来自 satelliteRepo.selectedPass, 而 selectPass()
只在 MainScreen 用户点击过境时调用 (grep 全仓库确认 3 处调用点全在
MainScreen), 雷达页运行期间该值不变。所以过境结束后重新查询仍然精确命中
同一个已结束的过境, nextPass != pass 恒为 false, 一次都切不过去。

模拟脚本复刻 findCurrentPass 四级回退 + tick 循环验证:
  修复前 ISS(10:00-10:10) 结束后, 到 10:19 仍在 tick ISS, 切换 0 次
  修复后 10:11 切到 NOAA-18(10:15-10:25), 切换 1 次
  边界: 最后一个过境结束后无下一个, 保持当前不崩溃

改为新增 findNextPassAfter(): 取 aosTime > current.losTime 的过境, 优先
同一颗卫星的下一圈 (雷达继续跟这颗星), 没有则退回任意卫星最早的那个。

## 2. 重复提交 QSO (上一轮改动无效)

上一轮加的 submitting 标志位没有任何作用: submit() 全程同步, 进入时置
true, 返回前置 false, 中间没有挂起点。两次 IME onDone 是两个独立事件,
第二次进来时标志早已复位。

改为记录上次入库的呼号与时间戳, 同一呼号在 2 秒内重复提交直接忽略。
这才是实际要防的场景 (误触两次回车存两条同样的 QSO)。

验证: :feature:radar:compileReleaseKotlin BUILD SUCCESSFUL
2026-08-14 12:45:33 +00:00
mckero 74902cddce fix(i18n): escape single quote in Turkish whatsnew string
Android AAPT requires single quotes in string resources to be
escaped as \' to avoid being interpreted as the start of an
escape sequence. The unescaped Ayarlar'ı triggered:
'Invalid unicode escape sequence in string'

values-tr/strings.xml:108 Ayarlar'ı → Ayarlar\'ı
2026-08-14 11:44:16 +00:00
mckero 8324903104 chore(release): bump versionCode to 462 and refresh whatsnew for v4.5.7
Increment versionCode 461 → 462 to allow reinstallation over the existing
v4.5.7 APK (required for覆盖发行版 to work on user devices).

Update whatsnew in all 4 locales (en/zh/tr/id+in) to document the 10 bug
fixes shipped in this release:
- Menu layout: Settings永久消失, AMSAT/WavelogLog forced migration
- DataParser: epoch parsing for UTC 00:00:01–00:01:26
- Radar: auto-switch to next pass, live Doppler offset
- Passes: division by zero in progress calculation
- SatelliteRepo: concurrent calculatePasses race
- WaveLog: duplicate QSO submission, grid square update race

Release notes now include both the DeepCW fp32 migration and the 10 fixes.
2026-08-14 11:38:59 +00:00
mckero a757474b06 fix(radar): 过境结束后自动切换到下一个过境
根因:
collectPassAndStartTickLoop() 启动时调用 findCurrentPass() 获取当前过境,
之后进入 while(isActive) 循环每秒 tickPass(),但从不重新检查过境是否结束。
结果:过境 LOS 后雷达页面继续显示旧卫星位置(冻结在地平线),用户必须
手动返回过境列表重新点击下一个过境。

触发条件:
1. 用户在过境进行中打开雷达页面
2. 过境结束时用户仍停留在雷达页面
3. passes 列表中存在后续过境

修复:
每次 tick 开始时检查 timeNow > pass.losTime,如果过境已结束则调用
findCurrentPass() 查找下一个过境。findCurrentPass() 的三级回退逻辑
(精确匹配 → 时间窗口 → 同卫星 → 第一个)保证能拿到合理的下一个过境。
如果找到且与当前 pass 不同,则重新 loadPassData() 加载新过境的电台和轨迹。

影响:
雷达页面现在会无缝切换到下一个过境,用户体验接近实时卫星跟踪软件。
如果 passes 列表为空(所有过境都结束),页面保持最后状态不崩溃。
2026-08-14 11:24:01 +00:00
mckero 9cfa238e5c fix(passes): 防止过境进度计算时除零崩溃
根因:
PassesViewModel.updateProgress() 计算进度时用 deltaNow / deltaTotal,
如果 TLE 损坏、轨道退化、或数据解析错误导致 losTime <= aosTime,
deltaTotal 为 0 或负数,除法触发 ArithmeticException 或产生 Infinity。

触发条件:
1. OMM/CSV 历元解析 bug(已在另一 commit 修复)导致时间错乱
2. 深空卫星 TLE 过期几十年,losTime 计算失败回退到 aosTime
3. 手动导入格式错误的 TLE

修复:
在除法前检查 deltaTotal <= 0f,跳过该过境的进度更新。
用户仍能看到过境列表,但异常过境不显示进度条(优雅降级)。

影响:
避免因单个异常 TLE 导致整个过境列表页面崩溃。
2026-08-14 11:22:54 +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 82c8112575 fix(settings): RadioControlDialog 组合期间写状态
问题:Compose 运行时警告两次 'State write during composition' (split mode 重置 + baud rate 调整)。
根因:splitMode/baudRate 的条件写操作在组合 body 内直接执行 (if 块)。
修复:用 LaunchedEffect 隔离副作用,避免组合期间修改状态。
影响:消除运行时警告,避免潜在的组合跳帧或死循环。
2026-08-14 09:33:53 +00:00
mckero 20e5b2f617 fix(roaming): 权限授予后页面内刷新状态
问题:用户在页面内跳系统设置授予定位权限再回来,GPS 监听不启动(必须退出重进页面)。
根因:permissionLauncher 回调是空的 { },不更新 hasPermission 状态。
修复:回调里检查 FINE/COARSE 权限并更新 hasPermission,触发 DisposableEffect 启动 GPS 监听。
影响:用户授权后立即生效,无需退出重进。
2026-08-14 09:27:00 +00:00
mckero aa10d4170c fix(radar): SwipeDeleteRow 倒计时期间重新拖拽后归零偏移
问题:倒计时期间重新拖拽会取消 pending(正确),但 offsetX 仍是负值,行卡在滑开状态(要继续滑才能归位)。
根因:onDragStart 里只取消了 pending,没有归零 offsetX。
修复:onDragStart 取消 pending 的同时归零 offsetX。
影响:拖拽取消后行立即回到正常位置。
2026-08-14 09:24:26 +00:00
mckero aac3aee4bb fix(radar): QRZ 网格回填后刷新列表显示
问题:QRZ 爬虫异步回填网格后,列表里对应行不会立即显示网格(要等下次保存/删除操作才看到)。
根因:updateGridsquare 成功后没有触发 refreshTick++,UI 的 entries 列表不会重新计算。
修复:回填成功后调用 onSaved() 触发刷新。
影响:网格回填立即可见,改善用户体验。
2026-08-14 09:22:05 +00:00
mckero d749c5ab48 fix(domain): WavelogQueue.updateGridsquare 添加同步锁
问题:updateGridsquare 是唯一没有 @Synchronized 的修改方法,与 add() 并发(LogTab 主线程 add + 后台协程 updateGridsquare)会触发 read-modify-write 竞态,导致新 QSO 丢失。
修复:给 updateGridsquare 加 @Synchronized,与其他修改方法保持一致。
影响:消除数据丢失风险。
2026-08-14 09:19:41 +00:00
mckero 082e0c5bff fix(radar): 防止 WaveLog 重复提交 QSO
问题:键盘 onDone 连击会存两条相同的 QSO(时间戳/呼号/频率完全一致)。
修复:添加 submitting 状态变量,提交期间拒绝重复调用。
影响:误操作不再造成重复记录。
2026-08-14 09:14:47 +00:00
mckero e69d6dee4a fix(radar): 修复 offset slider 拖动时多普勒计算用旧值
问题:onValueChange 回调里 offsetHz 是派生状态的旧快照,拖动时计算的频率滞后一帧。
修复:在回调内立即从 newVal 计算 newOffsetHz,传给多普勒计算器。
影响:拖动 offset 时频率显示实时正确。
2026-08-14 09:11:45 +00:00
mckero cf93ca9f71 fix(ui): 修复菜单布局的三个严重 bug
Bug #1: moveToMain 误驱逐页面
- 根因:每次调用 moveToMain 都执行驱逐逻辑,即使页面本来就在主菜单
- 场景:拖拽主菜单内部顺序 → SettingsViewModel 遍历新顺序逐个调 moveToMain
  → 每次都判断 main.size > 5 → 误驱逐最后一个页面
- 修复:只在真正从 More 移到主菜单时才驱逐(加 wasInMore 标志位)

Bug #2: onReorder 传 resolve 输出污染状态
- 根因:SettingsScreen 拖拽回调传的是 resolve 输出(mainItems/subItems)
  而不是持久化输入(screenOrder/subMenuOrder)
- 场景:拖拽主菜单 → onReorder 传 [Radar, ..., Settings](完整列表)
  → Settings 被显式存入 screenOrder → 下次 resolve 当作用户手动放置 → 参与驱逐逻辑
- 修复:
  1. 拖拽主菜单时过滤掉 Settings(锁定页面用默认位置,不存持久化)
  2. 另一个菜单用输入的 screenOrder/subMenuOrder,不用 resolve 输出

Bug #3: onReorder More 菜单传错参数
- 根因:拖拽 More 菜单时传的是 mainItems(resolve 输出),不是 screenOrder
- 修复:改用输入的 screenOrder

影响:修复前,拖拽主菜单会丢页面,移页面到主菜单可能导致 Settings 消失
2026-08-14 00:43:23 +00:00
mckero 7cadded6ca fix(data): compute the OMM epoch day fraction numerically, not by string surgery
排查页面顺序问题时顺带审计发现: OMM/CSV 历元在子夜后约 86 秒内会被解析成
完全错误的值, 且不抛异常 —— 静默的错误数据。

## 根因

DataParser.parseCSV 用字符串拼接构造历元:

    val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
    val epoch = "${year.substring(2)}$day$frac".toDouble()

substring(1) 的意图是切掉 "0.123" 的前导 0。但 Double.toString() 在数值
小于 1e-3 时切换为科学计数法, 于是被切掉的是【有效数字】, 剩下的指数后缀
让整个字符串重新变成一个合法但语义完全错误的 double。

Kotlin 侧实测(单测失败信息):

    00:00:01.000  期望 25001.000011574073  实得 0.2500115740740741
    00:01:00.000  期望 25001.000694444443  实得 2.5001944444444444

与 JDK 侧独立验证逐位一致。00:01:26.4 之后 frac >= 0.001, 不再用科学计数法,
所以这个 bug 只在每天前 86.4 秒的历元上出现(约占 0.1%), Celestrak OMM 数据
里整分历元并不罕见。

## 影响

runCatching 抓不到(没有异常), 该卫星的 juliandDateOfEpoch 会推出 year=2000
day≈0, tsince 偏差约 26 年 —— 方位/仰角/过境预报彻底失效, 不是精度下降。
且用户无从察觉。

## 修复

改为数值相加, 不经过字符串:

    val dayFraction = (hour + min + sec + ms) / 86400000.0
    val epoch = "${year.substring(2)}$day".toDouble() + dayFraction

"25001".toDouble() + 0.0000115 = 25001.0000115, 无科学计数法风险。

## 验证

DataParserTest 新增 5 个历元回归测试(子夜整点/子夜后 1 秒/子夜后 1 分钟/
正午/当日最后一毫秒)。先确认前两个在旧实现下失败(failures=2), 修复后:

- DataParserTest 24 个测试全绿(原有 19 个无回归)
- :core:domain:test 全量 105 个测试 0 失败 0 错误
2026-08-13 16:20:07 +00:00
mckero 6fc2f560b7 fix(nav): resolve the menu layout in core:domain so page order actually applies
用户报告"把 AMSAT 从更多菜单移到主菜单没生效"。排查后发现这是两个互相
掩盖的 bug, 其中一个会让用户永久无法进入设置页。

## Bug 1 (致命): 移任意页进主菜单 -> 设置入口从所有菜单消失

对"主菜单上限 5"的定义两处不一致:
- SettingsScreen.kt:892 判断 mainItems.filter { it != "Settings" }.size >= 5,
  上限是【不含 Settings 的 5 个】, 于是认为 [Satellites,Passes,Radar,Map]
  还有空位, 直接追加 -> 共 6 项
- MainScreen.kt:165 的 .take(5) 上限是【含 Settings 的前 5 个】, 而 Settings
  排在最后 -> 被截断丢弃

结果 Settings 既不在底栏也不在更多菜单(它不在 subMenuOrder 里), 用户再也
进不去设置页, 无法自行改回, 只能清数据或重装。

## Bug 2 (用户报告): AMSAT / WavelogLog 移到主菜单被静默撤销

MainScreen.kt:161-163 给老用户补新页面的迁移逻辑【无条件执行】:

    .let { list -> if ("AMSAT" in list) list else list + "AMSAT" }

用户把 AMSAT 移出子菜单后 subMenuOrder 里没有它, 这段又加回去, 第 165 行
filter { it.screenId !in subOrder } 于是永远过滤掉 AMSAT。

副作用: 这个 bug 恰好把主菜单拉回 5 项内, 反而掩盖了 Bug 1 —— 所以用户只
看到"AMSAT 移不过去", 没触发"设置锁死"。

## Bug 3: 溢出页面凭空消失而非落入更多菜单

.take(5) 直接丢弃超出的页面, 它们既不在底栏也不在更多菜单。

## Bug 4: 设置页展示顺序与底栏实际顺序不一致 (WYSIWYG 失效)

两处各自实现排序: 设置页不做 take(5)、用 sortedWith 把 Settings 钉最后;
MainScreen 做 take(5)、Settings 位置由 screenOrder 决定。

## Bug 5: 更多菜单里 AMSAT / Roaming 当前页不高亮

MoreMenuPopup.kt:69-79 的 when (currentKey) 缺 AmSat 与 Roaming 分支,
MainScreen.kt:188-198 缺 Roaming 分支, 靠 else -> false 兜底, 新增页面时
不会有编译错误提醒。Screen 子类都是 data object, 直接用 currentKey == screen
即可, 新增页面自动生效。

## Bug 6: 设置页主菜单区不过滤 hiddenScreens

MainScreen 会过滤隐藏页, 设置页不过滤, 于是隐藏的页面仍占据设置页的位置且
"移出主菜单"按钮可点, 与底栏实际情况不符。

## 改动

新增 core/domain/navigation/MenuLayout.kt (纯 Kotlin, 为 KMP 就绪) 作为菜单
布局的唯一入口:
- resolve(): 持久化偏好 -> 底栏 + 更多菜单。先给 Settings 预留名额再截断,
  溢出页面落入更多菜单而非丢弃; 两个列表都没提到的页面按默认归位(升级不丢页)
- moveToMain() / moveToMore(): 移动语义集中一处, 拒绝把 Settings 移出

MainScreen 与 SettingsScreen 改为共用它, 删除双份实现与无条件迁移逻辑。

## 死代码清理 (每项已 grep 全仓库确认零引用)

- SettingsAction.ResetScreenOrder: 无任何派发点, 仅定义与 when 分支
- UiSettingsCard 的 onReorder 参数: 函数体内两个 DragOrderList 的 onReorder
  实参都调 onUpdateMenu, 从未调用该参数
- SettingsAction.ReorderScreens: 唯一引用是上面那个死参数
- MainScreen 的 navigateToRadar 参数: 函数体内唯一出现是注释掉的一行
- Navigation.kt 的 defaultScreenOrder / defaultSubMenuOrder: 迁移后零引用,
  默认值已在 MenuLayout 内

保留 entry<RadarDestination> 分支不动 —— RadarDestination NavKey 被
PassDetailsMatcher deeplink 使用。

## 验证

- MenuLayoutTest 13 个新测试全绿, 每个对应上面一个 bug 场景
- :core:domain:test 全量 100 个测试 0 失败 0 错误
  (DataParser 19 / Doppler 17 / Qth 8 / Transponder 7 / CwCtc 7 /
   CwDeepBuffer 13 / CwGolden 3 / CwSpectrogram 8 / MenuLayout 13 /
   WaveLogApi 5)
- :core:domain:compileKotlin + :core:presentation + :app +
  :feature:settings compileDebugKotlin => BUILD SUCCESSFUL
- 用 Python 复刻新规则重跑当初失败的全部场景: 5 个页面逐一移入主菜单,
  设置入口全部保住、页面无丢失; 隐藏页 + 移动组合无页面丢失; 幂等性通过
2026-08-13 16:10:41 +00:00
mckero 8adf861ed7 chore(release): refresh 4.5.7 whats-new and bump versionCode to 461
版本名保持 4.5.7 不变(用户要求覆盖同一个发行版, APK 文件名仍为
Look4Sat-Pro-4.5.7.apk)。

versionCode 460 -> 461: 手机上已安装 versionCode 460 的 4.5.7, 若 code
不变则覆盖安装会被系统拒绝。versionCode 对用户不可见。

pass_whatsnew_message 五语(en/zh/tr/in/id)补全本次全部改动, 之前只写了
DeepCW 那批, 本轮 UI 改动与 fp32 模型未包含:
- 内置完整版 fp32 模型
- 更多菜单改靠右窄面板
- CW 页移除无效返回键与误导性状态提示
2026-08-13 14:49:36 +00:00
mckero f4f6ec7db5 feat(cw): ship the full fp32 DeepCW model instead of the int8 build
用户要求内置完整版模型, 不要量化版。

assets/deepcw/model.onnx: 4,354,478 bytes (int8) -> 15,139,839 bytes (fp32)
sha256 ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02,
与上游 commit 8e264d2 发布的原始文件逐字节一致, 零修改。

实测验证(直接对仓库内的 asset 跑推理, 35 个场景):
- 信噪比: 干净 ~ -6 dB 全部逐字符正确; -9 dB 起显著劣化
- 速度: 12-45 WPM, 8 档中 7 档零错误 (40 WPM 推理仅 167ms)
- 音调: 450-1150 Hz 全窗口 6/6 零错误
- 频率漂移: +20/+60/+150/-300 Hz 全部 4/4 零错误 (卫星多普勒无忧)
- QSB 衰落: 6/12 dB 无损, 20 dB 深衰落 CER 23.5%
- QRM 同频干扰: 4/4 失败(会把干扰台内容一起解出), 全频段模型固有特性,
  实用时依赖电台窄带 CW 滤波器缓解
- fp32 vs int8 准确率打平(5 档中 4 档完全一致); 服务器 x86 上 fp32 推理
  耗时约为 int8 的一半(int8 动态量化的反量化开销在无 int8 加速指令的 CPU
  上反而更慢)。手机 ARM 侧表现待装机确认。

NOTICE.md / DEEPCW.md / README.md 同步更新: 移除 int8 量化派生的记录与复现
步骤, 改为声明未修改照搬上游。

代价: APK 体积约 59MB -> 70MB, 运行内存峰值上升。此前真机闪退的根因是 R8
缺 -keep ai.onnxruntime.** 规则(已修), 与模型大小无关。
2026-08-13 14:48:56 +00:00
mckero 5d89190348 ui: slim the More menu and drop dead controls from the CW page
用户反馈三处 UI 问题, 一并处理。

1. 更多菜单风格不符 + 遮盖感重
   - 去掉全屏 scrim 遮罩(0.35 alpha 压暗整页), 改为透明点击层, 点外部仍可关闭
   - Card 限宽 232dp 靠右下角, 从"全宽卡片"变成竖长条
   - 容器色 surfaceContainerHigh -> surfaceContainer, 与导航栏一致; 加 1dp 细边框
   - 动画从全屏 expandVertically + spring 弹跳改为右下角原点 150ms scaleIn
   - Card 加 clickable(enabled=false) 吞掉点击, 避免点卡片空白区误触关闭

2. CW 页左上角退出键点击无效 -> 删除
   根因: CwDecodeScreen(navigateUp: () -> Unit = {}) 是默认空实现, 而
   MainScreen 的 entry<Screen.CwDecode> 调用 CwDecodeScreen() 从未传入
   navigateUp, 所以点击必然无反应。按 AGENTS.md 无死代码原则删除按钮 +
   navigateUp 参数 + cw_back 字符串(五语)。

3. CW 页"正在监听"状态行在停止解码后仍显示 -> 删除
   estimatedPitch 在暂停后保留上次值, 状态行不会消失, 属误导。删除该 Text
   后 estimatedPitch / lastInferenceMs 两个 collector 成为死代码, 一并清理;
   cw_status_listening / cw_status_tone 字符串(五语)同步删除。
   signalStrength(瀑布图) 与 errorMessage(错误提示) 仍在用, 保留。

验证:
- :app:compileDebugKotlin + :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
- :core:domain:test => 31 个 CW 测试全绿 (7+13+3+8)
2026-08-13 14:48:08 +00:00
mckero f9fd7a2cfa Move the LICENSING NOTICE to a separate NOTICE file so GitHub detects AGPL-3.0
GitHub 的 license 检测器(licensee gem)要求 LICENSE 文件是纯许可证原文,
顶部加说明块会导致检测失败(显示 NOASSERTION/Other)。

根 LICENSE 恢复为纯 AGPL-3.0 原文(从 DeepCW-AGPL-3.0.txt 复制),
说明块移到根目录 NOTICE 文件(GitHub 也识别 NOTICE 文件)。
2026-08-13 13:40:45 +00:00
mckero 21f14848da Merge origin/main (resolve CW fldigi vs DeepCW conflicts, keep DeepCW) 2026-08-13 13:39:13 +00:00
Arty Bishop 7cdc2952dc v4.4.6 - AMSAT status page, fully customizable data sources 2026-08-13 09:47:26 +02:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
PingouinFerreux 1982c2d3dc Fixed broken star history chart in README (#240) 2026-08-12 18:22:53 +02:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 24eebdef74 Consolidated app dialogs and tweaked bottom sheets 2026-08-11 14:14:14 +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
mckero c42e1d7b4e fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT"
Satellite QSOs uploaded with BAND=SAT, which is not a legal ADIF Band
enumeration value (the legal values are concrete bands: 160M/80M/.../
2M/70CM/23CM...). Loggers that fail to parse an unknown band fall back
to a default — observed as QSOs landing in 160m. SAT is only legal as
PROP_MODE (propagation mode), which is already sent for v1.

Changes (WaveLogApi):
- bandFromHz(): map TX frequency to the real ADIF band (2M for VHF,
  70CM for UHF, etc.)
- satModeFrom(): derive the ADIF SAT_MODE convention string from TX/RX
  bands ("V/U" = VHF up / UHF down, "U/V", "V/S", "U/S"...; empty for
  same-band links)
- v2 JSON: band=<real band>, add sat_mode when non-empty
- v1 ADIF: <band:> real band, add <sat_mode:> when non-empty;
  PROP_MODE=SAT kept

Verification:
- New tests: SO-50 (145.850 up / 436.795 down) -> band 2M, sat_mode V/U;
  AO-73 (435.150 up / 145.950 down) -> band 70CM, sat_mode U/V;
  same-band -> empty sat_mode; satellite freqs never map to 160M.
- All wavelog payload tests + full domain suite green.
2026-08-09 07:48:15 +00:00
mckero 09ebf1f39a feat(cw): add spectral auto-tune so the decoder finds the CW tone
The fldigi port ran a fixed 600 Hz NCO, so any real signal not inside
600±75 Hz (the 150 Hz filter passband) decoded nothing — the decode rate
was effectively zero unless the tone happened to be on frequency. This
mirrors the behaviour of the removed channelTracker: a sliding spectral
peak detector now steers the NCO to the strongest tone.

Changes:
- Collect raw input, run a 512-pt Hann-windowed FFT every frame, find
  the strongest bin in 300..1500 Hz (CW range), smooth-track it.
- First strong peak locks immediately (no RX reset, so the triggering
  element survives); later large jumps (>120 Hz) retune and reset the
  fldigi state machine; small drifts are eased at 20%.
- Absolute energy floor (peak < 30) so silence/noise never steers.
- estimatedPitch now reflects the tracked tone frequency.

Verification:
- New unit test: 900 Hz "CQ" with decoder initialized at 600 Hz decodes
  correctly and pitch moves to ~900 Hz.
- All 9 decoder tests pass; full domain/cw/radar test suites green.
2026-08-09 05:04:48 +00: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 ec40f29f28 fix(cw): make the waterfall redraw as new spectra arrive
The waterfall backed its pixels with a plain FloatArray and never
signalled Compose, so the Canvas drew once (empty) and stayed frozen —
no spectrum ever appeared. Add a monotonic frame-counter State that
pushSamples bumps per FFT frame; the Canvas reads it in composition to
trigger redraws. Also switch to log-ish intensity scaling so quiet bins
stay dark while strong CW tones pop, matching the DeepCW look.

Applies to both the CW decode screen and the radar transceiver panel.
2026-08-08 15:04:12 +00:00
mckero 5dd7a35a23 fix(cw): remove unused legacy drawables that fail release resource linking
ic_baseline_delete/pause/save/share_24.xml were leftovers from the
Morse Expert View-based UI. They reference ?attr/colorControlNormal
which does not resolve in the release variant (no Material dependency
in feature:cw), breaking assembleRelease. The new Compose UI uses
icons from core:presentation, so these files are dead code.
2026-08-08 14:45:10 +00:00
mckero 4b835bac5c feat(cw): replace reversed Morse Expert engine with a pure-Kotlin fldigi port
Background:
The CW decoder previously shipped a decompiled copy of the proprietary
Morse Expert 1.15 (com/ve3nea/morse_expert + obfuscated classes,
libnativedecoderjni.so, suncompat black-magic) — a copyright liability.
This removes all of it and reimplements the decoder on the open-source
fldigi (GPL v3) CW engine as a faithful pure-Kotlin port with no JNI.

Changes:
- Delete all Morse Expert reverse-engineered code: MainActivity,
  obfuscated packages (B/B0/D/E2/...), suncompat/, pas/nativedecoder,
  armeabi-v7a libnativedecoderjni.so, and the original View-based layouts
  (activity_main, cw_panel_main, options_menu).
- Add a full fldigi CW pipeline in core/domain/cw:
  - CwFldigiDsp: NCO down-conversion, FFT filter, movavg constants
  - CwFftFilt: overlap-add FFT band-pass filter (fftfilt port)
  - MorseTable + SomTable: full Morse code table + SOM codebook
  - CwFldigiDecoder: decode_stream AGC + hysteresis, state machine,
    adaptive speed tracking (5-55 WPM), SOM winner/normalize matching
- Rewrite CwDecodeScreen as pure Compose (DeepCW-style waterfall,
  live decode line, history, status cards) and CwSettingsDialog
  (speed/bandwidth/SOM) with no View interop.
- Replace the Morse Expert panel in TransceiversPage with a Compose
  panel driving the same decoder; mic capture at 8000 Hz.
- Drop the forced armeabi-v7a abiFilters now that no native lib exists.

Verification:
- 8 unit tests pass (CQ/HELLO at 18-20 wpm, A-J at 30 wpm with
  adaptive tracking, dot/dash/Farnsworth edge cases) — all decode
  correctly from synthesized CW.
- :feature:cw and :feature:radar compile; app assembleDebug succeeds.
- APK contains no ve3nea/nativedecoder/morse_expert classes.
2026-08-08 13:58:19 +00:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
Arty Bishop 602b1553a2 v4.4.4 - Icom CAT, components, filters and sources tweaks 2026-08-04 20:51:22 +02:00
atsunatsuandatsunatsu bd0881fb4b Added linear transponder Doppler calculator (#232)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:45:12 +02:00
atsunatsuandatsunatsu a9dd3e6e55 Localized pass date and time formats for Chinese (#231)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:12:10 +02:00
Lukas 905995ab44 Added configurable Radar offset to the sensors output (#230) 2026-08-04 18:08:44 +02:00
Arty Bishop f9806d7f7f Replaced the types selection dialog with modes selection 2026-07-31 16:04:39 +02:00
Arty Bishop 2d3adfc6a6 Added small tweaks to Sources, Components and strings 2026-07-31 12:27:25 +02:00
117 changed files with 3891 additions and 1837 deletions

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+1
View File
@@ -70,3 +70,4 @@ fastlane/readme.md
/app/release/output-metadata.json
/app/release/
/.kotlin/sessions/
.hermes/
+48 -49
View File
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
## Architecture
## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
@@ -34,9 +33,11 @@ data download.
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
**Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
## Build & Run
## Build & Platform
```shell
# Debug build
@@ -50,54 +51,55 @@ data download.
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Key Libraries
## Tech Stack
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing
- **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
## Engineering Heuristics (Lazy = Efficient)
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Bug-Fix Policy
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Roadmap
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
## Copilot Working Mode: Code-Only
-24
View File
@@ -1,27 +1,3 @@
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
This repository combines two separately-licensed components:
1. Look4Sat application code (all modules except the DeepCW model)
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
— Licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only)
Because this combined work incorporates an AGPL-3.0 component, it is
distributed under the GNU Affero General Public License v3.0. GPL-3.0
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
combined work as a whole.
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
--------------------------------------------------------------------------------
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+24
View File
@@ -0,0 +1,24 @@
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
This repository combines two separately-licensed components:
1. Look4Sat application code (all modules except the DeepCW model)
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
— Licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only)
Because this combined work incorporates an AGPL-3.0 component, it is
distributed under the GNU Affero General Public License v3.0. GPL-3.0
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
combined work as a whole.
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
--------------------------------------------------------------------------------
+5 -5
View File
@@ -44,17 +44,17 @@ neural decoding model, licensed under the GNU Affero General Public License v3.0
combined work is distributed under the
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
Model provenance, attribution and the applied int8 quantization are documented in
Model provenance and attribution are documented in
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
locally on-device and does not provide services over a network.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
@@ -64,6 +64,16 @@ class AprsForegroundService : Service() {
}
private fun startReporting() {
// onStartCommand reaches here for every ACTION_START and for the null
// intent that START_STICKY delivers on restart. Without this guard each
// call built a fresh AprsReporter and overwrote the field, leaving the
// previous one running with its own scope and timer: the server then
// received one duplicate position report per leaked instance per cycle,
// and ACTION_STOP could only ever stop the newest one.
reporter?.let { existing ->
if (existing.isRunning) return
existing.stop()
}
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { MainScreen() }
MainTheme(isDarkTheme = true) { NavRoot() }
}
}
@@ -20,20 +20,18 @@ package com.rtbishop.look4sat
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -51,7 +49,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -67,6 +65,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -83,6 +82,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -97,41 +97,45 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.status.SatStatusScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
LaunchedEffect(deeplink) {
deeplink?.let {
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { slideInTransition },
popTransitionSpec = { slideOutTransition },
predictivePopTransitionSpec = { slideOutTransition },
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
entry<Screen.Passes> { MainScreen() }
entry<RadarDestination> {
Scaffold { innerPadding ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
}
}
}
@@ -139,7 +143,7 @@ fun NavRoot(deeplink: String? = null) {
}
@Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) {
fun MainScreen() {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
@@ -148,27 +152,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
if (it != -1) it else Int.MAX_VALUE
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
// Menu layout is resolved in core:domain so the bar and the settings editor
// cannot disagree, and so Settings can never be pushed out of both menus.
val allNavItems = listOf(
Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming,
Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings
)
val menuLayout = remember(
otherSettings.screenOrder, otherSettings.subMenuOrder, otherSettings.hiddenScreens
) {
MenuLayout.resolve(
allScreenIds = allNavItems.map { it.screenId },
screenOrder = otherSettings.screenOrder,
subMenuOrder = otherSettings.subMenuOrder,
hiddenScreenIds = otherSettings.hiddenScreens
)
}
val moreNavItems = remember(allNavItems, subOrder) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
val mainNavItems = remember(menuLayout) {
menuLayout.mainIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
val moreNavItems = remember(menuLayout) {
menuLayout.moreIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// Intercept Back while the More menu is open: close the menu first
@@ -188,20 +192,16 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
NavigationSuiteScaffold(
navigationSuiteItems = {
mainNavItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.AmSat -> screen is Screen.AmSat
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
// Screen subclasses are data objects, so identity is enough and
// newly added pages highlight without touching this call site.
val isSelected = currentKey == screen
item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
@@ -260,7 +260,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
}
}
entry<Screen.Radar> {
@@ -287,7 +286,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
CwDecodeScreen()
}
entry<Screen.AmSat> {
SatStatusScreen(container = container)
SatStatusDestination()
}
entry<Screen.WavelogLog> {
WavelogLogScreen(queue = container.wavelogQueue)
@@ -346,14 +345,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
// More-menu popup panel (slim strip anchored to the bottom-right corner)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
enter = expandVertically(
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) + fadeIn(),
exit = shrinkVertically() + fadeOut()
enter = scaleIn(
animationSpec = tween(150),
transformOrigin = TransformOrigin(1f, 1f)
) + fadeIn(animationSpec = tween(150)),
exit = scaleOut(
animationSpec = tween(120),
transformOrigin = TransformOrigin(1f, 1f)
) + fadeOut(animationSpec = tween(120))
) {
MoreMenuPopup(
items = moreNavItems,
@@ -369,4 +372,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
}
}
@@ -1,13 +1,13 @@
/*
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
*
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
* (MainScreen's AnimatedVisibility + spring).
* A slim right-aligned strip above the bottom bar, vertical menu items (icon+text+arrow),
* current page highlighted; tap outside to close, tap an item to navigate. No dimming scrim, so the
* page stays readable. Open/close animation is driven by the caller (MainScreen's AnimatedVisibility).
*/
package com.rtbishop.look4sat
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -45,31 +46,30 @@ fun MoreMenuPopup(
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
// Transparent catcher: taps outside the card dismiss the menu without
// dimming the page behind it.
.clickable(onClick = onDismiss)
) {
Card(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp),
.padding(12.dp)
.widthIn(max = 232.dp)
// Swallow taps on the card so they do not reach the dismiss
// catcher underneath.
.clickable(enabled = false) {},
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
items.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
// Screen subclasses are data objects, so identity is enough. The
// old per-type when was missing AmSat and Roaming, leaving those
// pages unhighlighted while open.
val isSelected = currentKey == screen
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -35,27 +35,36 @@ class AprsIsClient(
fun connect() {
disconnect()
val s = Socket()
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
try {
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
} catch (e: Exception) {
// Close the local socket before re-throwing, so it does not leak when
// an exception is raised after s.connect() but before socket = s.
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
// accumulate leaked fds until the process cannot open any more files.
runCatching { s.close() }
throw e
}
}
@@ -68,24 +77,31 @@ class AprsIsClient(
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
// Read the server response inside the same lock: disconnect() (called
// concurrently from stop()/reconnect on another thread) nulls
// writer/reader/socket and closes them. Reading outside the lock raced
// with that: the response read could hit a just-closed socket and the
// swallowing runCatching reported Pair(true,"OK") for a packet that
// never left, or read through a stale reference. Serialising keeps
// the read on the connection this thread just wrote to. The 3 s read
// timeout bounds how long a concurrent disconnect waits.
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}.getOrElse { Pair(true, "OK") }
}
}
/** Read one line (server response; throws on timeout) */
@@ -181,8 +181,22 @@ class CwDeepDecoder(context: Context) : ICwDecoder {
val overflow = buffer.drainOverflow()
if (overflow.isNotEmpty()) {
for (v in overflow) {
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
// Flush before appending when the buffer is full, so large batches
// (e.g. 47999 samples already accumulated + 64000 new overflow)
// do not silently drop audio that scrolled out of the live window.
if (archiveSize >= archiveBuffer.size) {
val audio = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
try {
archiveDecode(audio)
} catch (t: Throwable) {
if (t is CancellationException) throw t
Log.e(TAG, "archive decode failed", t)
}
}
archiveBuffer[archiveSize++] = v
}
// Final flush when threshold is reached (e.g. exactly 48000 accumulated).
if (archiveSize >= ARCHIVE_THRESHOLD) {
val audio = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
@@ -28,6 +28,12 @@ import android.util.Log
*/
internal object CwProbe {
/** Keep the diagnostic file bounded: the decoder writes two lines per
* 1.5 s inference tick (~170 KB/hour), so without a cap it grows without
* limit on every release build. Truncate instead of deleting so the
* probe keeps the last diagnostics before a crash. */
private const val MAX_FILE_BYTES = 1_048_576L // 1 MiB
private var dir: java.io.File? = null
fun init(context: Context) {
@@ -39,6 +45,7 @@ internal object CwProbe {
runCatching {
val line = "${System.currentTimeMillis()} $label"
val file = java.io.File(target, "probe_cw.txt")
if (file.length() > MAX_FILE_BYTES) file.delete()
file.appendText("$line\n")
Log.i("CwProbe", line)
}
@@ -45,7 +45,12 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -76,10 +76,12 @@ class BluetoothReporter(
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
reporterScope.launch {
var opened: android.bluetooth.BluetoothSocket? = null
try {
rotatorConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = socket
socket.connect()
rotatorSocket = socket
rotatorStream = socket.outputStream
@@ -87,6 +89,11 @@ class BluetoothReporter(
Log.i(tag, "Rotator connected to $rotatorDeviceId")
} catch (e: Exception) {
Log.e(tag, "Rotator connect error: ${e.message}")
// Close the socket we opened, otherwise a failure after connect()
// leaks it: nothing else holds a reference once this returns.
runCatching { opened?.close() }
rotatorSocket = null
rotatorStream = null
rotatorConnected = false
} finally {
rotatorConnecting = false
@@ -97,10 +104,12 @@ class BluetoothReporter(
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
reporterScope.launch {
var opened: android.bluetooth.BluetoothSocket? = null
try {
frequencyConnecting = true
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = socket
socket.connect()
frequencySocket = socket
frequencyStream = socket.outputStream
@@ -108,6 +117,11 @@ class BluetoothReporter(
Log.i(tag, "Frequency connected to $frequencyDeviceId")
} catch (e: Exception) {
Log.e(tag, "Frequency connect error: ${e.message}")
// Close the socket we opened, otherwise a failure after connect()
// leaks it: nothing else holds a reference once this returns.
runCatching { opened?.close() }
frequencySocket = null
frequencyStream = null
frequencyConnected = false
} finally {
frequencyConnecting = false
@@ -42,6 +42,9 @@ class Ft817Controller(
private val commandDelayMs = 200L
private val maxAckReadFailures = 3
/** Largest frequency the 4-byte BCD / 10 Hz CAT field can represent. */
private val maxFrequencyHz = 999_999_990L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
@@ -53,9 +56,11 @@ class Ft817Controller(
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
var opened: android.bluetooth.BluetoothSocket? = null
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = btSocket
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
@@ -66,6 +71,13 @@ class Ft817Controller(
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
// Close the socket we opened. Without this a failure after connect()
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
// disconnect() only closes what already reached the fields.
runCatching { opened?.close() }
socket = null
outputStream = null
inputStream = null
isConnected = false
false
}
@@ -91,6 +103,16 @@ class Ft817Controller(
}
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
// The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution,
// so the protocol cannot express anything above 999,999,990 Hz. Below
// that the encoder is exact; above it, the %08d formatting silently
// drops the leading digit and the radio receives a frequency ten
// times lower (e.g. 1267.6 MHz becomes 126.76 MHz), and the tracking
// loop's read-back then locks onto the wrong band. Reject instead.
if (frequencyHz > maxFrequencyHz) {
Log.e(tag, "setFrequency rejected: $frequencyHz Hz exceeds FT-817 CAT limit $maxFrequencyHz")
return@withContext false
}
ioMutex.withLock {
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
}
@@ -67,9 +67,11 @@ class Ic705Controller(
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
var opened: android.bluetooth.BluetoothSocket? = null
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
opened = btSocket
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
@@ -84,6 +86,13 @@ class Ic705Controller(
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
// Close the socket we opened. Without this a failure after connect()
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
// disconnect() only closes what already reached the fields.
runCatching { opened?.close() }
socket = null
outputStream = null
inputStream = null
isConnected = false
false
}
@@ -71,14 +71,19 @@ class NetworkReporter(
private fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
reporterScope.launch {
var opened: SocketChannel? = null
try {
rotatorConnecting = true
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorSocket = opened
rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false
// Close a socket that connected but failed during setup, so a
// broken channel is never left referenced without a closer.
opened?.close()
} finally {
rotatorConnecting = false
}
@@ -88,14 +93,17 @@ class NetworkReporter(
private fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
reporterScope.launch {
var opened: SocketChannel? = null
try {
frequencyConnecting = true
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencySocket = opened
frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false
opened?.close()
} finally {
frequencyConnecting = false
}
@@ -111,6 +119,17 @@ class NetworkReporter(
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
onError()
// The channel failed a write: drop it and its closure obligation.
// Leaving it referenced lets the next connect overwrite the field
// and leak the old channel. Only the field the caller passed is
// cleared, matching the connected=false the onError sets.
if (socket === rotatorSocket) {
rotatorSocket?.close()
rotatorSocket = null
} else if (socket === frequencySocket) {
frequencySocket?.close()
frequencySocket = null
}
}
}
@@ -267,12 +267,18 @@ class RadioTrackingService(
if (tuningRadio.isEmpty()) {
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
// Only remember the frequency we actually wrote: if the radio
// rejects it (FT-817 CAT limit) or the link dropped, keeping
// lastSetTxFreq updated would make the manual-tuning detector
// see a phantom dial change on the next read-back.
if (txNow.setFrequency(txRadioFreq)) {
lastSetTxFreq = txRadioFreq.toDouble()
}
}
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
if (rxNow.setFrequency(rxRadioFreq)) {
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
}
@@ -450,13 +456,15 @@ class RadioTrackingService(
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
if (rxRadioFreq != null) {
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
radio.setWorkingFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
if (radio.setWorkingFrequency(rxRadioFreq)) {
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
if (txRadioFreq != null) {
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
radio.setTxVfoFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
if (radio.setTxVfoFrequency(txRadioFreq)) {
lastSetTxFreq = txRadioFreq.toDouble()
}
}
}
@@ -97,6 +97,13 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
@@ -35,13 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
@@ -52,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
} catch (_: Exception) {
emptyList()
}
}
@@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
} catch (_: Exception) {
emptyList()
}
}
@@ -82,22 +84,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) {
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 6).map { d ->
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
@@ -112,7 +114,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
)
}
}
val days = (0 until 6).map { d ->
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
@@ -34,9 +34,13 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -50,6 +54,10 @@ class SatelliteRepo(
private val _isCalculating = MutableStateFlow(false)
override val isCalculating: StateFlow<Boolean> = _isCalculating
// Serializes pass calculation. Callers queue instead of being dropped: a
// dropped call would silently discard the filter the user just applied.
private val calculationMutex = Mutex()
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
@@ -63,19 +71,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -117,45 +129,55 @@ class SatelliteRepo(
invertAosTimeWindow: Boolean,
modes: List<String>
) {
_isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
// Queue behind any in-flight calculation rather than dropping this call:
// every invocation carries filter settings the user just chose, so a
// dropped one leaves the list showing results for the previous filter.
calculationMutex.withLock {
_isCalculating.value = true
try {
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value
withContext(dispatcher) {
val idsWithModes = localStorage.getIdsWithModes(modes)
val stationPos = settingsRepo.stationPosition.value
val filteredSatellites = if (idsWithModes.isEmpty()) {
currentSatellites
} else {
currentSatellites.filter { it.data.catnum in idsWithModes }
}
// Compute passes for each satellite in parallel
val passLists = coroutineScope {
filteredSatellites.map { satellite ->
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
}.awaitAll()
}
// Flatten and filter in a single pass
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) {
for (pass in list) {
if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass)
}
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
} finally {
// finally: a thrown/cancelled calculation must not leave the
// progress indicator spinning forever.
_isCalculating.value = false
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
}
private fun isAosInRange(
@@ -170,7 +192,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -29,15 +29,18 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
class SettingsRepo(
private val context: android.content.Context,
@@ -69,9 +72,9 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
@@ -100,13 +103,16 @@ class SettingsRepo(
private val keyWavelogApiKey = "wavelogApiKey"
private val keyWavelogStationId = "wavelogStationId"
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val separatorComma = ","
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -114,10 +120,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -126,10 +132,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -144,7 +150,6 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -155,16 +160,13 @@ class SettingsRepo(
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
invertAosTimeWindow
)
}
//endregion
@@ -181,7 +183,10 @@ class SettingsRepo(
}
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
// Wrap an out-of-range longitude into -180..180. Subtracting 180 (the
// previous behaviour) mapped 270 to +90 instead of -90, i.e. the wrong
// hemisphere, and 360 to +180 instead of 0.
val newLongitude = ((longitude + 180.0).mod(360.0)) - 180.0
val locator = positionToQth(latitude, newLongitude) ?: return false
setStationPosition(latitude, newLongitude, altitude, locator)
return true
@@ -262,6 +267,7 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
@@ -325,6 +331,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -334,6 +344,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -342,7 +353,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -354,6 +365,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -390,6 +403,9 @@ class SettingsRepo(
putString(keyWavelogApiKey, new.wavelogApiKey)
putString(keyWavelogStationId, new.wavelogStationId)
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -413,7 +429,9 @@ class SettingsRepo(
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
)
//endregion
@@ -445,6 +463,7 @@ class SettingsRepo(
transceiversUrl = txUrl
)
}
//endregion
//region # Radio control settings
@@ -484,5 +503,27 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
//endregion
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
}
@@ -1,136 +0,0 @@
package com.rtbishop.look4sat.core.data.source
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import java.util.regex.Pattern
/**
* AMSAT satellite status page parser (https://amsat.org/status/)
*
* Page structure (static HTML, verified 2026-08):
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
* docTips.show('id') link
* - Report details live in the page's inline JS:
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
*
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
*/
object AmSatParser {
private val STATUS_COLORS = mapOf(
"#648fff" to 0xFF648FFF.toLong(),
"#ffb000" to 0xFFFFB000.toLong(),
"#dc267f" to 0xFFDC267F.toLong(),
"#fe6100" to 0xFFFE6100.toLong()
)
private val GRAY = 0xFFC0C0C0.toLong()
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
private val tipRe = Pattern.compile(
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
)
/** Parse the full page */
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
val reports = parseReports(html)
val statuses = parseStatusTable(html, reports)
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
}
/** Extract all reports (tips.* JS arrays) */
fun parseReports(html: String): Map<String, SatReport> {
val map = mutableMapOf<String, SatReport>()
val m = tipRe.matcher(html)
while (m.find()) {
val id = m.group(1)
val parts = m.group(2).split("<br>")
map[id] = SatReport(
id = id,
statusText = parts.getOrElse(0) { "" }.trim(),
call = parts.getOrElse(1) { "" }.trim(),
grid = parts.getOrElse(2) { "" }.trim(),
dateUtc = parts.getOrElse(3) { "" }.trim(),
timeUtc = parts.getOrElse(4) { "" }.trim()
)
}
return map
}
/** Parse the status table (48 satellite rows) */
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
val result = mutableListOf<SatStatus>()
val tables = extractTables(html)
for (table in tables) {
val rows = rowRe.matcher(table)
val parsed = mutableListOf<SatStatus>()
var rowIndex = 0
var dayHeaders: List<String> = emptyList()
while (rows.find()) {
val rowHtml = rows.group(1)
val cells = cellRe.matcher(rowHtml)
val cellList = mutableListOf<String>()
while (cells.find()) cellList.add(cells.group(1))
if (rowIndex == 0) {
// Header: Name + 6 days (each colspan=12)
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
rowIndex++
continue
}
if (cellList.size < 7) { rowIndex++; continue }
val name = stripHtml(cellList[0])
if (name.isBlank()) { rowIndex++; continue }
val days = mutableListOf<SatDay>()
for (d in 0 until 6) {
val start = 1 + d * 12
val end = start + 12
val slots = (start until end).mapNotNull { i ->
cellList.getOrNull(i)?.let { cell ->
val bg = bgRe.matcher(cell)
val color = if (bg.find()) {
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
} else GRAY
val link = linkRe.matcher(cell)
val ids = mutableListOf<String>()
while (link.find()) ids.add(link.group(1))
val hasReport = ids.isNotEmpty()
val count = if (hasReport) {
stripHtml(cell).trim().toIntOrNull() ?: ids.size
} else 0
SatSlot(
statusColor = if (hasReport) color else GRAY,
count = count,
reportIds = ids
)
}
}
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
}
parsed.add(SatStatus(name, days))
rowIndex++
}
if (parsed.isNotEmpty()) {
result.addAll(parsed)
break
}
}
return result
}
private fun extractTables(html: String): List<String> {
val result = mutableListOf<String>()
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
while (m.find()) result.add(m.group())
return result
}
private fun stripHtml(s: String): String =
s.replace(Regex("<[^>]+>"), "").trim()
}
@@ -20,11 +20,11 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -37,35 +37,27 @@ class RemoteSource(
try {
val fileUri = uri.toUri()
contentResolver.openInputStream(fileUri)?.buffered()
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource file stream exception: $exception")
null
}
}
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://amsat.org/status/")
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat status exception: $exception")
null
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
@@ -82,8 +74,11 @@ class RemoteSource(
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat catalog exception: $exception")
null
}
}
@@ -98,8 +93,11 @@ class RemoteSource(
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat reports exception: $exception")
null
}
}
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
} finally {
recorder.stop()
recorder.release()
// stop() on a recorder that never started throws
// IllegalStateException; wrapping each cleanup step separately
// keeps the original error (e.g. a permission denial during
// startRecording) intact and guarantees release() still runs.
// Without this, a start failure masked the real cause AND leaked
// the recorder because release() was skipped.
runCatching { recorder.stop() }
runCatching { recorder.release() }
}
}.flowOn(Dispatchers.IO)
}
@@ -125,6 +125,10 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
}
private class FakeLocalSource : ILocalSource {
@@ -166,10 +170,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -177,7 +181,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -194,13 +198,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override suspend fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
@@ -223,6 +227,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -0,0 +1,181 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "")
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override suspend fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
}
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
import java.util.Locale
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
@@ -35,20 +36,31 @@ object AprsPacket {
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/** Altitude extension /A=00000 (feet) */
/**
* Altitude extension /A=000000 (feet). The field is a fixed six-digit
* decimal, so a negative altitude (below sea level, or a bad GPS fix) must
* be clamped: "%06d" of -164 yields "/A=-00164", which is not a valid
* extension and corrupts the rest of the comment field.
*/
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
return String.format(Locale.ROOT, "/A=%06d", feet)
}
/** Speed/course extension (knots/degrees) */
/**
* Speed/course extension /CCC/SSS (degrees/knots). Course wraps into
* 0..359 and speed is clamped to three digits, because "%03d" of an
* out-of-range value widens the field and breaks the fixed-width format.
*/
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt()
return String.format("/%03d/%03d", bearing.toInt(), knots)
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
val course = ((bearing.toInt() % 360) + 360) % 360
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
}
}
@@ -100,17 +112,17 @@ class AprsPosition(
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format("%d . ", minutes / 10)
3 -> String.format("%02d. ", minutes)
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
else -> String.format("%02d.%02d", minutes, hundredths)
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format("%02d%s%c", degrees, frac, ns)
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format("%03d%s%c", degrees, frac, ew)
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
}
}
}
@@ -0,0 +1,23 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
object Constants {
const val FREQ_OFFSET_MIN_HZ = -50_000L
const val FREQ_OFFSET_MAX_HZ = 50_000L
}
@@ -23,10 +23,10 @@ data class SatDay(
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 6 days of state */
/** One satellite, 5 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
val days: List<SatDay> // 5 天(新→旧)
)
/** Overall page parse result */
@@ -29,8 +29,7 @@ data class PassesSettings(
val minElevation: Double,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val invertAosTimeWindow: Boolean = false
)
data class RCSettings(
@@ -42,6 +41,7 @@ data class RCSettings(
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
@@ -73,7 +73,10 @@ data class OtherSettings(
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
val wavelogAutoUpload: Boolean = false
val wavelogAutoUpload: Boolean = false,
// Upstream radar compass offset (merged from rt-bishop)
val radarCompassOffset: Float = 0f,
val radarCompassOffsetElev: Float = 0f
)
data class DataSourcesSettings(
@@ -0,0 +1,143 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.navigation
/**
* Single source of truth for the navigation menu layout.
*
* The bottom bar holds at most [MAIN_SLOTS] pages; the rest live behind the More
* button. Both the bar and the settings editor resolve through here, so the list
* the user edits is exactly the list they get.
*
* Lives in `core:domain` (pure Kotlin) so it is unit-testable and KMP-ready.
*/
object MenuLayout {
/** Bottom-bar capacity, including the Settings entry. */
const val MAIN_SLOTS = 5
/** Must stay reachable from some menu, so the user cannot lock themselves out. */
const val SETTINGS_ID = "Settings"
/** Bar contents for a fresh install. */
val defaultMainOrder = listOf("Satellites", "Passes", "Radar", "Map", SETTINGS_ID)
/** More-menu contents for a fresh install. */
val defaultMoreOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
/** What the bar and the More menu actually show. */
data class Layout(val mainIds: List<String>, val moreIds: List<String>)
/** A menu assignment ready to be persisted to settings. */
data class Assignment(val screenOrder: List<String>, val subMenuOrder: List<String>)
/**
* Map persisted preferences onto the two menus.
*
* A page named by neither persisted list is new to this install and follows
* the defaults, so upgrades never lose pages. Visible pages that overflow
* [MAIN_SLOTS] fall through to the More menu instead of disappearing, and
* [SETTINGS_ID] always survives the slot cut.
*/
fun resolve(
allScreenIds: List<String>,
screenOrder: List<String>,
subMenuOrder: List<String>,
hiddenScreenIds: List<String>
): Layout {
val visible = allScreenIds.filter { it !in hiddenScreenIds || it == SETTINGS_ID }
val wantMain = ArrayList<String>()
val wantMore = ArrayList<String>()
for (id in visible) {
when {
id in screenOrder -> wantMain.add(id)
id in subMenuOrder -> wantMore.add(id)
id in defaultMainOrder -> wantMain.add(id)
else -> wantMore.add(id)
}
}
wantMain.sortBy { rank(it, screenOrder, defaultMainOrder) }
wantMore.sortBy { rank(it, subMenuOrder, defaultMoreOrder) }
// Reserve the Settings slot before cutting so it cannot be truncated away.
val settingsOnBar = SETTINGS_ID in wantMain
val budget = if (settingsOnBar) MAIN_SLOTS - 1 else MAIN_SLOTS
val main = ArrayList<String>(MAIN_SLOTS)
for (id in wantMain) {
if (id == SETTINGS_ID) continue
if (main.size == budget) break
main.add(id)
}
if (settingsOnBar) main.add(SETTINGS_ID)
val overflow = wantMain.filter { it !in main }
return Layout(mainIds = main, moreIds = overflow + wantMore)
}
/** Move [screenId] onto the bar, evicting the last movable page when full. */
fun moveToMain(
screenId: String,
allScreenIds: List<String>,
screenOrder: List<String>,
subMenuOrder: List<String>
): Assignment {
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
val main = current.mainIds.toMutableList()
val more = current.moreIds.toMutableList()
val wasInMore = screenId in more
more.remove(screenId)
if (screenId !in main) {
val at = main.indexOf(SETTINGS_ID).let { if (it == -1) main.size else it }
main.add(at, screenId)
}
// Only evict when we actually added a new page from More; internal reordering must not evict.
if (wasInMore) {
val movable = main.filter { it != SETTINGS_ID && it != screenId }
if (main.size > MAIN_SLOTS && movable.isNotEmpty()) {
val evicted = movable.last()
main.remove(evicted)
more.add(0, evicted)
}
}
return Assignment(screenOrder = main, subMenuOrder = more)
}
/** Move [screenId] off the bar; Settings is refused so it stays reachable. */
fun moveToMore(
screenId: String,
allScreenIds: List<String>,
screenOrder: List<String>,
subMenuOrder: List<String>
): Assignment {
if (screenId == SETTINGS_ID) return Assignment(screenOrder, subMenuOrder)
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
val more = current.moreIds.toMutableList()
if (screenId !in more) more.add(screenId)
return Assignment(
screenOrder = current.mainIds.filter { it != screenId },
subMenuOrder = more
)
}
private fun rank(id: String, persisted: List<String>, fallback: List<String>): Int {
val persistedIndex = persisted.indexOf(id)
if (persistedIndex != -1) return persistedIndex
val fallbackIndex = fallback.indexOf(id)
return if (fallbackIndex != -1) fallbackIndex else Int.MAX_VALUE
}
}
@@ -424,8 +424,12 @@ object CelestialComputer {
}
if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold
daynum = sunrise
// Phase 4: fast-forward through the day until sun drops back below threshold.
// Start from just after sunrise (small offset) so the sun is clearly above
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
@@ -35,6 +35,7 @@ interface IMainContainer {
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar
fun providePairedBluetoothDevices(): List<Pair<String, String>>
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
fun provideNetworkReporter(): IReporter
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>)
suspend fun setModes(modes: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -32,9 +32,9 @@ interface ISettingsRepo {
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
fun setSelectedSatModes(modes: List<String>)
//endregion
//region # Passes filter settings
@@ -78,4 +78,9 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
}
@@ -23,9 +23,6 @@ interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
/** Fetch AMSAT status page HTML (with UA; null = failure) */
suspend fun getStatusHtml(): String?
/** Fetch AMSAT API catalog (JSON string; null on failure) */
suspend fun getAmSatCatalog(): String?
@@ -52,6 +52,15 @@ object Sources {
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
@@ -70,8 +70,13 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
// Add the day fraction numerically. Building it by string surgery breaks
// below 1e-3, where Double.toString() switches to scientific notation and
// dropping the first character removes a significant digit instead of the
// leading zero: 00:01:00 yielded "25001.944444444444445E-4" -> 2.50019,
// a silently valid epoch about 26 years off.
val dayFraction = (hour + min + sec + ms) / 86400000.0
val epoch = "${year.substring(2)}$day".toDouble() + dayFraction
OrbitalData(
name = name,
epoch = epoch,
@@ -91,7 +96,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -114,11 +114,30 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
}
}
@@ -41,6 +41,21 @@ fun Double.round(decimals: Int): Double {
return kotlin.math.round(this * multiplier) / multiplier
}
fun String.aprsPasscode(): Int {
val callsign = this.trim().uppercase().substringBefore('-') // commonly strip SSID
var hash = 0x73E2
var i = 0
while (i < callsign.length) {
hash = hash xor (callsign[i].code shl 8)
i++
if (i < callsign.length) {
hash = hash xor callsign[i].code
i++
}
}
return hash and 0x7FFF
}
//fun String.getHash(type: String = "SHA-256"): String {
// val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
@@ -81,9 +81,18 @@ fun clipLat(latitude: Double): Double {
}
fun clipLon(longitude: Double): Double {
// Reduce with a modulo so a single pass always terminates. The previous
// while-loop never returned for extreme inputs: Infinity stays Infinity
// after subtracting 360, so the loop ran forever, and a ~1e12 degree value
// took billions of iterations. NaN still passes through to clip() and is
// returned as NaN, which is the same behaviour as before.
if (!longitude.isFinite()) return longitude
var result = longitude
while (result < MIN_LONGITUDE) result += 360.0
while (result > MAX_LONGITUDE) result -= 360.0
result = ((result + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
// The closed interval [-180, 180] keeps +180 for a value that lands exactly
// on the positive boundary (old loop: 180 stays 180, only > 180 wraps);
// -180 is reserved for values that actually came from the west side.
if (result == -180.0 && longitude > 0.0) result = 180.0
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
}
@@ -74,8 +74,14 @@ fun qthToPosition(locator: String): GeoPos? {
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = longitude + 180
val newLatitude = latitude + 90
// The grid spans [0, 360) lon and [0, 180) lat once shifted. Clamping the
// field index alone (coerceIn below) is not enough: at exactly +90 lat or
// +180 lon the field saturates to R while the square/subsquare terms come
// from a modulo that has already wrapped to 0, so the encoded locator
// decoded back 10 degrees of latitude / 20 degrees of longitude away.
// Nudge the upper bound into the last cell instead.
val newLongitude = (longitude + 180).coerceIn(0.0, 360.0 - 1e-9)
val newLatitude = (latitude + 90).coerceIn(0.0, 180.0 - 1e-9)
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude % 20) / 2).toInt()
@@ -94,11 +100,13 @@ fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): Stri
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
return lat in -90.0..90.0 && lon in -180.0..180.0
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
// Maidenhead fields are A-R (18 x 18). Subsquare letters are A-X (24).
// Accepting S-X in the first pair decodes to latitude >90 / longitude >180.
return locator.matches("[a-rA-R]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
}
/**
@@ -106,6 +106,51 @@ object WaveLogApi {
WavelogResult.Success("")
}
/**
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
* that fails to parse an illegal band falls back to a default such as
* 160m. Satellite QSOs must carry the real band of the TX frequency.
*/
fun bandFromHz(freqHz: Long): String = when {
freqHz >= 1240_000_000 -> "23CM"
freqHz >= 902_000_000 -> "33CM"
freqHz >= 420_000_000 -> "70CM"
freqHz >= 222_000_000 -> "1.25M"
freqHz >= 144_000_000 -> "2M"
freqHz >= 50_000_000 -> "6M"
freqHz >= 28_000_000 -> "10M"
freqHz >= 24_890_000 -> "12M"
freqHz >= 21_000_000 -> "15M"
freqHz >= 18_068_000 -> "17M"
freqHz >= 14_000_000 -> "20M"
freqHz >= 10_000_000 -> "30M"
freqHz >= 7_000_000 -> "40M"
freqHz >= 5_102_000 -> "60M"
freqHz >= 3_500_000 -> "80M"
freqHz >= 1_800_000 -> "160M"
else -> "160M"
}
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
private fun bandLetter(freqHz: Long): String = when {
freqHz >= 1_240_000_000 -> "S"
freqHz >= 420_000_000 -> "U"
freqHz >= 144_000_000 -> "V"
else -> "V"
}
/**
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
*/
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
if (rxFreqHz <= 0) return ""
val up = bandLetter(txFreqHz)
val down = bandLetter(rxFreqHz)
return if (up == down) "" else "$up/$down"
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
@@ -142,10 +187,11 @@ object WaveLogApi {
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
@@ -155,6 +201,7 @@ object WaveLogApi {
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
@@ -178,14 +225,15 @@ object WaveLogApi {
}
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
return buildString {
append(field("call", qso.call))
append(field("band", "SAT"))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
@@ -195,9 +243,14 @@ object WaveLogApi {
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
append(field("rst_sent", "59"))
append(field("rst_rcvd", "59"))
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
// Send the grid at full precision. Truncating to 4 characters threw
// away the 6-character locator the QRZ lookup provides, coarsening the
// stored position from ~4.6 km to ~100 km and making a QSO logged via
// v1 disagree with the same QSO logged via v2 (which sends it whole).
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
append(field("prop_mode", "SAT"))
}
append("<eor>")
@@ -81,6 +81,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
@Synchronized
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
@@ -18,9 +18,11 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@ExperimentalCoroutinesApi
@@ -111,6 +113,47 @@ class DataParserTest {
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
}
private fun csvWithEpoch(epoch: String) = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
@Test
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
// Regression: the day fraction used to be built by string surgery
// (Double.toString().substring(1)), but toString switches to scientific
// notation below 1e-3, so the leading significant digit was truncated.
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
// roughly 26 years off, with no exception to reveal it.
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
assertEquals(25001.0, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
assertEquals(25001.5, sat.epoch, 1e-9)
}
@Test
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
}
@Test
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseTLEStream(validTLEStream)
@@ -239,4 +282,10 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
}
}
@@ -3,12 +3,17 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DopplerFrequencyCalculatorTest {
private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
@@ -18,7 +23,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = "linear", info = info, isAlive = true,
uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -68,19 +73,99 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 0)
// With zero Doppler, result equals mapDownlinkToUplink output
assertEquals(145_200_000L, uplink)
}
@@ -95,24 +180,23 @@ class DopplerFrequencyCalculatorTest {
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
val xpdr = linearTransponder()
val orbitalPos = pos(7.0) // ~7 km/s receding
val orbitalPos = pos(7.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Uplink freq should be Doppler shifted UP (compensating for receding)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
@@ -171,13 +255,11 @@ class DopplerFrequencyCalculatorTest {
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Inverted: offset from high end → maps to high end of uplink
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
// downlink → uplink → downlink should round-trip
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
@@ -185,7 +267,6 @@ class DopplerFrequencyCalculatorTest {
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
// Doppler round-trip: small residual due to freq-dependent Doppler
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
@@ -70,7 +70,9 @@ class QthConverterTest {
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
// Exact positive bounds belong to the final cell, not a modulo-wrapped
// R-field/0-square combination that decodes 10°/20° away.
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
@@ -85,6 +87,55 @@ class QthConverterTest {
}
}
@Test
fun `Encoded locator always decodes back within one cell`() {
// An 8-char cell is 30" lon x 15" lat, so a correct encode/decode pair
// can never differ by more than that. Field clamping used to break this
// near +90 / +180 and produced errors up to 10 deg lat / 20 deg lon.
var worstLat = 0.0
var worstLon = 0.0
var worst = ""
var lat = -90.0
while (lat <= 90.0) {
var lon = -180.0
while (lon <= 180.0) {
val qth = positionToQth(lat, lon, 8)
?: error("valid position rejected: ($lat, $lon)")
val pos = qthToPosition(qth) ?: error("own output rejected: $qth")
val dLat = kotlin.math.abs(pos.latitude - lat)
val dLon = kotlin.math.abs(pos.longitude - lon)
if (dLat > worstLat || dLon > worstLon) {
worstLat = maxOf(worstLat, dLat)
worstLon = maxOf(worstLon, dLon)
worst = "($lat, $lon) -> $qth -> (${pos.latitude}, ${pos.longitude})"
}
lon += 0.5
}
lat += 0.5
}
assert(worstLat <= 0.01 && worstLon <= 0.01) {
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
}
}
@Test
fun `Given out of range longitude returns null`() {
// Maidenhead only covers -180..180; 181..360 used to be accepted and
// encoded into a plausible-looking locator 20-200 deg away.
assert(positionToQth(0.0, 181.0) == null)
assert(positionToQth(0.0, 270.0) == null)
assert(positionToQth(0.0, 360.0) == null)
}
@Test
fun `Given locator with out of range field returns null`() {
// Fields run A-R; S-X in the first pair decoded past the poles.
assert(qthToPosition("SS00aa") == null)
assert(qthToPosition("XX99xx") == null)
assert(qthToPosition("AS00aa") == null)
assert(qthToPosition("AX99xx") == null)
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH Locator screenshot: OL42
@@ -0,0 +1,91 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
* course/speed extensions with the JVM default locale produced Eastern Arabic
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
* packets.
*
* Regression guard: every formatted field must stay ASCII regardless of the
* default locale.
*/
class AprsPacketLocaleTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
@Test
fun position_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertEquals("3954.25N/11624.44E>", encoded)
}
@Test
fun position_staysAsciiUnderBengaliLocale() {
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertEquals("3352.13S/15112.56E>", encoded)
}
@Test
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
val altitude = AprsPacket.formatAltitude(100.0)
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
assertEquals("/A=000328", altitude)
assertEquals("/090/019", courseSpeed)
assertEquals("r/39.904/116.407/100", filter)
}
@Test
fun altitude_clampsNegativeToKeepSixDigitField() {
// "%06d" of a negative value yields "/A=-00164": the '-' takes a digit
// slot, so the extension is no longer a valid fixed-width field.
assertEquals("/A=000000", AprsPacket.formatAltitude(-50.0))
assertEquals("/A=000000", AprsPacket.formatAltitude(-1.0))
assertEquals("/A=000328", AprsPacket.formatAltitude(100.0))
}
@Test
fun courseSpeed_wrapsCourseIntoValidRange() {
assertEquals("/000/019", AprsPacket.formatCourseSpeed(10.0, 360f))
assertEquals("/359/019", AprsPacket.formatCourseSpeed(10.0, -1f))
assertEquals("/090/019", AprsPacket.formatCourseSpeed(10.0, 90f))
}
@Test
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
for (ambiguity in 1..4) {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
.toUncompressedString()
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
}
}
}
@@ -0,0 +1,154 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.navigation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Menu layout rules. Every case here is a bug that shipped at least once, so
* treat a failure as a regression rather than a spec question.
*/
class MenuLayoutTest {
private val all = listOf(
"Satellites", "Passes", "Radar", "Mutual", "Roaming",
"CwDecode", "WavelogLog", "AMSAT", "Map", "Settings"
)
private fun layout(
screenOrder: List<String> = emptyList(),
subMenuOrder: List<String> = emptyList(),
hidden: List<String> = emptyList()
) = MenuLayout.resolve(all, screenOrder, subMenuOrder, hidden)
@Test
fun defaultsPutFivePagesOnTheBarAndTheRestBehindMore() {
val l = layout()
assertEquals(listOf("Satellites", "Passes", "Radar", "Map", "Settings"), l.mainIds)
assertEquals(listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT"), l.moreIds)
}
@Test
fun settingsIsAlwaysReachable() {
// Moving any page onto the bar used to push Settings out of BOTH menus,
// permanently locking the user out of the settings page.
for (page in listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")) {
val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue(
"moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}",
"Settings" in l.mainIds || "Settings" in l.moreIds
)
}
}
@Test
fun movingAmsatToMainActuallyTakesEffect() {
// The legacy migration re-appended AMSAT to the sub-menu unconditionally,
// silently undoing the user's choice.
val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds)
assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds)
}
@Test
fun movingWavelogLogToMainActuallyTakesEffect() {
val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("WavelogLog missing from the bar: ${l.mainIds}", "WavelogLog" in l.mainIds)
}
@Test
fun newPagesUnknownToPersistedOrderDefaultToTheMoreMenu() {
// Upgrading from a build that predates AMSAT and WavelogLog: neither list
// mentions them, so both must land behind More rather than vanishing.
val l = layout(
screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"),
subMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
)
assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds)
assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds)
}
@Test
fun everyVisiblePageIsReachableFromSomeMenu() {
// Pages beyond the five slots used to vanish instead of overflowing.
val l = layout(
screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"),
subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT")
)
assertEquals("every page must be reachable", all.toSet(), (l.mainIds + l.moreIds).toSet())
}
@Test
fun hiddenPagesAppearInNeitherMenu() {
val l = layout(hidden = listOf("Radar", "Map"))
assertTrue("Radar" !in l.mainIds && "Radar" !in l.moreIds)
assertTrue("Map" !in l.mainIds && "Map" !in l.moreIds)
}
@Test
fun settingsCannotBeHidden() {
val l = layout(hidden = listOf("Settings"))
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
}
@Test
fun theBarNeverExceedsFiveSlots() {
val l = layout(screenOrder = all)
assertTrue("bar had ${l.mainIds.size} slots: ${l.mainIds}", l.mainIds.size <= MenuLayout.MAIN_SLOTS)
}
@Test
fun movingAPageOutOfTheBarPutsItBehindMore() {
val moved = MenuLayout.moveToMore("Radar", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("Radar" in l.moreIds)
assertTrue("Radar" !in l.mainIds)
}
@Test
fun settingsCannotBeMovedOffTheBar() {
val moved = MenuLayout.moveToMore("Settings", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("Settings" in l.mainIds || "Settings" in l.moreIds)
}
@Test
fun resolveIsStableWhenAppliedTwice() {
// Persisting what resolve() produced must not change the outcome, or the
// settings list and the bar drift apart on the next recomposition.
val first = layout()
val second = layout(first.mainIds, first.moreIds)
assertEquals(first.mainIds, second.mainIds)
assertEquals(first.moreIds, second.moreIds)
}
@Test
fun movingAPageOntoAFullBarEvictsAnotherPageIntoMore() {
// The bar starts full (5 including Settings), so making room must push an
// existing page into More instead of dropping it.
val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("CwDecode" in l.mainIds)
assertEquals("nothing may be lost", all.toSet(), (l.mainIds + l.moreIds).toSet())
}
}
@@ -0,0 +1,105 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private val observer = GeoPos(22.314066, 108.706575)
private fun subLunarLongitude(gha: Double) = if (gha <= 180.0) -gha else 360.0 - gha
@Test
fun `moon hour angle stays within one revolution`() {
// Sample a full synodic month at 37-minute steps so the sweep crosses
// every hour-angle quadrant many times over.
var timeMillis = 1786060800_000L // 2026-08-07T00:00:00Z
val stepMillis = 37 * 60 * 1000L
val endMillis = timeMillis + 30L * 86400_000L
var samples = 0
while (timeMillis < endMillis) {
val gha = CelestialComputer.getMoonPosition(observer, timeMillis).gha
assertTrue("gha=$gha out of 0..360 at $timeMillis", gha >= 0.0 && gha < 360.0)
val longitude = subLunarLongitude(gha)
assertTrue(
"sub-lunar longitude=$longitude out of -180..180 at $timeMillis",
longitude >= -180.0 && longitude <= 180.0
)
samples++
timeMillis += stepMillis
}
assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000)
}
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -0,0 +1,62 @@
package com.rtbishop.look4sat.core.domain.utility
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* clipLon must reduce any longitude into [-180, 180] in bounded time. The old
* while-loop never returned for extreme inputs: Infinity stays Infinity after
* subtracting 360 (infinite loop), and ~1e12 degree values took billions of
* iterations. The modulo rewrite must be bit-equivalent for all finite values.
*/
class ClipLonTest {
private fun reduced(v: Double) = ((v + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
@Test
fun `finite values match the closed interval semantics`() {
// The old loop: subtract/add 360 until within (-180, 180] is not quite
// it either - 180 stays 180, -180 stays -180. So the interval is closed
// on both ends with +180 for positive-boundary hits.
assertEquals(-180.0, clipLon(-180.0), 1e-12)
assertEquals(180.0, clipLon(180.0), 1e-12)
assertEquals(0.0, clipLon(360.0), 1e-12)
assertEquals(180.0, clipLon(540.0), 1e-12)
assertEquals(-180.0, clipLon(-540.0), 1e-12)
assertEquals(-179.999, clipLon(180.001), 1e-9)
assertEquals(179.999, clipLon(-180.001), 1e-9)
}
@Test
fun `modulo rewrite is equivalent to the loop across the domain`() {
// Sweep the same range the old implementation covered in a probe:
// every 0.01 degree from -10000 to 10000 must agree with the reference
// reduction to within 1e-9.
var v = -10000.0
while (v <= 10000.0) {
val reference = clipLonByLoop(v)
assertEquals(reference, clipLon(v), 1e-9)
v += 0.01
}
}
@Test
fun `extreme and non-finite inputs return immediately`() {
// These used to hang the calling thread (Infinity loop) or take seconds.
assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), 0.0)
assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), 0.0)
assertTrue(clipLon(Double.NaN).isNaN())
assertFalse(clipLon(1e15).isNaN())
assertTrue(clipLon(1e15) in -180.0..180.0)
}
/** The old while-loop implementation, kept as the reference for equivalence. */
private fun clipLonByLoop(longitude: Double): Double {
var result = longitude
while (result < -180.0) result += 360.0
while (result > 180.0) result -= 360.0
return minOf(maxOf(result, -180.0), 180.0)
}
}
@@ -0,0 +1,109 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* Verifies the WaveLog upload payload frequency/band fields.
*
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
* value corrupts band derivation.
*/
class WaveLogApiPayloadTest {
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
private val uplinkHz = 145_850_000L
private val downlinkHz = 436_795_000L
@Test
fun v2_freq_usesMhzStringWithMSuffix() {
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
assertEquals("145.850000M", freq)
assertEquals("436.795000M", freqRx)
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
val parsedHz = parseLikeWaveLog(freq)
assertEquals(uplinkHz, parsedHz)
}
@Test
fun v1_adif_freq_isBareMhzNumber() {
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
assertEquals("145.850000", freq)
val adifFreq = freq.toDouble() * 1_000_000
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
}
@Test
fun v1_adif_preservesSixCharacterGrid() {
val qso = WavelogQso(
id = "test",
timeUtcMs = 0L,
call = "BG7NTA",
mode = "FM",
freqTxHz = uplinkHz,
freqRxHz = downlinkHz,
satName = "SO-50"
)
val adif = WaveLogApi.toAdif(qso, "OM89ab", "SO-50")
assertTrue(adif.contains("<gridsquare:6>OM89ab"))
}
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
private fun parseLikeWaveLog(raw: String): Long {
val s = raw.trim()
return if (s.endsWith("M", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000_000).toLong()
} else if (s.endsWith("k", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000).toLong()
} else {
s.toLong()
}
}
@Test
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
// Doppler-corrected values must remain near the base frequency.
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
val dopplerRate = 7.0 // km/s approaching
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
assertTrue(
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
kotlin.math.abs(corrected - uplinkHz) < 20_000
)
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
val mhz = corrected / 1_000_000.0
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
}
@Test
fun band_isRealBand_notSAT() {
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
// Same-band (e.g. simplex) -> empty sat mode
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
// Never 160m for satellite frequencies
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
}
@Test
fun adif_containsRealBandAndSatMode() {
// v2 payload fields (mirror postQso construction)
val band = WaveLogApi.bandFromHz(uplinkHz)
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
assertEquals("2M", band)
assertEquals("V/U", satMode)
}
}
@@ -27,13 +27,18 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -71,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
@@ -143,7 +150,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
ElevatedCard(
modifier = modifier
@@ -209,6 +216,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
@@ -224,8 +236,13 @@ fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier)
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier, enabled: Boolean = true) {
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action) {
fun IconCard(
action: () -> Unit, resId: Int, modifier: Modifier = Modifier,
enabled: Boolean = true, containerColor: Color = Color.Unspecified
) {
val colors = if (containerColor == Color.Unspecified) CardDefaults.elevatedCardColors()
else CardDefaults.elevatedCardColors(containerColor = containerColor)
ElevatedCard(modifier = Modifier.size(48.dp), enabled = enabled, onClick = action, colors = colors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
@@ -280,6 +297,35 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
altitude = 0, maxElevation = 0.0, orbitalObject = NearEarthObject(defaultOrbitalData), progress = 0f
)
@Composable
fun InfoDialog(
title: String,
onDismiss: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onDismiss) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
@@ -329,6 +375,56 @@ fun SharedDialog(
}
}
@Composable
fun ConfirmDialog(
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
content: @Composable () -> Unit
) {
DialogShell(onDismissRequest = onCancel) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = padding, top = padding, end = padding)
) {
CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel))
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = padding)
)
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
}
content()
}
}
@Composable
fun WhatsNewDialog(onDismiss: () -> Unit) {
InfoDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismiss = onDismiss,
onAccept = onDismiss
) {
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DialogShell(
@@ -337,6 +433,9 @@ private fun DialogShell(
) {
val padding = LocalSpacing.current.large
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val containerHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
val maxSheetHeight = containerHeight - statusBarHeight
val stopSheetFling = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -354,7 +453,7 @@ private fun DialogShell(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = null,
shape = MaterialTheme.shapes.medium,
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
scrimColor = Color.Black.copy(alpha = 0.64f)
) {
Column(
@@ -362,6 +461,7 @@ private fun DialogShell(
verticalArrangement = Arrangement.spacedBy(padding),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.nestedScroll(stopSheetFling)
) {
content(padding)
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat, "Satellites")
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
data object Radar : Screen(R.drawable.ic_satellite, R.string.nav_radar, "Radar")
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
@@ -48,18 +48,12 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@Serializable
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
data object AmSat : Screen(R.drawable.ic_satellite, R.string.nav_amsat, "AMSAT")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
}
// UI settings: default main-menu page order (5 bottom-bar slots)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI settings: default More-menu order (pages behind "More")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
@Serializable
data object RadarDestination : NavKey
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:fillColor="@android:color/white"
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
>
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Ajustes</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
@@ -26,7 +30,7 @@
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\n\nIntenta siempre limitar la lista a los que te
interesen mediante la búsqueda y el selector de tipos.</string>
interesen mediante la búsqueda y el selector de modos.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string>
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
<string name="pass_modes_title">Tipo de modulación</string>
<string name="pass_modes_title">Seleccionar modos</string>
<string name="pass_elevation">Elevación: %.1f°</string>
<string name="pass_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Red</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Control de radio</string>
<string name="rc_settings_title">Control CAT</string>
<string name="rc_radio_model">Modelo de radio</string>
<string name="rc_tx_device_hint">Dirección BT TX</string>
<string name="rc_rx_device_hint">Dirección BT RX</string>
<string name="rc_tx_name_hint">Nombre radio TX</string>
<string name="rc_rx_name_hint">Nombre radio RX</string>
<string name="rc_enable_switch">Activar CAT</string>
<string name="prefs_net_title">Salida de red</string>
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida Bluetooth</string>
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato</string>
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -94,10 +94,21 @@
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
@@ -93,10 +93,21 @@
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<string name="sat_type_hint">Режимы: %s</string>
<string name="sat_type_title">Выберите режимы</string>
<string name="sat_search_hint">Id - Название</string>
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
В этом приложении перечислено более 9000 спутников.
Отслеживать их все одновременно бессмысленно.
\n\nВсегда старайтесь сузить список до тех,
которые вас интересуют, используя поиск и селектор типов.</string>
которые вас интересуют, используя поиск и выбор режимов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
@@ -34,7 +38,7 @@
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
<string name="pass_modes_title">Выберите тип модуляции</string>
<string name="pass_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
@@ -95,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
@@ -109,16 +113,30 @@
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
<string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_data_output_title">Вывод</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="nav_radiocontrol">Управление радио</string>
<string name="rc_settings_title">CAT управление</string>
<string name="rc_radio_model">Модель радио</string>
<string name="rc_tx_device_hint">BT адрес TX</string>
<string name="rc_rx_device_hint">BT адрес RX</string>
<string name="rc_tx_name_hint">Имя радио TX</string>
<string name="rc_rx_name_hint">Имя радио RX</string>
<string name="rc_enable_switch">Включить CAT</string>
<string name="prefs_net_title">Сетевой вывод</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
@@ -126,22 +144,22 @@
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
<string name="prefs_bt_title">Вывод Bluetooth</string>
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
<string name="prefs_bt_rotator_output_hint">Формат</string>
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
<string name="prefs_bt_frequency_output_hint">Формат</string>
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_title">Прочее</string>
<string name="prefs_other_switch_utc">Время в UTC</string>
<string name="prefs_other_switch_update">Автообновление данных</string>
<string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<string name="sat_type_hint">මාදිලි: %s</string>
<string name="sat_type_title">මාදිලි තෝරන්න</string>
<string name="sat_search_hint">Id - නම</string>
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
<string name="sat_warning_message">
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ.
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
<string name="pass_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_import_satellites_error">චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">ජාලය</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
<string name="rc_settings_title">CAT පාලනය</string>
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
<string name="rc_enable_switch">CAT සබල කරන්න</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -98,10 +98,21 @@
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
* Daha Fazla menüsü: sağda ince panel, karartma yok
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
* 64-bit (arm64) desteği geri geldi
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
* Düzeltme: radar Doppler hesaplaması artık canlı kayma kaydırıcı değerini kullanıyor
* Düzeltme: geçiş ilerleme hesaplaması sıfıra bölme durumuna karşı korumalı
* Düzeltme: eşzamanlı calculatePasses çağrıları artık yinelenen hesaplamaları tetiklemiyor
* Düzeltme: WaveLog yinelenen QSO gönderimleri ve ızgara kare güncellemesi yarış durumları
</string>
<!-- Radar screen -->
@@ -13,12 +13,16 @@
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<string name="sat_type_hint">Режими: %s</string>
<string name="sat_type_title">Виберіть режими</string>
<string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
@@ -26,7 +30,7 @@
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
<string name="pass_modes_title">Виберіть тип модуляції</string>
<string name="pass_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Керування радіо</string>
<string name="rc_settings_title">CAT керування</string>
<string name="rc_radio_model">Модель радіо</string>
<string name="rc_tx_device_hint">BT адреса TX</string>
<string name="rc_rx_device_hint">BT адреса RX</string>
<string name="rc_tx_name_hint">Назва радіо TX</string>
<string name="rc_rx_name_hint">Назва радіо RX</string>
<string name="rc_enable_switch">Увімкнути CAT</string>
<string name="prefs_net_title">Мережевий вивід</string>
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_title">Інше</string>
<string name="prefs_other_switch_utc">Час в UTC</string>
<string name="prefs_other_switch_update">Автооновлення даних</string>
<string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -67,6 +67,8 @@
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected_count">已选 (%1$d)</string>
<string name="sat_group_available_count">可选 (%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
@@ -88,10 +90,21 @@
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
* 新: 内置完整版 fp32 模型,解码精度最高
* 新: CW 解码历史永久保留(文字不再消失)
* CW 瀑布图: 全新 inferno 配色与平滑渐变
* 更多菜单: 改为靠右窄面板,不再压暗页面
* CW 页面: 移除无效的返回键与误导性状态提示
* 恢复 64 位(arm64)支持
* 修复: 暂停/恢复不再误报"解码失败"
* 修复: 移动页面到主菜单不再导致设置入口永久消失
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
* 修复: 过境进度计算防除零崩溃
* 修复: 并发 calculatePasses 调用不再触发重复计算
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
</string>
<!-- Radar screen -->
@@ -283,4 +296,8 @@
\n• BG7NTA</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
</resources>
@@ -68,6 +68,8 @@
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
@@ -100,10 +102,21 @@
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
* NEW: CW decode history is kept permanently (text no longer disappears)
* CW waterfall: new inferno colour scheme with smooth gradients
* More menu: slim right-hand panel, no dimming overlay
* CW page: removed the dead back button and the misleading status line
* Restored 64-bit (arm64) support
* Fixed: pause/resume no longer shows a spurious "decode failed" error
* Fixed: moving pages to main menu no longer hides Settings permanently
* Fixed: AMSAT/WavelogLog can now be moved to main menu
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
* Fixed: radar page now auto-switches to next pass after current pass ends
* Fixed: radar Doppler calculation now uses live offset slider value
* Fixed: pass progress calculation guards against division by zero
* Fixed: concurrent calculatePasses calls no longer trigger duplicate calculations
* Fixed: WaveLog duplicate QSO submissions and grid square update race conditions
</string>
<!-- Radar screen -->
@@ -316,4 +329,8 @@
<string name="prefs_outro_license">The app comes with no warranty</string>
<string name="prefs_outro_deepcw" translatable="false">CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">Frequency offset (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
</resources>
@@ -1,2 +1,7 @@
* Added required tweaks to support Android 17 (API 37)
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* Added AMSAT status tracking page by MCKero6423 (#234)
* Tweaked Radar track on reposition by atsunatsu (#235)
* Tweaked linear transponder calc by atsunatsu (#236)
* Added saving per-sat doppler offset by atsunatsu (#237)
* Added custom freq offset setting to network reporting
* Added ability to customize data sources via import
* Fixed star chart in README by PingouinFerreux (#240)
+11 -12
View File
@@ -40,19 +40,18 @@ CPU; timed per-window on device and reported via `lastInferenceMs`).
## Model
| | fp32 (original) | int8 (shipped) |
|---|---|---|
| Size | 15,139,839 bytes | 4,248,808 bytes |
| Derivation | — | `quantize_dynamic` (weights → QUInt8, activations float32) |
| Input | `spectrogram` [1,1,T,65] float32 | unchanged |
| Output | `log_probs` [1,T,42] float32 | unchanged |
| In APK | no (available as a release asset) | yes (`assets/deepcw/model.onnx`) |
| | Value |
|---|---|
| File | `assets/deepcw/model.onnx` (fp32, as published upstream) |
| Size | 15,139,839 bytes |
| Modifications | none — vendored byte-for-byte |
| Input | `spectrogram` [1,1,T,65] float32 |
| Output | `log_probs` [1,T,42] float32 |
The int8 model ships inside the APK: it is ~4× smaller and measurably identical
to fp32 on synthetic CW at SNR ≥ −4 dB (both degrade together below that). The
fp32 model is published as a separate release asset for anyone who wants the
highest-fidelity reference. Both are AGPL-3.0-only — see
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hashes.
The full fp32 model ships inside the APK for maximum decode fidelity. An int8
`quantize_dynamic` build was trialled earlier (~4× smaller, measurably identical
at SNR ≥ −4 dB) but the shipped artifact is now the unmodified fp32 model. See
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hash.
Audio must be packaged **uncompressed** (`noCompress += "onnx"` in the app
module): ONNX Runtime mmap's assets and refuses compressed ones.
+4 -15
View File
@@ -14,11 +14,9 @@ network model obtained from the DeepCW project.
| **License** | GNU Affero General Public License v3.0 only (AGPL-3.0-only) |
| **License text** | [`DeepCW-AGPL-3.0.txt`](DeepCW-AGPL-3.0.txt) |
| **Obtained at commit** | `8e264d243bbd4467bd19f3f28292219405b47e0e` |
| **Original file size** | 15,139,839 bytes |
| **Original SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
| **Derived file size** | 4,248,808 bytes |
| **Derived SHA-256** | `cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98` |
| **Derivation** | Dynamic int8 quantization (weights → QUInt8, activations stay float32) via `onnxruntime.quantization.quantize_dynamic`. Input/output names, shapes and dtypes are unchanged. Measured CER on synthetic CW audio is identical to the fp32 model at SNR >= -4 dB; at -6/-8 dB both models degrade similarly. |
| **File size** | 15,139,839 bytes |
| **SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
| **Modifications** | None. The full fp32 model is vendored byte-for-byte as published upstream. |
Related upstream repositories by the same author (not vendored here):
@@ -52,7 +50,6 @@ the repository hosting this file.
### Reproducing the vendored files
```bash
# 1) Fetch the original fp32 model
SHA=8e264d243bbd4467bd19f3f28292219405b47e0e
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx.json
@@ -60,15 +57,7 @@ curl -sL -o DeepCW-AGPL-3.0.txt \
https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/LICENSE
sha256sum model.onnx
# expected: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
# 2) Reproduce the int8 quantization this repository ships
python - <<'PY'
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic("model.onnx", "model_int8.onnx", weight_type=QuantType.QUInt8)
PY
sha256sum model_int8.onnx
# expected: cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98
# then copy model_int8.onnx over assets/deepcw/model.onnx
# copy model.onnx and model.onnx.json into assets/deepcw/ unchanged
```
## ONNX Runtime
Binary file not shown.
@@ -77,7 +77,7 @@ import com.rtbishop.look4sat.core.presentation.R as CoreR
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
*/
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
fun CwDecodeScreen() {
val context = LocalContext.current
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
val decoder = remember { container.provideCwDecoder() }
@@ -95,9 +95,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val inferenceMs by decoder.lastInferenceMs.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
@@ -139,27 +137,13 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { isListening = false; navigateUp() }) {
Icon(
painter = painterResource(CoreR.drawable.ic_back),
contentDescription = stringResource(R.string.cw_back)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(CoreR.string.nav_cw),
style = MaterialTheme.typography.titleMedium
)
Text(
text = if (estimatedPitch != null) {
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
} else {
stringResource(R.string.cw_status_listening)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = stringResource(CoreR.string.nav_cw),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
IconButton(onClick = { isListening = !isListening }) {
Icon(
painter = painterResource(
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Rolling spectrogram history for the waterfall display.
@@ -46,6 +47,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
private val lock = Any()
private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
// Incremented by clear(). A pushSamples call records the generation before
// doing FFT outside the lock and discards its result if a clear occurred in
// the meantime, otherwise pre-clear audio would reappear after the button tap.
private var generation = 0L
/**
* Bumped on every change so Compose knows to redraw.
@@ -66,6 +71,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val audio: FloatArray
val generationAtStart: Long
synchronized(lock) {
pending.ensureCapacity(pending.size + resampled.size)
for (sample in resampled) pending.add(sample)
@@ -73,25 +79,34 @@ class CwWaterfallState(private val historyRows: Int = 96) {
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
audio = FloatArray(pending.size) { pending[it] }
pending.clear()
generationAtStart = generation
}
// FFT outside the lock; only the append below needs exclusivity.
val computed = CwDeepSpectrogram.compute(audio)
synchronized(lock) {
// Drop the result when the user cleared the display while this FFT
// was running: those samples belong to the discarded history.
if (generation != generationAtStart) return
for (row in computed) {
if (rows.size >= historyRows) rows.removeFirst()
rows.addLast(row)
}
}
_revision.value += 1
// update {} not `value += 1`: this runs on the audio capture thread while
// clear() runs on the main thread, and `+=` is a non-atomic
// read-modify-write. A lost increment means a dropped redraw, and two
// writes landing on the same value make StateFlow report no change at all.
_revision.update { it + 1 }
}
fun clear() {
synchronized(lock) {
generation++
rows.clear()
pending.clear()
}
_revision.value += 1
_revision.update { it + 1 }
}
}
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Geri</string>
<string name="cw_pause">Çözmeyi duraklat</string>
<string name="cw_resume">Çözmeyi sürdür</string>
<string name="cw_clear">Çözülen metni temizle</string>
<string name="cw_status_listening">Dinleniyor…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">返回</string>
<string name="cw_pause">暂停解码</string>
<string name="cw_resume">继续解码</string>
<string name="cw_clear">清空解码文本</string>
<string name="cw_status_listening">正在监听…</string>
<string name="cw_status_tone">%1$d Hz · %2$d 毫秒</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Back</string>
<string name="cw_pause">Pause decoding</string>
<string name="cw_resume">Resume decoding</string>
<string name="cw_clear">Clear decoded text</string>
<string name="cw_status_listening">Listening…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
@@ -49,6 +49,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.math.abs
class MapViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -219,26 +220,23 @@ class MapViewModel(
var orbitalPass = defaultPass
var aosTime = 0L.toTimerString()
var isTimeAos = true
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
when {
date.time < satPass.aosTime -> {
// Pass hasn't started yet — count down to AOS
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
}
date.time < satPass.losTime -> {
// Pass is in progress — count down to LOS
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
}
// Select by time, not by OrbitalPass.progress: that field defaults to 0
// and is only ever filled in by PassesViewModel's own local copy, never
// written back to the repository. Filtering on `progress < 1` therefore
// always matched the satellite's *first* pass, so once it ended the map
// countdown stuck at 00:00:00 and never advanced to the next pass.
currentOrNextPass(sat.data.catnum, date.time)?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
if (date.time < satPass.aosTime) {
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
} else if (date.time < satPass.losTime) {
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
}
}
val azimuth = satPos.azimuth.toDegrees()
val elevation = satPos.elevation.toDegrees()
val osmPos = satPos.toMapGeoPos()
@@ -265,31 +263,15 @@ class MapViewModel(
return satData to orbitalPass
}
/** Ongoing pass for [catnum] if any, otherwise its next upcoming pass. */
private fun currentOrNextPass(catnum: Int, timeMillis: Long): OrbitalPass? =
selectCurrentOrNextPass(allPasses, catnum, timeMillis)
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>()
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
var oldLongitude = 0.0
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
val currentPosition = satPos.toMapGeoPos()
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
// adding left terminal position
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
// adding right terminal position
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
}
oldLongitude = currentPosition.longitude
currentTrack.add(currentPosition)
}
satTracks.add(currentTrack)
_uiState.update { it.copy(track = satTracks) }
val track = satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time)
.map { it.toMapGeoPos() }
_uiState.update { it.copy(track = splitAtAntimeridian(track)) }
}
companion object {
@@ -306,3 +288,64 @@ class MapViewModel(
}
}
}
/**
* Splits a ground track into polylines that never span the antimeridian.
*
* Each 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. Without the entry point a new segment started inland (e.g. at -178)
* and the drawn track broke visibly at 180 degrees; reusing the next sample's
* latitude for the edge made the closing leg jump north or south.
*/
internal fun splitAtAntimeridian(track: List<GeoPos>): List<List<GeoPos>> {
val segments = mutableListOf<List<GeoPos>>()
val current = mutableListOf<GeoPos>()
var previous: GeoPos? = null
track.forEach { position ->
val last = previous
if (last != null && abs(position.longitude - last.longitude) > 180.0) {
val exitEdge = if (last.longitude > 0.0) 180.0 else -180.0
val edgeLatitude = crossingLatitude(last, position, exitEdge)
current.add(GeoPos(edgeLatitude, exitEdge))
segments.add(current.toList())
current.clear()
current.add(GeoPos(edgeLatitude, -exitEdge))
}
previous = position
current.add(position)
}
segments.add(current.toList())
return segments
}
/** Latitude where the leg between [from] and [to] crosses [edgeLongitude]. */
internal fun crossingLatitude(from: GeoPos, to: GeoPos, edgeLongitude: Double): Double {
// Unwrap the destination so the leg is continuous, then interpolate.
val unwrappedTo = when {
from.longitude > 0.0 && to.longitude < 0.0 -> to.longitude + 360.0
from.longitude < 0.0 && to.longitude > 0.0 -> to.longitude - 360.0
else -> to.longitude
}
val span = unwrappedTo - from.longitude
if (abs(span) < 1e-12) return from.latitude
val fraction = (edgeLongitude - from.longitude) / span
return from.latitude + (to.latitude - from.latitude) * fraction
}
/**
* Picks the pass to describe for [catnum] at [timeMillis]: the one in progress,
* or else the earliest one still to come.
*
* Deliberately ignores OrbitalPass.progress. That field is a UI-side value filled
* in by PassesViewModel on its own copy of the list; the repository always
* reports 0, so any `progress` predicate here silently matches everything.
*/
internal fun selectCurrentOrNextPass(
passes: List<OrbitalPass>,
catnum: Int,
timeMillis: Long
): OrbitalPass? {
val forSatellite = passes.filter { it.catNum == catnum }
return forSatellite.firstOrNull { timeMillis >= it.aosTime && timeMillis < it.losTime }
?: forSatellite.filter { it.aosTime > timeMillis }.minByOrNull { it.aosTime }
}
@@ -0,0 +1,110 @@
package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
/**
* A ground track must be cut into polylines that never span the antimeridian,
* and the cut must be seamless: the closing point of one segment and the opening
* point of the next sit on opposite edges at the same interpolated latitude.
*
* Regression: the previous implementation only appended the outgoing edge point,
* so every following segment began inland (e.g. at -178) and the drawn track
* broke at 180 degrees. It also reused the next sample's latitude for the edge,
* which made the closing leg jump.
*/
class AntimeridianSplitTest {
private fun pos(lat: Double, lon: Double) = GeoPos(lat, lon)
private fun assertNoSegmentSpansTheEdge(segments: List<List<GeoPos>>) {
segments.forEachIndexed { index, segment ->
segment.zipWithNext { a, b ->
assertTrue(
"segment $index still spans the antimeridian: $a -> $b",
abs(b.longitude - a.longitude) <= 180.0
)
}
}
}
private fun assertSeamsAreOnOppositeEdges(segments: List<List<GeoPos>>) {
segments.zipWithNext { previous, next ->
val exit = previous.last()
val entry = next.first()
assertEquals("exit longitude", 180.0, abs(exit.longitude), 1e-9)
assertEquals("entry longitude", 180.0, abs(entry.longitude), 1e-9)
assertEquals("edges must be opposite", 0.0, exit.longitude + entry.longitude, 1e-9)
assertEquals("latitude must be continuous", exit.latitude, entry.latitude, 1e-9)
}
}
@Test
fun `eastward crossing is cut seamlessly`() {
val segments = splitAtAntimeridian(
listOf(
pos(10.0, 165.0), pos(12.0, 172.0), pos(14.0, 179.0),
pos(16.0, -178.0), pos(18.0, -171.0)
)
)
assertEquals(2, segments.size)
assertEquals(180.0, segments[0].last().longitude, 1e-9)
assertEquals(-180.0, segments[1].first().longitude, 1e-9)
assertNoSegmentSpansTheEdge(segments)
assertSeamsAreOnOppositeEdges(segments)
}
@Test
fun `westward crossing is cut seamlessly`() {
val segments = splitAtAntimeridian(
listOf(
pos(10.0, -165.0), pos(12.0, -172.0), pos(14.0, -179.0),
pos(16.0, 178.0), pos(18.0, 171.0)
)
)
assertEquals(2, segments.size)
assertEquals(-180.0, segments[0].last().longitude, 1e-9)
assertEquals(180.0, segments[1].first().longitude, 1e-9)
assertNoSegmentSpansTheEdge(segments)
assertSeamsAreOnOppositeEdges(segments)
}
@Test
fun `edge latitude is interpolated rather than copied`() {
// 179 -> -178 spans three degrees of longitude and two of latitude, so
// the edge sits one degree past the first sample, not at the second one.
val segments = splitAtAntimeridian(listOf(pos(14.0, 179.0), pos(16.0, -178.0)))
assertEquals(14.0 + 2.0 / 3.0, segments[0].last().latitude, 1e-9)
}
@Test
fun `track that stays near the edge is not split`() {
val segments = splitAtAntimeridian(listOf(pos(10.0, 175.0), pos(12.0, 178.0)))
assertEquals(1, segments.size)
assertEquals(2, segments[0].size)
}
@Test
fun `repeated crossings all produce seams`() {
val segments = splitAtAntimeridian(
listOf(pos(0.0, 170.0), pos(5.0, -175.0), pos(10.0, 175.0), pos(15.0, -170.0))
)
assertEquals(4, segments.size)
assertNoSegmentSpansTheEdge(segments)
assertSeamsAreOnOppositeEdges(segments)
}
@Test
fun `empty and single point tracks are handled`() {
assertEquals(listOf(emptyList<GeoPos>()), splitAtAntimeridian(emptyList()))
assertEquals(1, splitAtAntimeridian(listOf(pos(1.0, 2.0))).size)
}
}
@@ -0,0 +1,84 @@
package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The map info panel must describe the pass that is happening now, or the next
* one if none is.
*
* Regression: selection used to filter on `OrbitalPass.progress < 1`. That field
* defaults to 0 and is only ever updated on PassesViewModel's private copy, so
* the predicate matched every pass and the map always described the satellite's
* first pass - leaving the countdown frozen at 00:00:00 once it had ended.
*/
class CurrentOrNextPassTest {
private val hour = 3_600_000L
private val iss = 25544
private val noaa = 33591
private fun orbitalData(catnum: Int) = OrbitalData(
name = "sat-$catnum",
epoch = 21320.51955234,
meanmo = 15.48582035,
eccn = 0.0004694,
incl = 51.6447,
raan = 309.4881,
argper = 203.6966,
meanan = 299.8876,
catnum = catnum,
bstar = 0.31985e-4
)
private fun pass(catnum: Int, aos: Long, los: Long) = OrbitalPass(
aosTime = aos,
losTime = los,
orbitalObject = NearEarthObject(orbitalData(catnum))
)
private val issFirst = pass(iss, 10 * hour, 10 * hour + 600_000)
private val issSecond = pass(iss, 12 * hour, 12 * hour + 600_000)
private val issThird = pass(iss, 14 * hour, 14 * hour + 600_000)
private val other = pass(noaa, 11 * hour, 11 * hour + 600_000)
private val passes = listOf(issFirst, other, issSecond, issThird)
@Test
fun `before the first pass returns the first pass`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, (9.5 * hour).toLong()))
}
@Test
fun `during a pass returns that pass`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour + 300_000))
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 12 * hour + 300_000))
}
@Test
fun `after a pass ends returns the following pass`() {
// The case the progress predicate got wrong: it kept returning issFirst.
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, (10.5 * hour).toLong()))
assertEquals(issThird, selectCurrentOrNextPass(passes, iss, 13 * hour))
}
@Test
fun `after the last pass returns null`() {
assertNull(selectCurrentOrNextPass(passes, iss, 15 * hour))
}
@Test
fun `other satellites are ignored`() {
assertEquals(other, selectCurrentOrNextPass(passes, noaa, 11 * hour + 60_000))
assertNull(selectCurrentOrNextPass(passes, noaa, 12 * hour))
}
@Test
fun `exact aos belongs to the pass and exact los does not`() {
assertEquals(issFirst, selectCurrentOrNextPass(passes, iss, 10 * hour))
assertEquals(issSecond, selectCurrentOrNextPass(passes, iss, 10 * hour + 600_000))
}
}
@@ -608,7 +608,7 @@ private fun MutualPassCard(
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.ic_radar),
painter = painterResource(id = R.drawable.ic_satellite),
contentDescription = null,
modifier = Modifier.height(16.dp).width(16.dp)
)
@@ -400,7 +400,15 @@ class MutualViewModel(
val refinedAos = refineEdge(sat, posA, posB, aos, 1_000L, true)
val refinedLos = refineEdge(sat, posA, posB, los, 1_000L, false)
if (refinedLos <= refinedAos) continue
if (refinedLos <= refinedAos) {
// Refine collapsed the window (e.g. a near-horizon pass whose
// AOS/LOS both sit at the edge of the 70 s refine window).
// Advance the search regardless: skipping without moving
// searchStart would make findNextMutualPass return the same
// pass forever once the refine windows can no longer span it.
searchStart = los + 120_000L
continue
}
val mutualPass = sampleMutualPass(sat, posA, posB, refinedAos, refinedLos,
minElevADeg, minElevBDeg, sampleInterval)
@@ -58,20 +58,13 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlin.math.roundToInt
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
@@ -147,7 +140,7 @@ internal fun PassesFilterDialog(
)
cancel()
}
SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
ConfirmDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) {
SliderRow(
title = stringResource(R.string.pass_filter_elev),
value = elevationValueNew.doubleValue,
@@ -372,7 +365,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
ConfirmDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
modifier = Modifier
@@ -381,7 +374,7 @@ internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
itemsIndexed(Sources.satelliteModes) { index, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -71,10 +71,10 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -135,26 +135,14 @@ private fun PassesScreen(
}
if (uiState.shouldSeeWhatsNew) {
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
SharedDialog(
title = stringResource(R.string.pass_whatsnew_title),
onDismissRequest = dismiss,
onAccept = dismiss,
titleFontSize = 18
) { padding ->
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
}
WhatsNewDialog(onDismiss = dismiss)
}
ScreenColumn(
topBar = { isVerticalLayout ->
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -163,7 +151,7 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
}
)
}
@@ -290,6 +278,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Preview(showBackground = true)
@Composable
private fun DeepSpacePassPreview() {
@@ -316,12 +309,13 @@ private fun PassItem(
isVerticalLayout: Boolean = true,
isUtc: Boolean = false
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.getDefault()).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
@@ -341,6 +335,10 @@ private fun PassItem(
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
@@ -350,7 +348,7 @@ private fun PassItem(
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.onSurface
)
val elevColor = elevationColor(pass.maxElevation)
Icon(
@@ -34,6 +34,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -42,6 +45,7 @@ import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -61,7 +65,7 @@ class PassesViewModel(
aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute,
invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow,
showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace,
modes = settingsRepo.passesSettings.value.selectedModes,
modes = settingsRepo.selectedSatModes.value,
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
@@ -87,33 +91,43 @@ class PassesViewModel(
}
}
}
// Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch {
satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) {
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
val grouped = groupPasses(processed, isUtc)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
sunTimes = sunTimes,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
}
settingsRepo.selectedSatModes.collectLatest { modes ->
_uiState.update { it.copy(modes = modes) }
}
}
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
// computations run once per restart, progress/countdown are calculated every second.
viewModelScope.launch {
combine(
satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
val filtered = if (showDeepSpace) allPasses
else allPasses.filter { !it.isDeepSpace }
// Expensive: recompute sun times once per items/UTC change, not every second
val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) }
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow)
val grouped = groupPasses(processed, isUtc)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
groupedPasses = grouped,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000.milliseconds)
}
}
}
}
fun onAction(action: PassesAction) {
@@ -128,21 +142,9 @@ class PassesViewModel(
aosStartMinute = action.aosStartMinute,
aosEndMinute = action.aosEndMinute,
invertAosTimeWindow = action.invertAosTimeWindow,
showDeepSpace = action.showDeepSpace,
modes = _uiState.value.modes
)
is PassesAction.FilterRadios ->
applyFilter(
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
lowElevation = _uiState.value.lowElevation,
highElevation = _uiState.value.highElevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
showDeepSpace = _uiState.value.showDeepSpace,
modes = action.modes
showDeepSpace = action.showDeepSpace
)
is PassesAction.FilterRadios -> setModesFilter(action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog ->
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
@@ -153,14 +155,20 @@ class PassesViewModel(
}
}
/** Returns a locale-appropriate date format for pass grouping headers. */
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
val pattern = if (Locale.getDefault().language == "zh") {
val locale = displayLocale()
val pattern = if (locale.language == Locale.CHINESE.language) {
"yyyy'年'M'月'd'日' EEEE"
} else {
"EEE, dd MMM yyyy"
}
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
@@ -171,7 +179,7 @@ class PassesViewModel(
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = dateFormat(tz)
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
@@ -221,6 +229,11 @@ class PassesViewModel(
if (!pass.isDeepSpace && time > pass.aosTime) {
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
// Guard against division by zero (corrupted TLE, degenerate orbit, etc.)
if (deltaTotal <= 0f) {
// Skip passes with zero or negative duration
continue
}
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
@@ -259,8 +272,7 @@ class PassesViewModel(
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
showDeepSpace: Boolean,
modes: List<String>
showDeepSpace: Boolean
) = viewModelScope.launch {
settingsRepo.setPassesSettings(
PassesSettings(
@@ -269,8 +281,7 @@ class PassesViewModel(
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
invertAosTimeWindow
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
@@ -283,10 +294,10 @@ class PassesViewModel(
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
invertAosTimeWindow = invertAosTimeWindow,
showDeepSpace = showDeepSpace,
modes = modes
showDeepSpace = showDeepSpace
)
}
val modes = settingsRepo.selectedSatModes.value
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
@@ -298,6 +309,20 @@ class PassesViewModel(
)
}
private fun setModesFilter(modes: List<String>) = viewModelScope.launch {
settingsRepo.setSelectedSatModes(modes)
_uiState.update { it.copy(modes = modes) }
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = _uiState.value.hours,
minElevation = _uiState.value.elevation,
aosStartMinute = _uiState.value.aosStartMinute,
aosEndMinute = _uiState.value.aosEndMinute,
invertAosTimeWindow = _uiState.value.invertAosTimeWindow,
modes = modes
)
}
private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(
@@ -307,7 +332,7 @@ class PassesViewModel(
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
modes = settingsRepo.selectedSatModes.value
)
}
@@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -82,6 +83,9 @@ import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
private const val DUPLICATE_WINDOW_MS = 2000L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogTab(
@@ -198,6 +202,11 @@ fun LogTab(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
groupEntries.forEach { entry ->
// key() ties the row's swipe/countdown state to the QSO id. Without it
// Compose reuses state by position, so a list reorder mid-countdown
// (a new QSO is inserted at index 0) moves the pending deletion onto a
// different record and deletes the wrong one.
key(entry.id) {
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
@@ -231,6 +240,7 @@ fun LogTab(
)
}
}
} // key(entry.id)
}
}
}
@@ -253,12 +263,20 @@ private fun ExpandedLogInput(
val scope = rememberCoroutineScope()
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
// A plain in-flight flag cannot help: submit() is synchronous, so it always
// clears before the next key event arrives.
var lastSaved by remember { mutableStateOf("" to 0L) }
val savedMsg = stringResource(id = R.string.wavelog_saved)
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
val now = System.currentTimeMillis()
val (lastCall, lastAt) = lastSaved
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
lastSaved = call to now
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
@@ -286,7 +304,10 @@ private fun ExpandedLogInput(
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
)
if (grid != null) queue.updateGridsquare(qsoId, grid)
if (grid != null) {
queue.updateGridsquare(qsoId, grid)
onSaved() // 触发 refreshTick++ 刷新列表
}
}
}
}
@@ -458,7 +479,12 @@ internal fun SwipeDeleteRow(
.background(MaterialTheme.colorScheme.surfaceContainer)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { if (pending) { pending = false } },
onDragStart = {
if (pending) {
pending = false
offsetX = 0f
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
@@ -284,7 +284,8 @@ private fun PagerCard(
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
onAction = onAction,
requestMicPermission = requestMicPermission
requestMicPermission = requestMicPermission,
calculatorOffsetKHz = uiState.calculatorOffsetKHz
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
@@ -352,6 +353,7 @@ private fun RadarCard(
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
shouldFlipRadar = uiState.shouldFlipRadar,
modifier = Modifier.align(Alignment.Center),
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
@@ -58,6 +58,7 @@ data class RadarState(
val satTrack: List<OrbitalPos> = emptyList(),
val shouldShowSweep: Boolean = false,
val shouldUseCompass: Boolean = false,
val shouldFlipRadar: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(),
@@ -69,7 +70,9 @@ data class RadarState(
val mutualEndTime: Long = 0L,
val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你",
val mutualLabelB: String = "友台"
val mutualLabelB: String = "友台",
// Upstream Doppler calculator offset (merged from rt-bishop)
val calculatorOffsetKHz: String = ""
)
enum class SstvStatus { Idle, Recording }
@@ -124,4 +127,6 @@ sealed interface RadarAction {
data object CwReset : RadarAction
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
data class CwPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
}
@@ -79,6 +79,7 @@ fun RadarViewCompose(
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
shouldFlipRadar: Boolean,
modifier: Modifier = Modifier,
sunPosition: CelestialComputer.SunPosition? = null,
moonPosition: CelestialComputer.MoonPosition? = null,
@@ -146,7 +147,9 @@ fun RadarViewCompose(
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedSweepColor = primaryColor
}
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
rotate(rotation) {
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer)
@@ -67,13 +67,16 @@ class RadarViewModel(
private val showToast: IShowToast
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var stationPos = settingsRepo.stationPosition.value
private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private var cwDecoder: ICwDecoder? = null
private var cwListeningJob: Job? = null
private var sensorCollectionJob: Job? = null
// Celestial positions change slowly, recompute at most once per minute
private var lastCelestialUpdateMs = 0L
@@ -92,33 +95,67 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState
init {
collectCompassSensor()
collectSettingsChanges()
collectSettingsChanges() // also handles initial sensor subscription
collectStationPositionChanges()
collectPassAndStartTickLoop()
collectRadioTrackingState()
}
private fun collectCompassSensor() {
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
// Starts sensor collection if not already running.
private fun startSensorCollection() {
if (sensorCollectionJob?.isActive == true) return
sensorCollectionJob = viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
val orientationValues =
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
// Stops sensor collection and disables the hardware sensor.
private fun stopSensorCollection() {
sensorCollectionJob?.cancel()
sensorCollectionJob = null
sensorsRepo.disableSensor()
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
settingsRepo.otherSettings.collect { settings ->
compassOffset = settings.radarCompassOffset
compassOffsetElev = settings.radarCompassOffsetElev
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
shouldShowSweep = settings.stateOfSweep,
shouldUseCompass = settings.stateOfSensors
shouldUseCompass = settings.stateOfSensors,
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
)
}
// Reactively wire sensor hardware to the compass setting
when {
settings.stateOfSensors -> startSensorCollection()
else -> stopSensorCollection()
}
}
}
}
private fun collectStationPositionChanges() {
viewModelScope.launch {
settingsRepo.stationPosition.collect { newPos ->
if (stationPos != newPos) {
stationPos = newPos
magDeclination = sensorsRepo.getMagDeclination(stationPos)
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
val pass = _uiState.value.currentPass ?: return@collect
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
}
}
}
}
@@ -127,9 +164,19 @@ class RadarViewModel(
private fun collectPassAndStartTickLoop() {
viewModelScope.launch {
val pass = findCurrentPass() ?: return@launch
val allRadios = loadPassData(pass)
var pass = findCurrentPass() ?: return@launch
var allRadios = loadPassData(pass)
while (isActive) {
// Once the tracked pass is over, follow the same satellite into its
// next pass. findCurrentPass() cannot do this: it keys off
// selectedPass, which only changes when the user taps a pass, so it
// would keep resolving to the pass that just ended.
if (System.currentTimeMillis() > pass.losTime) {
findNextPassAfter(pass)?.let { next ->
pass = next
allRadios = loadPassData(next)
}
}
tickPass(pass, allRadios)
delay(1000.milliseconds)
}
@@ -145,6 +192,17 @@ class RadarViewModel(
?: passes.firstOrNull()
}
/**
* Earliest pass that starts after [current] ends. Prefers the satellite being
* tracked so the radar keeps following it; falls back to any satellite so the
* page moves on instead of freezing on an expired pass.
*/
private fun findNextPassAfter(current: OrbitalPass): OrbitalPass? {
val later = satelliteRepo.passes.value.filter { it.aosTime > current.losTime }
return later.filter { it.catNum == current.catNum }.minByOrNull { it.aosTime }
?: later.minByOrNull { it.aosTime }
}
// Loads transmitters and satellite track for pass, sets initial state, returns full radio list
private suspend fun loadPassData(pass: OrbitalPass): List<SatRadio> {
_uiState.update { it.copy(currentPass = pass) }
@@ -223,6 +281,7 @@ class RadarViewModel(
// OrtSession holds native memory; it must be released explicitly.
cwDecoder?.close()
cwDecoder = null
stopSensorCollection()
}
fun onAction(action: RadarAction) {
@@ -232,7 +291,17 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
// Load the saved offset for the newly selected satellite
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update {
it.copy(
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
}
// Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) {
@@ -277,7 +346,7 @@ class RadarViewModel(
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
}
RadarAction.SstvReset -> {
is RadarAction.SstvReset -> {
sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
@@ -296,6 +365,15 @@ class RadarViewModel(
is RadarAction.CwToggleExpanded -> {
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
}
is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum
}
if (catnum != null) {
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
}
}
@@ -473,7 +551,7 @@ class RadarViewModel(
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
// - 0.50-0.60: Conservative, best for noisy environments
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
@@ -496,7 +574,7 @@ class RadarViewModel(
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
// Ask users to compare both if they report line-skipping/compression artifacts.
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
// Debug: Return scope visualization in frames (increases per-frame memory usage).
private const val SSTV_INCLUDE_SCOPE = false
@@ -508,7 +586,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3
)
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
RadarViewModel(
@@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.OutlinedText
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import kotlin.math.roundToInt
@@ -96,14 +96,14 @@ internal fun SstvPage(
if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false }
SharedDialog(
ConfirmDialog(
title = "SSTV Mode",
onDismissRequest = dismiss,
onAccept = dismiss
) { _ ->
onAccept = dismiss,
onCancel = dismiss
) {
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.7f)
.fillMaxHeight(0.69f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
@@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
@@ -31,8 +32,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -43,6 +47,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
@@ -51,11 +56,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -67,6 +74,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -76,6 +84,7 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -128,10 +137,12 @@ fun CalculatorPage(
cw: CwSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
@@ -179,6 +190,8 @@ fun CalculatorPage(
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth()
)
}
@@ -463,10 +476,11 @@ private fun ExpandedRadioControl(
)
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 5,
modifier = Modifier.fillMaxWidth()
) {
val chipModifier = Modifier.width(64.dp)
val chipModifier = Modifier.weight(1f)
FilterChip(
selected = radioControl.ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
@@ -530,152 +544,362 @@ private fun ExpandedRadioControl(
}
}
private enum class EditedField { TX, PASSBAND, RX }
@Composable
private fun DopplerFrequencyCalculator(
transponder: SatRadio,
orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") }
var rxInputMHz by remember { mutableStateOf("") }
var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) }
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field
val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) {
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
if (rxHz != null) {
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0)
}
if (lastEditedField == EditedField.TX) {
// TX 是锚点,不变;重算 RX
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else {
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
}
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
if (txHz != null) {
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.radar_doppler_calc),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.radar_doppler_info),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Offset input
OutlinedTextField(
value = offsetKHz,
onValueChange = { newVal ->
offsetKHz = newVal
// Trigger recompute based on last edited field
if (lastEditedBy == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull()
if (txMHz != null && txMHz > 0) {
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
}
} else {
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
}
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
},
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth()
)
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX and RX inputs on the same row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
// TX input → compute RX
OutlinedTextField(
value = txInputMHz,
onValueChange = { newVal ->
txInputMHz = newVal
lastEditedBy = EditedField.TX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val txHz = (mhz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
// RX input → compute TX
OutlinedTextField(
value = rxInputMHz,
onValueChange = { newVal ->
rxInputMHz = newVal
lastEditedBy = EditedField.RX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val rxHz = (mhz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
// RX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
}
}
}
private enum class EditedField { TX, RX }
@Composable
private fun CwDecoderPanel(
cw: CwSubState,
@@ -804,3 +1028,4 @@ private fun transceiverTitle(radio: SatRadio): String {
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more