Compare commits

..
Author SHA1 Message Date
atsunatsu 14bd984327 chore: bump version to 4.4.7-ba7opf.12.1 (versionCode 487) 2026-09-22 11:05:50 +08:00
atsunatsu d5c100903b fix(mutual,passes): map Match prefill opens at time-range card; whats-new dialog scrollable
- Match prefill now positions the LazyColumn via its initial state
  (queryMutualPasses(initialScrollIndex = 1)) instead of a post-layout
  scrollToItem, so the page reliably opens with the time-range card at the
  top while the 24h query runs.
- The install-time what's new dialog text now scrolls inside a bounded box
  (max 360dp) instead of overflowing the fixed dialog frame.
2026-09-22 11:05:47 +08:00
atsunatsu 1c6388f3bc chore: bump version to 4.4.7-ba7opf.12 (versionCode 486) 2026-09-22 10:47:00 +08:00
atsunatsu 3e0874e6a8 feat(map): grid QSO dialog Match button prefills the match page
- Grid-QSO dialog gains a Match action (radio-tower icon + label) in its
  header; tapping it navigates to the match page pre-filled with the tapped
  grid as the opposite station and a 24h time range, auto-starts the query,
  and scrolls the page straight to the time-range card.
- Every grid is now tappable (previously only worked grids opened the
  dialog); unworked grids show an empty QSO list instead of a hint.
2026-09-22 10:46:56 +08:00
atsunatsu 6beaeb3e1a chore: bump version to 4.4.7-ba7opf.11.4 (versionCode 485) 2026-09-21 16:51:50 +08:00
atsunatsu 91f0cb1c56 fix(map): grid labels match first-call font size and display zoom 2026-09-21 16:51:13 +08:00
atsunatsu 3b72f8d68a fix(res): rename pass-history slider label 先前时间 to 历史过境时间 (values-zh) 2026-09-21 16:51:09 +08:00
atsunatsu 3bc2983d26 fix(passes): expire finished passes beyond history window, refresh keeps lookback 2026-09-21 16:51:06 +08:00
atsunatsu 0ec4c5dc6c chore: bump version to 4.4.7-ba7opf.11.3 (versionCode 484) 2026-09-21 11:35:33 +08:00
atsunatsu 8e8a3849df fix(map): first-call labels now appear exactly when grid lines do
The 11.2 gate only relaxed the pre-return zoom check, but the showLabels
gate below it still required LABEL_ZOOM_SUB (6.5), so first-call labels
stayed hidden in the 6.0..6.5 band where the 4-char grid lines render.
- Gate showLabels on GRID_ZOOM_SUB (6.0) when showFirstCallLabels is on.
- Relax MIN_LABEL_CELL_PX (48) to 0.6x for first-call mode so 1° cells
  (~45 px on the equator at zoom 6.0) are not filtered out.
Normal grid-code labels are unchanged (still LABEL_ZOOM_SUB).
2026-09-21 11:35:10 +08:00
atsunatsu cd65e6c077 fix(res): single-line what's new string, unformatted qso count, si typo
- pass_whatsnew_message rewritten as a single line with escaped \n bullets:
  real newlines in the value are invalid for aapt2 ('not a valid string').
- Escape the apostrophe in "map's"; mark grid_qso_calls_count
  formatted=false (two %d + '·' triggered multiple-substitutions).
- values-si: remove stray comma after map_altitude </string>.
All were latent resource errors surfaced by the forced recompile.
2026-09-21 11:11:52 +08:00
atsunatsu a3d370b1fa chore: bump version to 4.4.7-ba7opf.11.2 (versionCode 483) 2026-09-21 10:52:35 +08:00
atsunatsu bf492e3f8b feat(passes/radar): port pass history + compass calibration, cumulative what's new
- Pass history (Iu-yang1 fork): PassesSettings.hoursBefore look-back window;
  second 'Time before' slider in the pass filter; completed passes stay
  listed (progress clamped to [0,1]) in the history window.
- Compass calibration (Iu-yang1 fork): OtherSettings.compassOffsetDegrees,
  CompassAccuracy from the magnetic sensor, calibration dialog with
  figure-eight accuracy meter, corrected heading and manual offset field;
  RadarViewModel applies the offset.
- What's New dialog now shows the cumulative 4.4.6->4.4.7 changelog and
  follows the system language (values/ + values-zh/).
- pass_filter_history_hours localized in es/ru/si/tr/uk.
2026-09-21 10:42:45 +08:00
atsunatsu 3591960d55 feat(map): show first-call labels as soon as grid lines appear
Move the first-call label gate from LABEL_ZOOM_SUB (6.5) to GRID_ZOOM_SUB
(6.0): worked (green) cells are labelled with their first callsign as soon
as the 4-char grid lines render, not only at the name-label zoom.
2026-09-21 10:42:37 +08:00
atsunatsu 3f7a087938 chore: bump version to 4.4.7-ba7opf.11.1 (versionCode 482) 2026-09-21 09:44:46 +08:00
atsunatsu 4b82332133 feat(map): first-call callsign label toggle in VUCC grid mode
Add a 'first call' pill toggle below the grid-mode switch (map top-right,
visible only in grid mode + VUCC award). When on, worked (green) cells are
labelled with the first callsign worked in that grid (earliest confirmed
QSO by time) instead of the Maidenhead code; non-worked cells get no label
at all. Persisted via OtherSettings.stateOfMapFirstCall (full MVI chain).
The feature is VUCC-only: All and other award overlays never draw it.
2026-09-21 09:44:43 +08:00
atsunatsu dcd16a0196 chore: bump version to 4.4.7-ba7opf.11 (versionCode 481) 2026-09-19 04:27:55 +08:00
atsunatsu 9e88609efc feat(amsat): show consecutive same-status report count in status cells
Day-cell numbers now count the most recent consecutive reports sharing the
newest status (counting back from the newest report, stopping at the first
different status), instead of the total report count of the day. Empty gaps
do not break the streak; statuses compare by exact report text; counting
does not cross day boundaries.

- SatDay gains streakCount (computed in AmSatRepository.buildStatuses)
- DayCell renders day.streakCount instead of slot count sum
- Add real org.json to core:data unit tests (android.jar stub cannot parse)
- Add 2 streak tests: mixed-status slot breaks, all-same-status counts all
2026-09-19 04:27:19 +08:00
atsunatsu 322becdda8 feat(satellites): fuzzy name search ignoring separators
The satellite search only matched exact continuous substrings, so names with
dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by
queries without those separators. Now the query is normalized (lowercased,
non-alphanumeric separators stripped) and split into space-separated tokens,
and every token must appear in the normalized satellite name. This makes
'ao7' match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'.
Numeric queries still match catnum exactly. Covered by 8 new unit tests.
2026-09-18 14:49:41 +08:00
atsunatsu 7f93a10556 chore: bump version to 4.4.7-ba7opf.9 (versionCode 479) 2026-09-18 02:04:09 +08:00
atsunatsu 3cd5b49f98 feat(mutual): keep pass list scroll position across page switches
The mutual pass list LazyColumn reset to the top on every return to the page.
Scroll position is now stored in the Activity-scoped MutualViewModel and
restored on re-entry; a new query (queryGeneration bump) rebuilds the list at
the top so stale positions never leak into fresh results.
2026-09-18 02:04:04 +08:00
atsunatsu 4dbe896124 feat(map): keep grid-mode viewport across page switches
The osmdroid map was recreated on every return to the Map page, resetting
center/zoom to the default and forcing grid mode back onto the station grid.
Now the viewport (center + zoom) is saved in the Activity-scoped
MapFilterViewModel only in grid mode and restored on re-entry, so the user
keeps exactly where they left off. Satellite mode keeps its original
follow-the-satellite behavior; cold-start entries and in-page grid toggles
still center on the station grid as before.
2026-09-18 02:03:57 +08:00
atsunatsu b47bd78ecd feat(map): keep award filter selection across page switches
The award chip (VUCC/DXCC/WAJA/...) now lives in an Activity-scoped
MapFilterViewModel instead of a per-composition remember, so switching
to another page and back keeps the last selection; it resets to VUCC
only once per process (cold start). Removes the effect that forced
VUCC on every grid-mode entry.
2026-09-17 12:57:36 +08:00
atsunatsu c89e79e28f chore: bump version to 4.4.7-ba7opf.8 (versionCode 478) 2026-09-17 11:53:44 +08:00
atsunatsu b40a79e3c6 docs: credit BG5JSU with CW decoding feature 2026-09-17 11:38:19 +08:00
atsunatsu 6c23696e22 docs: add BG5JSU to acknowledgments (after BG7NTA) 2026-09-17 11:25:44 +08:00
atsunatsu f997d95cd4 chore: bump version to 4.4.7-ba7opf.7 (versionCode 477) 2026-09-17 10:41:11 +08:00
atsunatsu e0a1d279ce i18n(zh): localize update time to yyyy年M月d日 HH:mm:ss 2026-09-17 10:20:26 +08:00
atsunatsu 059c816075 feat(lotw): show last sync time on settings, precise incremental cursor
The stored sync cursor was 'yyyyMMdd', which LoTW's qso_qslsince silently
ignores (measured live: it falls back to the system-supplied default).
Write a full UTC 'yyyy-MM-dd HH:mm:ss' cursor instead — the documented
format LoTW honors — so incremental pulls are exact (second precision)
and the settings page can show the last successful sync time on the LoTW
card, formatted like the ephemeris update time (prefs_updated_title).
Legacy yyyyMMdd cursors are normalized for the API, the gate and the
display (lotwCursorApi/lotwCursorDate/lotwCursorEpochMs).
2026-09-17 10:13:52 +08:00
atsunatsu 60dfa5d600 feat(lotw): auto-sync grids on app start with separate toggle
Mirrors the ephemeris auto-update check: on every app start, if LoTW
credentials are configured, the new 'auto-sync LoTW grids' toggle is on,
at least one manual sync was done before, and today has not synced yet
(ARRL rate limit, ~once a day), pull confirmations since the last sync
and merge them. Same callsign -> incremental merge; callsign change ->
full replace. Manual dialog and automatic path share the orchestration
(mode resolution, gate, merge/persist) moved to core:domain
LoTWGridSync.kt; LoTWSyncMode moved to core:domain. 8 unit tests.
2026-09-16 20:18:22 +08:00
atsunatsu 5f1eb239ed feat(lotw): cancel in-flight sync, honest progress bar, drop stale hint
- Cancel: the dialog's cancel/back while syncing now aborts the download job
  (cooperative cancellation in the stream reader, CancellationException
  propagated, nothing written to storage). An accidental full sync can be
  stopped immediately.
- Progress: the size estimate is conservative (900 B/record vs measured 720)
  so the bar never hits 100% before the download truly ends; storage writes
  moved to the IO dispatcher (multi-MB per-grid QSO JSON used to freeze the
  main thread after download); when the bar caps, the UI switches to
  'Processing downloaded data…' instead of looking stuck.
- Removed the outdated '3-10 seconds' hint (misleading for large accounts).
2026-09-16 18:17:52 +08:00
atsunatsu 01ca06b43f chore: bump version to 4.4.7-ba7opf.6 (versionCode 476) 2026-09-16 16:58:05 +08:00
atsunatsu 22363c2549 fix(map): roamed-grid stripes keep geographic density
Stripe spacing now scales as 44px * 2^(zoom-7) instead of a fixed 44px on
screen: at the map's max zoom (7.0) the stripes keep the established look,
and at lower zooms they shrink with the map, so stripes stay pinned to the
map (same stripes-per-cell at every level) — denser on screen when zoomed
out, sparser when zoomed in.
2026-09-16 16:53:54 +08:00
atsunatsu 45fe755470 ui(dialogs): pad title away from cancel; LoTW buttons bottom corners
- SharedDialog title gets start padding when only a cancel button is shown
  (was flush against it); dialogs with both buttons unchanged.
- LoTW dialog: full-sync button bottom-left, incremental-merge bottom-right
  (SpaceBetween instead of side-by-side).
2026-09-16 16:53:50 +08:00
atsunatsu 0ce177cfbf fix(mutual/radar): bigger radar shortcut; no bounce-back on finished pass
- Pass card header: radar icon button enlarged to 48dp touch target with a
  24dp icon, spanning both header rows vertically (name/time + elevations);
  the pass info column narrows so the Opposite elevation shifts left.
- RadarDestination no longer auto-returns instantly when the mutual pass has
  already ended — the auto-return now only runs while the pass is actually in
  progress. Previously the collapsed-card radar shortcut appeared broken
  ('flash back to Mutual') whenever the computed pass had finished.
- Fakes: add the 4 last-sync bookkeeping members missed by the LoTW commit.
2026-09-16 16:53:46 +08:00
atsunatsu f3fdf61784 feat(mutual): radar shortcut icon on collapsed pass card
Icon-only radar button at the right end of the collapsed pass header (always
visible, not only when expanded); the pass info shifts left. The button's tap
is consumed and does not toggle the card expansion.
2026-09-16 14:34:04 +08:00
atsunatsu fc028f852c feat(lotw): full/incremental sync modes with live progress and time estimate
Two explicit sync buttons in the LoTW dialog:
- Full sync: qso_qslsince=2000-01-01, fresh report replaces all stored data.
- Incremental merge: since=last successful sync date, merged into the stored
  set (grids union, QSOs deduped by call + time). First sync / callsign change
  automatically falls back to full replace so no stale grids from another
  account linger.

Progress & estimates:
- Report body is now streamed; the header's <APP_LoTW_NUMREC> gives the real
  QSL count, total size is estimated as NUMREC * 720 B (measured), and the UI
  shows a determinate bar with 'N QSOs, ~X s remaining' once known.
- Pre-sync hint shows the measured rate (~8 s per 100 QSOs).
- readTimeout raised to 600 s (stall timeout, not a total cap — huge accounts
  legitimately take 30+ min at ARRL's ~10 KB/s stream rate); connectTimeout
  raised to 60 s (a busy server took 36.5 s to accept a connection).

Also: ISettingsRepo gains last-sync date/callsign bookkeeping, and the mutual
test fake implements the new members.
2026-09-16 14:34:00 +08:00
atsunatsu 7a865fffd7 chore: bump version to 4.4.7-ba7opf.5 (versionCode 475) 2026-09-16 11:57:27 +08:00
atsunatsu 89220a0b4b fix(status): day tiles show total daily report count
DayCell displayed the count of the newest non-gray slot only; it now sums the
counts of all 12 slots of the day. The tile color still reflects the newest
reported status.
2026-09-16 11:57:24 +08:00
atsunatsu 03d0055ce2 refactor(settings): drop redundant map-grid toggle
Grid mode is toggled from the map page pill (MapAction.ToggleGridMode writes
stateOfMapGrid); the settings-page switch was a duplicate entry point. Removes
the SwitchRow, the ToggleMapGrid action and its ViewModel branch, and trims
the OtherCard height by one row.
2026-09-16 11:57:18 +08:00
atsunatsu afb1e434e8 fix(map): constrain VUCC grid selector width to its content
The expanded option rows used fillMaxWidth inside a full-screen Box, which
stretched the selector's dark surface across the whole map. The Column now
sizes to its widest child (IntrinsicSize.Max) so the box is just wide enough
for the label, count and expand arrow.
2026-09-16 11:57:11 +08:00
atsunatsu 1bed7849a3 feat(mutual): mutual page times follow the UTC setting
The pass card time range and the elevation-curve X-axis labels were hardcoded
to the device timezone. MutualUiState now carries isUtc, initialized and kept
in sync with OtherSettings.stateOfUtc (same pattern as PassesViewModel), and
both the card timeFormat and the chart axis use UTC when the setting is on.

Also fixes pre-existing test-fake compile breakage: FakeSettingsRepo built
OtherSettings with positional args missing shouldSeeWhatsNew, and never
implemented the wavelogSettings/lotwSettings/worked-grids members added by
later interface extensions.
2026-09-16 11:57:03 +08:00
atsunatsu 06f00e1601 chore: bump version to 4.4.7-ba7opf.4 (versionCode 474) 2026-09-16 04:10:03 +08:00
atsunatsu 462c3de155 fix(radar): elevation color thresholds not applied on radar page
Root cause: LocalElevationThresholds was provided inside MainScreen, but
RadarDestination is a root-level sibling entry in NavRoot's NavDisplay, so
it fell outside the CompositionLocal scope and always used the default
15/45 thresholds.

Fix: hoist the CompositionLocalProvider to NavRoot so both the tab content
and the root-level RadarDestination read the user's low/high elevation
thresholds. Also affects upstream (same navigation structure).
2026-09-16 02:41:53 +08:00
atsunatsu 4020381b03 chore: bump version to 4.4.7-ba7opf.3 (versionCode 473) 2026-09-15 11:16:33 +08:00
atsunatsu b8389f63fe fix(awards): close antimeridian-straddling rings so shared zone/country boundaries render
WAZ/DXCC award rings are stored with the closing edge implicit (the renderer
close()s single-segment rings). Rings that straddle ±180° are split into
multiple segments, and multi-segment rings must not be close()d (each close()
would draw a spurious chord across the map), so the implicit closing edge
was never drawn -> the shared boundary between WAZ zones 31/32 (165E,-8 ->
180,-12), and the closing edges of Russia's main ring / Fiji / Russia's
Chukotka tip in DXCC, stayed open.

Fix: store every ring explicitly closed (append first point). splitRingAt
Antimeridian then cuts the closing edge at ±180 exactly like any other edge,
so each segment ends on the correct side and the boundary lines render.
2026-09-15 11:16:32 +08:00
atsunatsu e64beee44f 更新 README.md 2026-09-15 10:04:18 +08:00
atsunatsu d2dffc039c 更新 README.md 2026-09-15 10:03:30 +08:00
atsunatsu d7d4a752ed chore: bump version to 4.4.7-ba7opf.2 (versionCode 472) 2026-09-15 01:49:33 +08:00
atsunatsu 6a3ab54226 fix(map): '1 QSO' singular never matched on zh systems
Android plurals have no 'one' category in Chinese (zh plural rules only
contain 'other'), so a single QSO rendered as '1 QSOs' on Chinese-locale
devices. Replace the plurals resource with explicit singular/plural
strings selected in code.
2026-09-15 01:34:04 +08:00
atsunatsu 3b3579696b fix(map): worked grids follow the selected operated grid; add All option
Switching the operated-grid selector now re-filters the green worked
cells on the map (the overlay receives the per-grid worked set instead
of the global one, on every recomposition via setGridMode). Previously
only the VUCC progress count changed while the map stayed green for all
operated grids.

The selector gains an 'All (n)' entry at the bottom of the list: it
selects no operated grid (null), showing every worked grid combined and
restoring the global VUCC count.
2026-09-15 00:25:12 +08:00
atsunatsu 826f44829b fix(map): polar field-row grid lines vanish at field zoom
Maidenhead row R (80-90N) and row A (-90..-80S) vertical lines were drawn
with projectionToY(±90). Web Mercator diverges there: osmdroid's
TileSystemWebMercator yields y01=±Infinity -> Long.MIN/MAX_VALUE pixel
coordinates (verified via JVM test: lat=90 -> -9.2e18, lat=-90 -> +9.2e18).
The canvas cannot rasterize such coordinates, so the polar rows' grid
lines silently disappeared at field zoom (2-char labels).

Fix: clamp the projected latitude to the Mercator limit ±85.0511 in
projectionToY(). The polar rows then project onto the screen edge and
their lines render (the sliver beyond 85.05 is off-screen anyway). Row A
kept its symmetric handling; row R now uses the same path.
2026-09-14 21:24:35 +08:00
atsunatsu b979524b37 chore: bump version to 4.4.7-ba7opf.1 (versionCode 471) 2026-09-14 20:45:38 +08:00
atsunatsu 9e748f5534 feat(map): worked-grid QSO counts and operated-grid selector
- QSO dialog: each callsign row shows 'n QSO'/'n QSOs' (plurals) centered
  between callsign and date, styled like the date.
- Grid mode + VUCC filter: top-left selector picks the operated grid the
  VUCC count is computed from (defaults to the most-worked grid), with a
  collapsible list of all operated grids; arrow style matches the QSO
  dialog rows.
2026-09-14 20:45:25 +08:00
atsunatsu 25b982ac59 feat(vucc): per operated-grid VUCC counting
GridQso gains myGrid (ADIF MY_GRIDSQUARE) captured per QSO during LoTW
parsing (handles alphabetical field order + EOR reset) and persisted as
JSON key 'mg' (absent in pre-myGrid data -> null). Legacy syncs keep the
global VUCC count; one resync unlocks per-grid counting.
2026-09-14 20:45:20 +08:00
atsunatsu 6a6399b606 fix(passes): recalculate passes when the UTC toggle changes
Port of upstream PR #252 (wty2019wty): combine otherSettings.stateOfUtc
into initRepository so an existing AOS time window is reapplied in the
newly selected timezone right after toggling UTC, instead of waiting for
the next selection/position change.
2026-09-14 18:54:22 +08:00
atsunatsu c1cc992abd chore: bump version to 4.4.7-ba7opf.0 2026-09-14 14:30:27 +08:00
atsunatsu 212dced3c4 feat(data): persist ndot and protect manually imported transceivers
- DB v2 migration: entries.ndot column so hasDecayed() actually works after
  reload (parsed ndot was dropped on write, making the decay detection dead
  code); radios.isCustom column to mark manually imported transceivers.
- updateTransceiversFromFile marks imported radios isCustom=true; remote
  updates now deleteManagedRadios() then insert the fresh snapshot, keeping
  manual imports (no source can refresh them, so nothing would bring them back).
- Wire MIGRATION_1_2 into MainContainer (fallbackToDestructiveMigration=false
  would otherwise crash on upgrade).
2026-09-14 14:30:23 +08:00
atsunatsu 7ef555e377 fix(time): respect UTC toggle for AOS filter window and sunrise/sunset day anchor
- PassesViewModel: use the DEVICE timezone for the natural-day anchor in
  findTodaySunRiseSet instead of the display timezone. With UTC enabled the
  anchor was UTC midnight = 08:00 local for UTC+8 stations, when the sun is
  already up — tripping the white-night guard and rendering '--:--'.
- SatelliteRepo.isAosInRange: follow the UTC display toggle so the pass time
  window is interpreted in UTC when UTC mode is on. Previously the list
  showed UTC times while the filter silently used local time, off by the
  timezone offset (8h for China).
2026-09-14 14:13:16 +08:00
atsunatsu 2773145e0c chore(upstream): v4.4.7 low-risk backports — night overlay alloc-free, radar inset padding, sunrise/sunset tests 2026-09-14 13:41:15 +08:00
atsunatsu d8d3d81648 perf(map): viewport culling, label limit, scroll refresh, SetVisible gating 2026-09-14 13:41:11 +08:00
atsunatsu 25a41def35 i18n: localize WAPC/WAJA map labels to English
- wapc.json: add name_en for all 34 provinces (Beijing/Guangdong/...)
- waja.json: add name_en for all 47 prefectures (Gifu/Kyoto/...)
- AwardBoundaryOverlay: render English label on non-Chinese UI,
  fall back to source name when name_en is absent
- bump version to 4.4.6-ba7opf.10.4 (469)
2026-09-13 01:25:36 +08:00
atsunatsu d9dc0a1735 docs: reword ADIF parsing description in README 2026-09-12 05:56:41 +08:00
atsunatsu 692e917893 docs: drop boundary-claim wording and Wavelog from README (LoTW sync only) 2026-09-12 05:54:03 +08:00
atsunatsu 6371bb114d docs: list major fork features in README — grid mode, award boundaries, LoTW/Wavelog sync, in-app updates, custom TLE sources 2026-09-12 03:05:47 +08:00
atsunatsu 2d76aeda23 chore: bump version to v4.4.6-ba7opf.10.3 2026-09-12 01:51:13 +08:00
atsunatsu ecc24fd836 fix(radar): CW decoder text hidden behind system nav bar on gesture-nav devices
The radar page is a full-screen destination; the transceiver list had no
bottom inset padding, so on gesture navigation the translucent system bar
floated over the CW decoder's decoded-text output at the end of the list.
Add navigationBars height as LazyColumn contentPadding (no-op on
three-button nav where the window is already resized).
2026-09-12 01:22:02 +08:00
atsunatsu 689f08881c fix(map): dxcc boundary aligns with Chinese official claim — Zangnan + Karakoram corridor
- Zangnan (Arunachal Pradesh): China ring south edge moved from the de-facto
  line to the official claim (DataV Tibet south arc); India ring's Arunachal
  protrusion removed so the region shows as China (both rings share the new
  boundary). Verified: Zangnan points in China ring only, Assam stays India.
- Karakoram corridor / Shaksgam valley (China-controlled since 1963): the
  coarse 13-pt west segment (Karakoram Pass -> Afghan/Tajik corner) replaced
  with the official Xinjiang south arc (232 pts) — the valley is now inside
  the China ring. Aksai Chin was already correct; no other entity deviates
  from the official map (SCS islands follow their own DXCC entities).
2026-09-12 01:08:43 +08:00
atsunatsu d467200a45 style(map): roamed stripes — 44f spacing, 14f width, alpha 90 (match green fill)
User picked the spacing-44/width-14 GridMaster-style zebra from the preview
grid; opacity lowered to the same alpha as the worked green fill (90).
2026-09-12 00:32:59 +08:00
atsunatsu 6277e36a96 feat(map): own 4-char grid bold outline also at field zoom; bump v4.4.6-ba7opf.10.2
Field zoom (2-char labels) now draws the station's own 2°x1° square outline
on top of the field grid, so the operator always sees their exact grid.
Polar labels + order-independent LoTW parsing fixes ride along in this release.
2026-09-11 23:58:57 +08:00
atsunatsu 08a5c8c460 fix(lotw): order-independent ADIF parsing — roamed/work grids dropped by sequential PROP_MODE gate
parseConfirmedGrids and parseRoamedGrids gated grid fields on propMode == SAT
at the moment the field line was read. LoTW emits ADIF fields alphabetically
(GRIDSQUARE/MY_GRIDSQUARE before PROP_MODE), so the gate was never satisfied
on real reports: the worked set AND the roamed set silently came back empty.
Both parsers now buffer the record and decide at <EOR>, matching the proven
parseConfirmedGridQsos pattern. Regression tests cover the alphabetical order.
2026-09-11 23:47:08 +08:00
atsunatsu 1ffb5ed9e8 fix(map): polar 2-char labels visible + own-grid bold only at sub-square zoom
- Polar bands (80..90 / -90..-80) extend past the map's latitude limit, so
  their label center fell off-screen and the labels never rendered. Labels are
  now centered in the VISIBLE part of the cell; fully off-screen cells are
  still skipped. Verified with a projection-accurate simulation render: Arctic
  band shows IR/JR, Antarctic band shows IA/JA, ±80 lines intact.
- Own-grid bold outline is drawn only at sub-square zoom (zoom >= 6): at field
  zoom (2-char labels) the current grid is no longer marked (user request).
  The ownGrid-set-every-frame fix stays, so the outline still survives map
  refreshes at sub-square zoom.
2026-09-11 23:12:33 +08:00
atsunatsu cc684b6e3a fix(map): own-grid outline in grid-line color (bold only); passes top bar aligned to upstream; v10.1
- Bold outline now uses the same yellow as the regular grid lines
  (argb(160,255,224,130)) at 6f stroke — no color change, just thicker.
- Passes top bar matches upstream exactly: left = radios dialog
  (ic_radios -> ToggleRadiosDialog), right = passes filter dialog
  (ic_filter -> TogglePassesDialog). The calendar button stays only on the
  radar page (upstream behavior); removes the now-unreachable
  PassesAction.AddToCalendar wiring.
- Version bumped to 4.4.6-ba7opf.10.1.
2026-09-11 22:48:53 +08:00
atsunatsu 6cb5b062d3 fix(map): own-grid bold outline wiped on every recomposition — keep ownGrid set
setGridMode only received stationPosition on the grid-mode entry frame (for
centering); every later recomposition (map refreshes once per second) passed
null, so gridOverlay.ownGrid was reset to null and the bold outline vanished.
Split the concerns: ownGrid is now derived from uiState.stationPosition on
EVERY update, while centering stays gated by centerOnStation (= entry only).
2026-09-11 22:40:44 +08:00
atsunatsu 9c12b1eccb fix(map): own grid never striped — bold outline only
The station's own gridsquare is in the roamed set (LoTW MY_GRIDSQUARE includes
the home grid). Exclude it from the blue stripe overlay at both zoom levels so
the current cell shows only its bold cyan outline, matching the user's request.

chore: bump version to 4.4.6-ba7opf.10
2026-09-11 21:17:12 +08:00
atsunatsu d56392f3c6 feat(ui): top-right 日程按钮 — add current/next pass to system calendar on radar and passes pages
Restores the original repo's calendar function (ic_calendar → AddToCalendar via
CalendarContract ACTION_INSERT) to the top-right of both pages, replacing the
map buttons:
- Radar page: back to upstream's addToCalendar (current pass name/aos/los).
- Passes page: new PassesAction.AddToCalendar wired through PassesViewModel +
  container.provideAddToCalendar() (next pass name/aos/los); left button back
  to the pass filter (reverts the earlier radios-button swap).
The map button is gone from both top bars — the map is now a bottom-nav tab.
Also drops the now-dead openMapRequest/navigateToMap navigation chain in
NavRoot/MainScreen that only the radar map button used.
2026-09-11 21:03:59 +08:00
atsunatsu 14b98cc7b1 feat(cw): ship 64-bit CW decoder — arm64-v8a jniLibs from Morse Expert 1.15 Play split
Brings in libnativedecoderjni.so for arm64-v8a (from Iu-yang1/Look4Sat-FT4,
the Morse Expert 1.15 official Play arm64 split) and lifts the ABI pin from
armeabi-v7a-only to armeabi-v7a + arm64-v8a in both feature/cw and app. 64-bit
devices now load the native decoder natively instead of falling back to ARM32
(or crashing when the arm64 lib was missing).
2026-09-11 20:54:25 +08:00
atsunatsu 200d2274de chore: bump version to 4.4.6-ba7opf.9.19 2026-09-11 20:42:30 +08:00
atsunatsu 3d950f90ad feat(ui): map as 4th bottom-nav tab, passes top bar restored to upstream layout, bigger QSO expand arrow
- Map moves from the passes-page top-right button to the bottom navigation
  bar at position 4 from the left (Satellites, Passes, AMSAT, Map, Mutual,
  Settings), matching the upstream nav order.
- Passes top bar restored to the original repo layout: left = radios dialog
  (ic_radios), right = passes filter/schedule dialog (ic_filter) — the map
  button is gone from the top bar.
- Worked-grid QSO dialog expand arrow enlarged from bodySmall to 18sp.
2026-09-11 20:42:27 +08:00
atsunatsu 1cc0bc770c feat(map): VUCC default chip, own-grid bold at all zooms, roamed-grid blue stripes
- Grid mode now opens with the VUCC award chip selected by default (re-asserted
  on every grid-mode entry; the user's in-mode chip choice is preserved).
- The station's own grid outline is now drawn at every zoom level: 2-char field
  border below the sub-square zoom, 4-char square border above it (was hidden
  at the default entry zoom), stroke widened to 6f.
- Grids the account operated from (LoTW <MY_GRIDSQUARE>, satellite QSOs only)
  are covered with GridMaster-style blue 45° zebra stripes. The stripe paint is
  fully opaque so a worked+roamed cell shows pure-blue stripes with green
  between them — the two colors never alpha-blend into a teal/green mix.
- LoTW sync now parses and persists the roamed set (LoTWResult.Success +
  SettingsRepo key 'roamedGrids'), plumbed through MapState/MapViewModel into
  MaidenheadGridOverlay. Unit tests cover MY_GRIDSQUARE parsing (SAT-only,
  GRIDSQUARE non-leak, 4-char truncation).
2026-09-11 20:27:46 +08:00
atsunatsu 84ba25c163 fix(map): disable vertical map repetition — eliminate spurious vertical artifact lines
osmdroid's vertical (Y) map repetition wraps points below the viewport up to
the top edge. Antarctica's full-circle ring then draws a full-height artifact
line whenever its X range crosses the screen at low zoom. The map must never
repeat vertically, so disable setVerticalMapRepetitionEnabled.
2026-09-11 20:27:34 +08:00
atsunatsu 5bf4546ee3 chore: bump version to 4.4.6-ba7opf.9.18 2026-09-10 01:36:18 +08:00
atsunatsu 030a6eecc9 fix(map): split antimeridian edges instead of dropping — closes WAZ zone 1/19/31/32 outlines
splitRingAtAntimeridian used to drop any edge crossing ±180°, leaving
visible gaps in WAZ zone outlines (zone 31's 40°N Pacific boundary,
zone 1/19's polar arcs, zone 32's Antarctic arc). The edge is now split
in two at ±180° with interpolated latitude, each half kept in its own
segment, so outlines close across the map's left/right edges.
normalizeLon now maps to (-180, 180] so edges ENDING on the
antimeridian are not mistaken for crossings.
2026-09-10 01:05:25 +08:00
atsunatsu 42174da0be fix(map): chip order — VUCC first, DXCC second
chore: bump version to 4.4.6-ba7opf.9.17
2026-09-09 09:20:51 +08:00
atsunatsu 06421f8cfc fix(map): eliminate spurious pole-to-pole lines in award boundary overlay
- projectionToY clamps latitude to Web Mercator max (±85.05) so raw -90
  polar vertices (Antarctica ring, WAZ zones 29/30/32/39) no longer project
  to infinity, removing vertical pole spikes
- traceSegment unwraps longitude deltas per-vertex (continuous with previous
  point) instead of folding each vertex independently — a full-circle ring
  (Antarctica, span ~358°) no longer jumps ±180° mid-ring to draw a line
  across the whole map
- closing chord now folds to the nearest world copy (short-side seal at the
  antimeridian) instead of a full-width chord
- AwardCalculator: DXCC chip now listed before VUCC
2026-09-09 08:49:37 +08:00
atsunatsu 9ea5b533c1 chore: bump version to 4.4.6-ba7opf.9.16 2026-09-09 02:58:09 +08:00
atsunatsu b6a9331ba3 refactor(map): drop redundant 'All' award chip — VUCC already shows plain worked-grid view 2026-09-09 02:50:31 +08:00
atsunatsu 390fd7717d chore: bump version to 4.4.6-ba7opf.9.15 2026-09-09 02:31:54 +08:00
atsunatsu ef0a34723b fix(map): award boundary fixes — HK/MO labels, MO DXCC entity 152, antimeridian ring closure
- wapc/dxcc assets: add Hong Kong & Macau labels (forceLabel) and MO boundary in dxcc
- AwardBoundaryOverlay: rings split at antimeridian no longer close() per segment — removes spurious Russia cross-continent chord
- AwardCalculator: Macau DXCC entity 330 -> 152 (+test)
- Settings LoTW dialog: add sync duration hint (zh/en)
2026-09-09 02:30:54 +08:00
atsunatsu 36d091a7e1 chore: bump version to 4.4.6-ba7opf.9.14 2026-09-08 16:44:11 +08:00
atsunatsu 20143cf096 fix(lotw): strip STATE ' // Name' suffix for award stats
LoTW returns STATE as 'CODE // Name' (verified with a real report 2026-09),
e.g. 'GD // Guangdong' (China), '34 // Tottori-ken' (Japan), 'CA // California'
(US). The award calculator matched on the bare CODE, so every mainland China
province (WAPC), all Japan prefectures (WAJA) and US states (WAS) failed —
WAPC only showed HK/MO/TW (entity branch, no STATE needed) and WAJA was empty.

Normalize STATE once at parse time: strip the ' // Name' suffix so the stored
GridQso.state is the clean code consumed by persistence and AwardCalculator.
Add a test covering China/Japan/US formats.
2026-09-08 11:22:24 +08:00
atsunatsu a64f9c80b9 fix(map): split award boundary rings at antimeridian + anchor projection
DXCC (Russia/Fiji/Antarctica) and WAZ (Pacific CQ zones 1/19/31/32) polygon
rings cross ±180. The old overlay normalized every vertex to [-180,180)
independently, folding far-side vertices onto the opposite screen edge and
drawing a spurious line sweeping across the whole map (esp. at low zoom:
DXCC horizontal/vertical lines, WAZ extra long edges).

Split each ring into contiguous segments at the antimeridian, then project
each segment anchored on its first vertex with continuous longitude offsets
(no per-vertex fold). Ring geometry stays intact while panning/zooming.
2026-09-08 11:22:16 +08:00
atsunatsu db0320f521 chore: bump version to 4.4.6-ba7opf.9.13 2026-09-08 08:47:56 +08:00
atsunatsu adc9ac008b fix(map): restore grid lines between worked cells
The .9.11 skip logic removed every line segment bordering a worked
gridsquare, which also erased the shared edges between adjacent worked
cells — adjacent greens merged into one unreadable patch. Revert to
drawing the full Maidenhead grid unconditionally; the green fills stay
on top exactly as before.
2026-09-08 08:26:44 +08:00
atsunatsu 4a33d1b19a feat(map): award filters with boundary overlay in grid mode
- Grid-mode top bar replaced with award filter chips (All/DXCC/VUCC/
  WAPC/WAJA/WAZ/WAS) showing live progress (count/target)
- New AwardBoundaryOverlay renders real administrative boundaries
  (provinces/prefectures/CQ zones/states/countries) with the VUCC visual
  language: yellow boundary lines, green fill for worked regions, labels
- Boundary assets packaged from public GeoJSON (DataV China, Japan land,
  HB9HIL CQ zones, Natural Earth states/countries) with ADIF match keys:
  WAPC province codes, WAJA ADIF prefecture numbers, US state abbrs,
  ARRL DXCC entity codes
- GridQso extended with dxcc/country/cqz/state parsed from LoTW report
  (qso_qsldetail); AwardCalculator derives six-award progress; STATE is
  interpreted per DXCC entity (ADIF 'STATE depends on DXCC')
- Grid-mode toggle pill floated at map top-right (Grid mode / Satellite mode)
- New tests: AwardCalculatorTest (9 cases), LoTW award-field parsing (3 cases)
2026-09-08 08:22:12 +08:00
atsunatsu cce9209b3d chore: bump version to 4.4.6-ba7opf.9.12 2026-09-08 06:14:34 +08:00
atsunatsu 237e4cb27a fix(update): APK asset URL used stale '-release' filename — downloads 404'd
Since v4.4.6-ba7opf.9.9 releases are uploaded as Look4Sat-<version>.apk (no
'-release' suffix), but parseRelease still built the download URL as
Look4Sat-<version>-release.apk. Update check succeeded (page parses) while the
download 404'd ('下载失败请重试'). Align the generated URL with the actual
asset name; mirror-prefixed URLs fixed too.
2026-09-08 06:09:42 +08:00
atsunatsu cd3eded77c chore: bump version to 4.4.6-ba7opf.9.11 2026-09-08 06:03:35 +08:00
atsunatsu 7d7f8f1eda feat(map): expanded QSO list — drop ordinal prefix, add per-QSO date
Expanded per-callsign rows previously read '2 · FO-29 · CW · V/U · 11:30Z'
(ordinal + summary, no date). Now each expanded line is
'yyyy-MM-dd · FO-29 · CW · V/U · 11:30Z' — the first QSO's date already
lives in the header row, so only subsequent QSOs carry a date prefix.
2026-09-08 05:57:27 +08:00
atsunatsu 11b46c6710 feat(map): tap worked cells from GRID_ZOOM_SUB (lines visible), labels from zoom 6.5
- Tap-to-show-QSOs now works as soon as 4-char grid lines appear (zoom 6),
  not only when 4-char labels show (was zoom 7). Selection outline already
  used GRID_ZOOM_SUB, so feedback is immediate.
- 4-char labels now appear from zoom 6.5 (was 7.0), so the sub-square name
  shows shortly after the lines appear instead of a full zoom level later.
2026-09-08 05:54:06 +08:00
atsunatsu da1facac64 fix(map): world-repeat copies no longer repaint in place — alpha stacking made worked cells brighter and opaque
Pixel analysis of the user's screenshot: worked-cell green measured RGB(57,160,74)
against background RGB(16,16,15), which matches alpha≈178 — i.e. the alpha=90
fill painted ~3 times in place. Root cause: at low zoom colRepeats>0 and every
turn loop shifted the cell by dLon=turn*360°, but projectionToX() normalizes
longitude back into [-180,180), so all turns collapsed onto the SAME screen x
and each cell/line/label was painted 2*colRepeats+1 times at one spot.

Fix: keep the turn offset as a pixel shift (turn * worldWidthPx) on the x
coordinate in ALL turn loops (worked fills at both zooms, vertical lines,
horizontal lines, labels). World-repeat copies now land on their true screen
positions instead of stacking on top of each other.
2026-09-08 05:46:08 +08:00
atsunatsu cb1f341ed9 fix(map): field-zoom grid lines no longer cut through worked cells — root cause of 'green too bright'
The previous fixes only handled sub-square zoom lines. At field zoom the 20°x10°
grid lines were drawn unconditionally: cellLabel(zoom<6) returns 2-char fields
which never match the 4-char workedGrids set, so every field line crossed every
worked 2°x1° square. Bright-yellow (alpha 160) lines through the green fill is
what read as 'green got brighter' — the fill color itself never changed.

Now both line passes iterate the finer 2°x1° sub-grid (forced 4-char labels via
GRID_ZOOM_SUB) and skip segments bordering worked cells, at field zoom and
sub-square zoom alike. Revert the alpha softening (c2e496c4) — the fill was
never the problem; also restores worked-cell labels (7afce7ee revert).
2026-09-08 05:28:12 +08:00
atsunatsu c2e496c406 fix(map): soften worked-cell fill at sub-square zoom, restore cell labels
Reverting the label-skip from 7afce7ee: worked cells keep their 4-char
labels. The 'green too bright' report is an area effect, not an overlay
overdraw — the same alpha=90 fill that reads as a small patch at field zoom
covers half the viewport at sub-square zoom. Use a softer fill (alpha 50)
for the sub-square branch, field zoom unchanged.
2026-09-08 05:02:05 +08:00
atsunatsu 7afce7ee68 fix(map): skip labels on worked cells — bright-yellow 4-char text on green fill read as 'green too bright'
The previous fix skipped grid LINES through worked cells, but each worked cell
still got a bright-yellow (alpha 220 + black shadow) 4-char label drawn dead
center on the green fill at zoom>=7. Field zoom never shows this because 2-char
labels sit at field centers and almost never overlap a worked 2x1 square. Now
worked cells stay completely label-free, matching the field-zoom look.
2026-09-08 04:47:43 +08:00
atsunatsu 86048ee1fd fix(lotw): run report fetch on Dispatchers.IO — main-thread blocking caused ANR/crash
fetchReportBody is a synchronous HttpURLConnection call (up to 30s connect +
120s read) but was invoked directly from a Main-dispatcher viewModelScope
coroutine: the whole UI froze for the duration of the request, then Android
killed the app with an ANR dialog. Wrap both fetch methods in
withContext(Dispatchers.IO). Bug present since the endpoint was introduced.
2026-09-08 04:32:21 +08:00
atsunatsu 42a6ffa4c9 chore: bump version to 4.4.6-ba7opf.9.9 2026-09-08 04:17:52 +08:00
atsunatsu cf1d8e6539 LoTW sync: distinguish failure causes (bad credentials / rate limit / timeout / network)
Rate limit classification per ARRL policy (one download in progress per user
id; HTTP 503 'Page request limit' observed on throttled pulls) and the HTML
login-error page returned for wrong credentials. Settings screen now shows a
specific message per cause instead of one generic 'check callsign/password/network'.
2026-09-08 04:07:03 +08:00
atsunatsu ab8b072f1f fix(map): worked cells at sub-square zoom stay line-free — grid lines skip worked segments
Lines (alpha 160 yellow) were drawn AFTER the worked fills (alpha 90 green),
so at zoom>=6 every worked cell's four borders got yellow-on-green
composited — the cells looked brighter than the same cells at field zoom
(no square lines there). Segments bordering a worked cell are now skipped
in both meridian and parallel passes.
2026-09-08 03:44:21 +08:00
atsunatsu 1d4036e3d0 fix(map): vertical grid lines vanish at global views — bbox now anchored on view center, not left/right edge pixels
The antimeridian unwrap (span > 180 => shift left edge +360) misfired on
every global view (span is legitimately > 180): the range inverted and the
meridian loop drew nothing. Anchor the bbox on the view-center longitude
plus half viewport width in degrees instead; continuous unwrapped range
handles both antimeridian crossing and world repeats.
2026-09-08 03:25:12 +08:00
atsunatsu ab22244f86 fix(map): grid-mode field center offset by -90/-180 base
Maidenhead field index 0 spans -90..-80 lat / -180..-160 lon, so the
field-center must subtract those bases. Without them, entering grid mode
centered a hemisphere away (OL62 -> 115N/290E instead of 25N/110E).
2026-09-08 03:19:40 +08:00
atsunatsu 3b0874d707 chore: bump version to 4.4.6-ba7opf.9.8 2026-09-08 02:56:16 +08:00
atsunatsu 2c5b1479f1 test(lotw): VUCC up to 4 grids in one contact; clarify comment 2026-09-08 02:51:24 +08:00
atsunatsu 39d4498a54 feat(map): tap worked grid at zoom>=7 to show per-grid satellite QSO dialog
- LoTW sync now stores per-QSO detail (call/time/sat/mode/bands) grouped by
  gridsquare (PROP_MODE=SAT only), single JSON blob in SharedPreferences
- GridQso model derives band label (V/U, U/V, V/A...) from BAND_RX/BAND
- Tap detection via osmdroid MapEventsOverlay; selected grid outlined green
- Centered dialog: calls sorted by first-contact time, first-QSO summary,
  expandable call rows list subsequent QSOs in that grid; tap outside closes
2026-09-08 02:47:54 +08:00
atsunatsu 9ed2aebc9d fix(lotw): only collect satellite QSO grids (PROP_MODE=SAT); keep both grids of VUCC_GRIDS pairs 2026-09-08 02:09:25 +08:00
atsunatsu 72d5e13acf tune(map): 4-char sub-square labels at zoom>=7; zoom 6 shows grid lines only 2026-09-08 01:55:40 +08:00
atsunatsu 736dc23aae chore(map): drop unused FIELD_ZOOM_LABELS constant 2026-09-08 01:52:01 +08:00
atsunatsu 052ba79c94 fix(map): field labels visible at all zooms (pixel-size gated), not gated by FIELD_ZOOM_LABELS 2026-09-08 01:51:14 +08:00
atsunatsu cb648caf65 tune(map): raise thresholds — sub-square grid lines at zoom>=6 (was 5), field labels at zoom>=4 (was 3) 2026-09-08 01:48:24 +08:00
atsunatsu 05170541ed fix(map): grid mode centering via view.post (first-layout reset); antimeridian unwrap of visible bbox fixes Pacific-centered vanishing grid; tile meridians/fills across world repeats at low zoom 2026-09-08 01:39:21 +08:00
atsunatsu e75d2dc410 chore: bump version to 4.4.6-ba7opf.9.7 2026-09-08 00:27:17 +08:00
atsunatsu d55a07bc26 feat(map): fill individual worked 2x1 degree squares green at field zoom without square grid lines 2026-09-08 00:14:05 +08:00
atsunatsu c372386a77 fix(data): LoTW sync resilience — longer timeouts (30s/120s), gzip transfer, follow redirects; ARRL server-side latency is the bottleneck 2026-09-08 00:05:22 +08:00
atsunatsu de6ade025c feat(settings): LoTW dialog — hint rewritten without Wavelog, confirm button is now Sync, remove separate sync button 2026-09-07 23:29:19 +08:00
atsunatsu 2eb6d6cd5b fix(map): stop satellite tracking animation in grid mode so it stays centered on the local grid 2026-09-07 22:54:19 +08:00
atsunatsu c09f718aae feat: mutual chip uses theme primary yellow, plain text distance; top bar text indent; low-zoom meridians via direct Mercator math (bypass osmdroid wrap bug); remove Wavelog sync entry — LoTW is the sole grid source (replace instead of merge) 2026-09-07 19:10:44 +08:00
atsunatsu a9fe0b74e9 feat: mutual match screen target-grid chip (needle bearing + grid + distance); grid mode centering no longer swallowed when station position is not yet available 2026-09-07 17:54:14 +08:00
atsunatsu 2c9a5b376f feat: release-notes body in update checker; grid mode centers on station grid on every entry; own grid square drawn with thick outline; low-zoom meridian vanishing while panning fixed 2026-09-07 15:06:33 +08:00
atsunatsu 422e480b2f release: v4.4.6-ba7opf.9.3 2026-09-07 14:07:57 +08:00
atsunatsu efd6931abb fix: LoTW sync — force full report with qso_qslsince (LoTW default only returns new confirmations since last query) and accept lowercase <eoh> header 2026-09-07 13:51:41 +08:00
atsunatsu c7ca77a943 fix: sync dialogs use typed values directly (save+sync in one step); LoTW card wording; center map on station grid when entering grid mode; center grid labels in cells; update-check falls back to GitHub accelerator mirrors 2026-09-07 07:22:41 +08:00
atsunatsu be6473e692 feat: LoTW confirmed-grids sync card — fetch confirmed QSO grids directly from ARRL LoTW (lotwreport.adi) and merge with Wavelog grids; credentials stored locally 2026-09-07 06:55:42 +08:00
atsunatsu 75d162301b fix: meridians vanish at low zoom (osmdroid clips Mercator X; normalize longitude to view center); hide satellite layer and bottom info card in grid mode 2026-09-07 06:28:58 +08:00
atsunatsu 07c09ec3e9 fix: VersionComparator parses multi-level suffixes (4.4.6-ba7opf.9.1) by comparing all numeric segments, not just the last one 2026-09-07 06:11:31 +08:00
atsunatsu 513d50e56a chore: bump version to 4.4.6-ba7opf.9.1 2026-09-07 06:01:45 +08:00
atsunatsu 366edd8bbb fix: correct longitude bounds in Maidenhead grid overlay (left/right were swapped, so meridians and worked-grid fills never drew) 2026-09-07 06:01:10 +08:00
atsunatsu 13df0dfcec fix: Wavelog sync via v1 API logbook_get_worked_grids (3.0.2 has no API v2) 2026-09-07 02:11:40 +08:00
atsunatsu eb1a5d51a2 chore: bump version to 4.4.6-ba7opf.9 2026-09-07 02:02:36 +08:00
atsunatsu 4e3547649f feat: Maidenhead grid mode with worked-grid highlights from Wavelog (API v2 grid=all) 2026-09-07 01:42:42 +08:00
atsunatsu 7abbeb6eb3 fix: update check via GitHub web endpoint, avoiding API 60 req/hour rate limit 2026-09-06 21:33:41 +08:00
atsunatsu 5c73d5ed7a feat: default TLE source order with LAPAN-A2 (Kaggle) first, R4UAB second 2026-09-06 20:18:04 +08:00
atsunatsu a663ef7f1b build: read release signing credentials from local.properties/env instead of hardcoding
Passwords for the release keystore were committed in plaintext to a
public repo. Move them to gitignored local.properties with an
environment-variable fallback. Keystore file itself stays in $HOME.
2026-09-05 07:57:33 +08:00
atsunatsu 1f1e1fb144 fix: keep update install action visible 2026-09-01 11:03:47 +08:00
atsunatsu 5166daa297 fix: localize AMSAT date labels in Chinese 2026-09-01 10:18:56 +08:00
atsunatsu 604c2ec59d feat: check for updates page in settings
Add a full-screen update checker accessible from a button at the
bottom of the settings page. It queries the fork's GitHub latest
release and can download + install the APK in place.

- UpdateRepository (core/data): fetch releases/latest via GitHub API,
  parse tag/description/APK asset, stream APK download to cacheDir
- VersionComparator (core/domain): compare 4.4.6-ba7opf.N style tags
  (base version first, then build number; v-prefix tolerant) + tests
- IMainContainer/MainContainer: inject updateRepo
- UpdateCheckerScreen (feature/settings): shows current version,
  checks for updates, displays latest version + release notes, and a
  Download & install button; FileProvider hands the APK to the
  system installer
- SettingsScreen: bottom entry button, full-screen page toggle
- AndroidManifest: REQUEST_INSTALL_PACKAGES permission + FileProvider
- i18n strings (en/zh)
2026-08-29 16:25:51 +08:00
atsunatsu 1904a6b3e6 feat: drag reorder handle in data sources dialog
Port the upstream drag-reorder pattern (dragHandle/draggedVisual/
autoScroll/DragRowState) so custom data source rows can be reordered
by long-pressing the handle icon, replacing the old move-up button.

- Add 40dp drag-handle box at row start with long-press gesture
- Live reorder while dragging (neighbour squeeze + spring settle)
- Edge auto-scroll during drag
- Keep per-source enabled checkbox + HTTP status label

This file also carries the #242 data-source dialog UI (enabled
checkbox, status code label, restore-default button) which shares
the same sourceSection function and cannot be cleanly split.
2026-08-29 16:25:09 +08:00
atsunatsu 7f390117b8 feat: data source toggles + HTTP status codes (upstream #242)
Port upstream PR #242 data layer: per-source enabled toggles and HTTP
status codes for custom data sources.

- IRemoteSource: NetworkResult(code, stream) + getNetworkStream
- RemoteSource: return HTTP status code (200 ok / error code / ERR)
- ISettingsRepo: dataSourcesStatus StateFlow<Map<String,Int>>
- SettingsRepo: persist enabled flags (single JSON per key) + status
- DatabaseRepo: filter by enabled toggles, write back status codes
- Settings.kt: DataSourcesSettings + satelliteEnabled/transceiversEnabled
- Sync test fakes (AmSatRepositoryTest, DatabaseRepoTest, Fakes.kt)

Fork-specific features preserved: builtinTypesByUrl reverse lookup,
satnogs migration, map-type data source structure.
2026-08-29 16:21:59 +08:00
atsunatsu 346803fe6e fix: normalize Maidenhead grid input to uppercase in mutual pass
- onStationAGrid/onStationBGrid store the trimmed upper-cased grid instead
  of the raw input (ol63 used to stay lowercase in the UI)
- drop the local latLonToGrid/gridToLatLon copies and reuse the domain
  QthConverter, removing the drift risk between two implementations
- inject computeDispatcher (default Dispatchers.Default, no behaviour
  change) so the pass computation is unit-testable on a test scheduler
- add MutualViewModelTest (15 cases): init prefill, grid/lat-lon linkage,
  error paths, main-list reuse, fallback search, sorting, clearError
2026-08-29 10:55:15 +08:00
atsunatsu 47b87a1604 refactor: consolidate Maidenhead conversion into domain QthConverter (4/6/8-char)
- QthConverter.qthToPosition now accepts 4/6/8-char locators (previously
  6-char only: 4-char returned null, 8-char was silently truncated)
- positionToQth now emits upper-case sub-square letters (IO91VL) so all
  callers render grids consistently
- Extended QthConverterTest: 4/6/8-char parsing, odd-length and out-of-range
  rejection, lower-case input normalisation
2026-08-29 10:54:55 +08:00
atsunatsu 935fe6d99f chore: bump version to 4.4.6-ba7opf.6 2026-08-28 15:39:17 +08:00
atsunatsu 69af1c38bb fix: use real SatNOGS TLE source so newly launched satellites appear
- Point the built-in SatNOGS source at db.satnogs.org/api/tle/?format=3le
  (the CelesTrak satnogs group is a separate, slower subset); rename the
  old CelesTrak source to 'CelesTrak SatNOGS'.
- Add a one-time migration in SettingsRepo: installed apps that already
  persisted a satelliteUrls list containing the old CelesTrak SatNOGS URL
  get the real SatNOGS TLE URL inserted right after it.
- Dedupe imported satellites by catnum and radios by uuid in
  DatabaseRepo.updateFromRemote so multiple sources don't insert the
  same primary key twice.
- Test: importing JAMX-0825b (NORAD 98248) from the real SatNOGS source.
2026-08-28 15:23:23 +08:00
atsunatsu 5c35f2418d chore: bump version to 4.4.6-ba7opf.5 2026-08-28 01:07:26 +08:00
atsunatsu 2aa9858660 feat: open map from radar header
Replace the radar page calendar action with the map icon and route the action through the Passes map flow so the Passes navigation item remains selected.
2026-08-28 00:42:00 +08:00
atsunatsu bc6e065804 feat: prefetch AMSAT status on foreground
Start AMSAT status prefetch when the app enters foreground and share the in-flight request with the status page fetch path to avoid duplicate network requests.
2026-08-28 00:41:32 +08:00
atsunatsu 7924a6f69e feat: cache AMSAT status while app is foreground 2026-08-23 19:27:16 +08:00
atsunatsu 9a2a715d1f chore: bump version to 4.4.6-ba7opf.3 2026-08-22 21:15:41 +08:00
atsunatsu ee32c7a1eb feat: confirm AMSAT report uploads 2026-08-22 20:50:39 +08:00
atsunatsu 1d54ac4718 style: color code AMSAT upload status chips 2026-08-22 19:43:46 +08:00
atsunatsu 433ce85561 feat: upload AMSAT status reports 2026-08-22 19:14:24 +08:00
atsunatsu cebe3b7ec4 refactor: move map access under passes 2026-08-22 18:35:53 +08:00
atsunatsu d8f60cf270 docs: add Mutual Pass and AMSAT status features to README 2026-08-20 14:28:09 +08:00
atsunatsu 4a3332fdb5 chore: remove dist/ from tracking, add to .gitignore 2026-08-19 22:19:29 +08:00
atsunatsu 9e7fdbfe71 fix: passband RX update now uses full Doppler round-trip instead of pure passband mapping
When adjusting RX in passband mode (via +/- buttons or slider), the old
code used TransponderMapper.mapDownlinkToUplink (pure passband mapping
without Doppler compensation), producing ~10kHz error at LEO velocities.

Fix: use DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset
for the full TX-from-RX round-trip, matching the RX→PASSBAND button
onClick logic. Also fixed frame mismatch: position now uses txLow/txRange
(Doppler-shifted frame) consistently.
2026-08-19 22:19:14 +08:00
atsunatsu 463df58eb9 fix: keep calculator page content composed while scrolling 2026-08-12 17:50:10 +08:00
atsunatsu 58b8801c0c fix: preserve calculator frequencies while scrolling 2026-08-11 23:42:06 +08:00
atsunatsu 2581ba92c0 feat: remember per-satellite doppler offset in calculator
Store the offset (kHz) per NORAD catalog id in SharedPreferences so
each satellite's last entered offset is restored when reopening the
frequency calculator.
2026-08-10 15:11:02 +08:00
atsunatsu e5131ebb72 fix: amsat icon position, swap mutual icon to radio-tower
- Adjust ic_satellite_alt translateY from -226.13 to -230.5 so icon
  is centered vertically (not clipped at bottom)
- Replace ic_match with ic_radio_tower (MDI) for Mutual page
2026-08-09 01:53:16 +08:00
atsunatsu f9accdd517 feat: add AMSAT status page to bottom navigation bar
- Add ic_satellite_alt.xml icon (from upstream author rt-bishop)
- Add Screen.AMSAT to Navigation.kt
- Add AMSAT entry in MainScreen.kt nav bar + NavDisplay entry
- Wire up feature:status module dependency in ApplicationPlugin
- Chinese/English nav_amsat strings already exist from earlier port
2026-08-09 01:03:30 +08:00
atsunatsu ee3c1e7058 feat: add AMSAT satellite status tracking page (#234)
Port upstream PR #234 to fork main:
- New feature:status module (SatStatusScreen, SatStatusViewModel)
- New AmSatRepository + IAmSatRepository + SatStatus models
- RemoteSource: AMSAT API methods (getAmSatCatalog, getAmSatReports)
- MainContainer/IMainContainer: DI injection
- MainTheme: AMSAT status colors (tertiary slots)
- Chinese translations added for all AMSAT strings
- 26 unit tests passing

Note: navigation entry not included — upstream PR #234 intentionally
left it out pending UI restructuring. Add AMSAT to your nav bar or
menu as desired.
2026-08-08 22:13:56 +08:00
atsunatsu a08a28df39 fix: recalculate passes when station position changes
Use combine(selectedIds, stationPosition) in SatelliteRepo.initRepository
so that passes are recalculated on either satellite selection change or
station position change. StateFlow already emits distinct values.
2026-08-08 20:30:08 +08:00
atsunatsu 759a6de2c6 chore: replace workflow badge with release version badge 2026-08-08 15:58:11 +08:00
atsunatsu ba5a984c16 fix: broaden linear transponder matching and deduplicate same-range entries
- isNamedLinearTransponder: match ' Lin' prefix (AO-7 style) and
  add fallback branch (hasLinearName && hasLinearMode) for entries
  with 'Linear'/'Lin' but no 'transponder' word (AO-7, AO-73)
- add deduplicateTransponders: merge entries sharing the same
  uplink/downlink frequency range, preferring non-CW entry
  (AO-7 Mode A had separate SSB and CW entries; JO-97 had
  separate CW and SSB Transponder entries)
- apply dedup in CalculatorPage filter chain
- 6 new tests, 23 total, all passing
2026-08-08 15:42:33 +08:00
atsunatsu 083b8a2847 Merge pull request #5 from MCKero6423/feat/thanks-bg7nta
Add BG7NTA (CW decoding feature) to the thanks list
2026-08-08 12:44:05 +08:00
mckero e8ebfb1ef3 feat(credits): add BG7NTA to the thanks list
Add BG7NTA (CW decoding feature) to the in-app credits/thanks section
across all three locale files (en, zh, tr).
2026-08-08 03:52:24 +00:00
atsunatsu 12eed1d9a9 i18n: use string resources for mutual match page (zh/en) 2026-08-07 21:27:01 +08:00
atsunatsu b3859fbece i18n: translate '友台' to 'opposite' for clarity 2026-08-07 20:06:43 +08:00
atsunatsu f40a67e1b7 revert: remove fldigi CW decoder migration, keep Morse Expert 2026-08-07 20:01:53 +08:00
atsunatsu 16dce5df2a chore: remove .cxx build artifacts from git tracking 2026-08-07 19:59:03 +08:00
atsunatsu 80a58bed61 i18n: localize mutual match page to English 2026-08-07 19:53:36 +08:00
atsunatsu a8afc63bba fix: default CW decoder to ham radio QSO mode 2026-08-07 18:43:01 +08:00
atsunatsu dbf9a2a65f fix: native decoder crash on 64-bit devices; restore calculator layout 2026-08-07 11:25:42 +08:00
atsunatsu ddecaa6017 docs: update README for BA7OPF fork features 2026-08-06 13:19:32 +08:00
atsunatsu 418a05e343 fix(i18n): update Chinese station position wording 2026-08-04 21:03:35 +08:00
atsunatsu c80e351212 fix: correct linear calculator offset mapping 2026-08-04 18:41:53 +08:00
atsunatsu a333192df5 feat: add CW decoder and linear calculator page 2026-08-04 17:49:28 +08:00
atsunatsu a7b41c95c7 fix: improve match header and daily sun times
Match page UI:
- Always show a meaningful status line in the top bar instead of leaving the second row blank on first entry.
- Add a compact status chip for waiting, calculating, result, no-match, and error states.
- Remove the duplicate intro card so the first screen starts directly with station inputs.

Pass list sun times:
- Compute sunrise/sunset from each visible date group's 00:00 in the selected timezone.
- Avoid using an arbitrary pass AOS as the rise/set search start, which could jump later-day headers to the following day's events.
2026-08-03 21:38:57 +08:00
atsunatsu 2298d8eea4 feat: add mutual radar overlay arrows 2026-08-03 17:27:48 +08:00
atsunatsu 1813e1bfa4 fix: refine mutual pass matching and radar overlay 2026-08-03 13:04:42 +08:00
atsunatsu bd09ad22ff chore: remove .hermes from git tracking, add to gitignore 2026-08-03 08:55:04 +08:00
atsunatsu 7e90071c4d fix: revert radar pager to Transceivers tab, default minElev to 0
- Radar pager now defaults to Transceivers tab (initialPage=0)
- Transceivers don't auto-expand (selectedUuid defaults to null)
- Mutual page default minElev changed from settings value to 0.0
- Display filters out portions below the minElev threshold
2026-08-03 03:58:45 +08:00
atsunatsu d1cc944be2 feat: filter mutual page curves to minElev window (satlover.de style)
Elevation curve chart and mutual radar plot now only show the portion
where both stations are above their respective minimum elevation.
The curves are filtered at display time, the pass search still uses
the 0° horizon boundary for consistency with the Passes page.
2026-08-03 03:47:25 +08:00
atsunatsu fb28283679 feat: default pager to SSTV, remove radar plot drag, filter B track by elevation>0 2026-08-03 03:44:37 +08:00
atsunatsu 81ae8772d2 feat: add 'current exact position' button for station A
Removed the automatic posA=stationPos override. Added a button
'当前精确位置' below the grid input that fills in the exact station
position from settings (lat/lon + grid). Users can now freely edit
the position fields and use the button when they want the exact
position.
2026-08-03 03:32:03 +08:00
atsunatsu 571145838d fix: gridToLatLon normalization bug + bidirectional grid/latlon sync
Root cause: gridToLatLon used (lon+180)%360-180 and (lat+90)%180-90
for normalization, treating grid values as from prime meridian/equator
when they are actually from IDL/South Pole. A 6-char grid like OL62AA
(112°E, 22°N) was returning -67.96°, -67.98° (Atlantic Ocean).

Also added bidirectional sync: entering a grid auto-fills lat/lon,
entering lat/lon auto-fills the 6-char grid, so users can verify.
2026-08-03 03:27:15 +08:00
atsunatsu 017b178f2e fix: use refineEdge with exact station position to get correct AOS/LOS boundaries 2026-08-03 03:15:39 +08:00
atsunatsu 670444858c fix: use getElevation for elevation sampling (same function as getLeoPass)
getFullPosition and getElevation both call calculateObs internally,
but the elevation values reported by the user (-60°) suggest they may
differ. Now elevation is computed via getElevation (same as getLeoPass),
while getFullPosition is only used for azimuth.
2026-08-03 03:13:11 +08:00
atsunatsu 7bdff5df81 fix: use passes list with corrected station position, fallback to independent search 2026-08-03 03:08:35 +08:00
atsunatsu df083840ac fix: use independent search algorithm directly, no passes list reuse
The passes list AOS/LOS times can be stale or computed for a different
station position, causing elevation curves to show -60° at pass start.
The independent search (refineEdge + sampleMutualPass) always computes
elevation for the actual positions. With posA now set to the exact
station position, the pass times should match the main page.
2026-08-03 03:03:08 +08:00
atsunatsu a0279bac17 fix: use pass AOS/LOS times directly without refineEdge
The refineEdge function re-computed boundaries using the grid-center
position (posA), which differs from the exact station position that
the passes list was computed with. This caused all passes to be
filtered out. Now we trust the pass list's AOS/LOS times directly
and just sample the elevation/azimuth curves.
2026-08-03 02:47:09 +08:00
atsunatsu 3d630041e1 chore: add debug info when mutual pass search finds nothing
Shows pass count and satellite count in the error message to help
diagnose why the main pass list yields no results.
2026-08-03 02:27:01 +08:00
atsunatsu ea92d3c150 fix: add fallback pass search when main pass list yields no results
If the main pass list (satelliteRepo.passes) is empty or the common
window check filters everything out, fall back to the independent
search algorithm so the user always gets results.
2026-08-03 02:23:51 +08:00
atsunatsu 86ee3dcbac fix: reuse main page pass list for mutual search
findMutualPasses was computing passes independently with its own
search algorithm, which produced different results from the main
page's getLeoPass. Now it directly reuses satelliteRepo.passes,
which is the same list shown on the main passes page. The only
additional computation is refining the common window for station B
and sampling elevation/azimuth curves.
2026-08-03 02:16:15 +08:00
atsunatsu 43788ba7a7 fix: mutual pass search now matches main radar pass definition
Root cause: mutual search used the min-elevation threshold (default 10°)
as the AOS/LOS boundary, while the main radar uses the 0° horizon
(getLeoPass). With identical stations the windows therefore never
matched. Also, an in-progress pass was counted as a new one.

- AOS/LOS boundaries now at the 0° horizon (refined to 1s)
- Skip in-progress mutual windows at search start (like getLeoPass)
- Filter requires BOTH stations to reach their min elevation
- Default min elevation follows the main radar passes setting
2026-08-03 02:04:13 +08:00
atsunatsu 1a3f1bb34c fix: radar track rendering bugs
- Elevation ring labels were inverted (90/60/30 from outer to inner);
  now 30/60/90 correctly from outer ring to center
- Split track paths at the 0/360° azimuth wrap in both MutualRadarView
  and main RadarView, so passes crossing due north no longer draw a
  line straight across the plot
2026-08-03 01:53:37 +08:00
atsunatsu 998c98267b fix: draw full station-B track on main radar, live dot on top
Track line is no longer time-limited (always shows the whole mutual
arc). The station-B live position is drawn separately on the line
using the same pulsing-dot mode as the local station.
2026-08-03 01:29:17 +08:00
atsunatsu 28fa373db0 feat: bidirectional drag sync between elevation curve and radar track
- ElevationCurveChart is now controlled (progress + onProgressChange)
- MutualRadarView shows shared time-cursor positions for both stations
  and is draggable/tappable to move the cursor
- MutualPassCard owns a single dragProgress feeding both charts, so
  dragging either chart moves the other in sync
2026-08-03 00:58:09 +08:00
atsunatsu 4ca33c37ad feat: keep mutual state across nav + overlay station-B track on main radar
- MutualViewModel is Activity-scoped, so returning from Radar keeps query results
- TrackSampleData gains time field for live cut-off
- Removed standalone mutual card from radar page
- RadarViewCompose draws optional dashed station-B track + current dot
- RadarScreen builds trackB from mutual data up to current time
2026-08-03 00:55:05 +08:00
atsunatsu bd42e643ea feat: dual-station radar track plot (satlover/satmatch style)
- MutualPass now carries TrackSample (azimuth/elevation for both stations)
- New MutualRadarView: polar plot with elevation rings (30/60/90),
  cardinal spokes, solid A-track vs dashed B-track, AOS/LOS markers
- Shown in expanded mutual pass card and radar page mutual card
2026-08-03 00:21:20 +08:00
atsunatsu 4baf3821e5 fix: elevation curve x-axis float precision loss
Absolute epoch millis (~1.7e12) exceeds Float precision (ULP ~131s
at that magnitude), so 5s-spaced samples collapsed onto identical
x coordinates, producing a stepped/jagged curve. Use Long relative
time deltas (sample - start) before converting to Float.
2026-08-02 23:36:59 +08:00
atsunatsu d7e1ce11c9 fix: smooth dual-station elevation curve
- Fix cubic Bezier control points (proper Catmull-Rom to Bezier conversion)
- Refine AOS/LOS more robustly by walking from edge into the pass window
- Still 5s sampling for smooth curves
2026-08-02 23:07:28 +08:00
atsunatsu 5de370983d fix: lock screen orientation to portrait 2026-08-02 22:19:37 +08:00
atsunatsu f272e268ab fix: smooth elevation curves using cubic Bezier instead of lineTo
Replace Path.lineTo() with Path.cubicTo() using Catmull-Rom
to Bezier conversion for smooth elevation curves.
2026-08-02 22:10:17 +08:00
atsunatsu e59507a4d3 fix: smoother elevation curves with refined AOS/LOS and 5s sampling
- Reduce sample interval from 10s to 5s for smoother curves
- Add refineEdge() to find exact AOS/LOS at 1s resolution
- Curve now starts/ends at the correct horizon-crossing points
2026-08-02 21:59:51 +08:00
atsunatsu 0bae306312 fix: default station A grid, find all mutual passes, color toArgb()
- Pre-fill station A grid from settingsRepo.stationPosition
- Find ALL mutual passes per satellite (not just first)
- Fix Color.hashCode() -> Color.toArgb() for native canvas paint
- Use 2min gap between pass searches to avoid duplicates
2026-08-02 21:44:31 +08:00
atsunatsu 65f61a5147 fix: mutual pass elevation unit (radians->degrees) + grid input
Bug: OrbitalObject.getElevation() returns radians, but
MutualViewModel was comparing it directly with degree values
from the slider (10-90), causing no matches to be found.

Fix: add elevationDeg() helper that converts to degrees.

Also add Maidenhead grid square input support (4/6/8 chars)
alongside existing lat/lon fields. Grid takes priority when
filled.
2026-08-02 21:36:44 +08:00
atsunatsu 1064dc739e feat: add mutual pass query with dual-station elevation curve
Port satlover.de dual-station pass matching feature:
- New feature/mutual module with mutual pass data model
- MutualViewModel: compute overlapping passes for two stations
- ElevationCurveChart: Canvas-based dual elevation curve with drag
- MutualScreen: input form + results with expandable cards
- Navigation: add Mutual tab to bottom navigation bar
- i18n: add Chinese/English strings for new feature
2026-08-02 21:22:42 +08:00
atsunatsu 8aedd38b51 fix: consistent Chinese date format for pass headers and sun times
Extract dateFormat() helper to ensure computeSunTimes and
groupPasses use identical date format, fixing sunrise/sunset
display bug.

For Chinese locale: "2026年8月1日 星期六"
For English locale: "Sat, 01 Aug 2026"
2026-08-02 20:28:56 +08:00
atsunatsu ad8f08a577 fix: revert DateFormat.FULL to SimpleDateFormat with locale
DateFormat.FULL may cause inconsistent date labels between
computeSunTimes and groupPasses, leading to missing or
wrong sunrise/sunset times. Revert to the original pattern
"EEE, dd MMM yyyy" but with Locale.getDefault() so day
names follow the system language.
2026-08-02 20:26:01 +08:00
atsunatsu 2afce54472 fix: restore satellite catalog number in satellite selection list
Keep number removed from pass list (Components.kt) and
pass detail (PassesScreen.kt) as requested, but restore
it in the satellite selection list (SatellitesScreen.kt).
2026-08-02 20:09:51 +08:00
atsunatsu 8e50fcf8fb style: use primary color for satellite name (same as former number)
Use MaterialTheme.colorScheme.primary for satellite name
text in all three lists (Components, SatellitesScreen,
PassesScreen), matching the color that was previously
used for the catalog number.
2026-08-02 19:58:00 +08:00
atsunatsu e11b03d56b refactor: remove satellite catalog number from UI
Remove the NORAD catalog number (e.g. '44444 - ') prefix
from satellite names in three places:
- Pass list items (Components.kt)
- Satellite selection list (SatellitesScreen.kt)
- Pass detail view (PassesScreen.kt)

The number is still accessible via satellite details if needed.
2026-08-02 19:48:14 +08:00
atsunatsu fa55b82a5a fix(i18n): use locale-aware full date format for Chinese convention
Replace SimpleDateFormat("EEE, dd MMM yyyy") with
DateFormat.getDateInstance(DateFormat.FULL, locale) which
automatically uses the correct format for each locale:

- Chinese: "2026年8月1日 星期六"
- English: "Saturday, August 1, 2026"
- Other locales: their respective conventions
2026-08-02 19:34:43 +08:00
atsunatsu d57832b0d4 fix(i18n): use system locale for date formatting instead of English
PassesViewModel and PassesScreen used Locale.ENGLISH for
SimpleDateFormat, causing day-of-week names like 'Sun' to
always show in English. Changed to Locale.getDefault() so
the device's language setting is respected.

Affects: 'EEE, dd MMM yyyy' date labels in the pass list
(group headers and sun times), and 'HH:mm:ss' time format.
2026-08-02 19:30:42 +08:00
atsunatsu 1d0e8921a9 i18n(zh): complete Chinese translation with new features
- Add Doppler calculator strings (多普勒频率计算器)
- Add CW decoder strings (CW 解码器、开始、停止、清空)
- Add CAT radio control strings (电台控制、CAT 电台控制等)
- Add data import error messages
- Update outro thanks with xdsopl and Robot36 contributors
- Preserve all existing 154 lines of translation
- 20 new strings added
2026-08-02 19:23:17 +08:00
atsunatsu 23d95b8241 fix(cw): fix timing analysis - process per spectrogram column
- Add newColumnCount tracking to CwSpectrogram
- CwDecoder now processes each new column individually for timing
- Each column = 8ms at 8000 Hz sample rate
- Proper per-column iteration through spectrogram history
2026-08-01 19:52:39 +08:00
atsunatsu 8af80866bc feat(cw): v3 spectrogram-based multi-channel Bayesian decoder
Complete rewrite inspired by Morse Expert / CW Skimmer (VE3NEA):
- CwFFT: radix-2 FFT (256-point) for time-frequency analysis
- CwSpectrogram: sliding-window waterfall (40 cols x 33 bins, 8ms resolution)
- CwBayesianDecoder: Gaussian probability replaces hard dit/dash thresholds
- CwChannelTracker: multi-channel peak detection (up to 3 signals)
- CwDecoder: integrates all components, monitors 200-1200 Hz simultaneously

Key advantages over v2 (ggmorse):
- Frequency-agnostic: full spectrum monitored, not locked to one tone
- Multi-channel: tracks multiple signals in parallel
- Bayesian: probability-based decisions, not hard ratios
- Doppler tolerant: frequency drift just moves energy between bins
2026-08-01 19:41:32 +08:00
atsunatsu b9e5ff70f5 feat(cw): add continuous pitch tracking for Doppler drift
- After initial pitch lock, re-scan every ~8 seconds in a narrow
  ±100 Hz window around the current pitch estimate
- Only re-lock if pitch change > 20 Hz (avoids jitter)
- Helps maintain lock when Doppler tracking has residual error
2026-08-01 19:25:27 +08:00
atsunatsu c176d6ad88 feat(cw): port ggmorse algorithms - auto pitch/speed detection, adaptive threshold
- Add CwResampler: linear resampler (downsample to 4 kHz base rate)
- Add CwFilter: first-order IIR high-pass (200 Hz) + low-pass (1200 Hz)
- Add CwGoertzel: running Goertzel filter for tone tracking
- Add CwPitchDetector: DFT-based pitch detection (200-1200 Hz, 10 Hz steps)
- Rewrite CwDecoder: auto pitch detection, auto speed estimation (5-55 WPM),
  adaptive threshold with exponential moving average, resampled 4 kHz pipeline
- 22 unit tests covering resampler, filter, Goertzel, pitch detector, decoder state
- All existing UI/ViewModel code unchanged (same class interface)
2026-08-01 19:22:14 +08:00
atsunatsu 5a6b162e4f fix(cw): add microphone permission check before starting decoder
- Dispatch CwPermissionResult alongside SstvPermissionResult
- Add requestMicPermission callback through TransceiversPage chain
- Check cw.hasPermission before starting audio capture
- Prevent crash when RECORD_AUDIO not granted
2026-07-31 19:00:56 +08:00
atsunatsu 33712c29fc feat(cw): add built-in CW Morse code decoder for linear transponders
- Add CwDsp with FIR bandpass filter, envelope detection, Goertzel tone detector
- Add CwDecoder with real-time Morse timing analysis and character lookup
- Add CW state/actions to RadarState, wire into RadarViewModel
- Add collapsible CW decoder panel to transceivers page
- 19 unit tests covering DSP, Morse table, and decoder state
- Shares IAudioCapture with SSTV, auto-stops SSTV when CW starts
2026-07-31 18:44:49 +08:00
atsunatsu eac1e6e273 feat: add Doppler frequency calculator for linear transponders (issue #91)
- Add DopplerFrequencyCalculator utility with passband mapping + Doppler correction
- Add MHz input UI with real-time 1Hz refresh during pass
- Add kHz offset for downlink frequency correction
- TX/RX fields on same row for compact layout
- 40 unit tests covering linear/FM/inverted transponders
2026-07-31 14:17:19 +08:00
Arty Bishop f585372594 Added cleartext traffic support for custom TLE URLs #227 2026-07-30 17:15:02 +02:00
Arty Bishop 4585332b1a Fixed manual OMM (.csv) data import, tweaked messaging 2026-07-30 15:56:50 +02:00
Arty Bishop 6d2b0ced49 Implemented AOS window and elevation highlight filters 2026-07-30 13:28:37 +02:00
Arty Bishop e86bc2c700 Added a few tweaks to SSTV sensitivity and reception 2026-07-30 12:01:54 +02:00
Arty Bishop 9a52fdfde5 Added support for Icom IC-705 CAT (#229) 2026-07-30 11:43:03 +02:00
Rui Oliveira 07504a2a95 Add the option to override the frequency from the radio 2026-07-24 20:29:51 +01:00
Rui Oliveira 81525b6dd3 Map AFSK (e.g. ISS APRS) to FM 2026-07-24 20:10:37 +01:00
Rui Oliveira c7089ab314 Fix setting the mode (e.g. FM, USB, LSB) 2026-07-24 20:10:10 +01:00
Rui Oliveira 77dfb7bb05 Remove PTT-aware logic, which is not necessary
I was setting the Tx frequency only when we were
in Tx mode, but the Icom 705 will accept the
command to set the Tx frequency even when in Rx mode, so
there is no need to check the PTT state before sending the command.
2026-07-24 20:09:43 +01:00
Rui Oliveira 58fb688593 Fix bugs in setting the Tx frequency
Uses 0x25 01 command to set the Tx frequency,
which is the correct command when in split mode.
2026-07-24 20:09:01 +01:00
Rui Oliveira dc596b4348 Fix bugs in setting Rx frequency 2026-07-24 20:08:46 +01:00
Rui Oliveira f9b1a97a37 Refactor "Disconnect" and "Track" logic 2026-07-24 20:08:23 +01:00
Rui Oliveira d700432829 Add initial support for Icom IC-705 radio
- Added support for Icom IC-705 radio model with split mode functionality.
- Updated RadioTrackingService to handle single-radio split mode and dual-radio configurations.
- Modified MainContainer to provide appropriate radio controllers based on selected model.
- Enhanced SettingsRepo to include split mode preference in radio control settings.
- Updated UI in SettingsDialog to allow toggling of split mode for IC-705 and adjust device selection accordingly.
- Improved handling of baud rates based on selected radio model.
- Added extended operations in IRadioController for IC-705 specific commands.
2026-07-24 20:07:11 +01:00
Arty Bishop b17ea2d918 v4.4.3 - Added required tweaks to support Android 17 (API 37) 2026-06-27 14:15:05 +01:00
Arty Bishop 976fdfd949 Added Star History graph to README, tweaked AGENTS 2026-06-27 12:31:24 +01:00
dependabot[bot] d8db258bd8 Bump actions/checkout from 6 to 7 (#221)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 16:38:32 +01:00
Arty Bishop 7b6fb5eb8f v4.4.2 - Fixed transceivers retention, various minor tweaks 2026-06-21 15:19:47 +01:00
Arty Bishop f4aef4f4f0 Fixed SSTV frequency display and "All" category retention 2026-06-21 15:10:28 +01:00
Arty Bishop 9147323db2 Cleaned up SensorsRepo class, removed deprecated calls 2026-06-21 15:03:45 +01:00
Arty Bishop 81d397a09d v4.4.1 - Continuous SSTV decoding, frequency display 2026-06-07 17:23:19 +01:00
Arty Bishop ec1a55c93d Added initial code for possible future deeplink nav support 2026-06-07 16:01:23 +01:00
Arty Bishop e5033cb2e9 Added continuous SSTV decoding of manually selected type 2026-06-06 19:20:35 +01:00
Arty Bishop 39b786ae82 v4.4.0 - Implemented CAT and SSTV support in RadarScreen 2026-06-02 20:40:06 +01:00
Arty Bishop 79809aaec0 Added SSTV image decoding functionality to RadarScreen 2026-06-02 20:06:22 +01:00
Arty Bishop f7f5a73c4c Added colored elevation and decay check to satellite passes 2026-05-30 15:35:52 +01:00
Arty Bishop 18c8ac1822 Merged RadarScreen and RadioControlScreen functionality 2026-05-30 14:14:40 +01:00
Arty Bishop 22607e7710 v4.3.2 - Implemented Swipe-to-Focus behavior, minor fixes 2026-05-30 13:16:14 +01:00
Arty Bishop ad0ff6859e Updated release workflow to avoid third-party dependencies 2026-05-25 18:30:49 +01:00
Arty Bishop a39b1716e7 Added consistent pass selection to Radar and Map screens 2026-05-25 18:30:49 +01:00
Arty Bishop 8a2339cfbf Implemented Swipe-to-Focus behavior for satellite passes 2026-05-25 18:30:48 +01:00
Arty Bishop 821fa0dfe7 v4.3.1 - Zipped custom sources handling, translation fixes 2026-05-04 16:55:15 +01:00
Arty Bishop ce8c2a4834 Added zipped data sources handling to DatabaseRepo 2026-05-04 15:36:42 +01:00
Arty Bishop 8cc4ef5610 Tweaked passes list to show DeepSpace ones at the top 2026-05-04 15:36:42 +01:00
Mubi-Baihua dde22faf46 Added minor tweaks to Chinese translation (#216) 2026-05-02 19:32:18 +01:00
Arty Bishop 7ad81e32e1 v4.3.0 - Sticky header, sun/moon positions, red night mode 2026-04-30 19:32:17 +01:00
Arty Bishop 5af963f3c9 Added several tweaks to sunrise/sunset time calculations 2026-04-28 21:00:43 +01:00
Arty Bishop bfb9fb6ca8 Tweaked ViewModel retention to mirror the Nav2 behavior 2026-04-26 14:44:29 +01:00
Emre Can AkdaşandArty Bishop df24c66ef3 Fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
Co-authored-by: Arty Bishop <44072814+rt-bishop@users.noreply.github.com>
2026-04-26 14:42:16 +01:00
Arty Bishop b166e896c2 Added current moon/sun positions to the RadarScreen 2026-04-26 14:32:16 +01:00
Arty Bishop 18f8ab517d Added current moon/sun positions to the MapScreen 2026-04-26 00:11:08 +01:00
Arty Bishop d3b8e951dd Added red night mode filter overlay for the whole app 2026-04-25 19:22:32 +01:00
Arty Bishop 6e5a3cb0bb Added sticky header with a date and sunrise/sunset time 2026-04-25 19:20:22 +01:00
Arty Bishop 31d329a19b Added CelestialComputer, extracted common functionality 2026-04-25 18:18:00 +01:00
Arty Bishop 3df358b88c v4.2.2 - Kotlin Serialization, Navigation3, translation fixes 2026-04-24 17:24:00 +01:00
Arty Bishop 7d5bca300d Migrated to Compose Navigation3, updated dependencies 2026-04-24 17:20:23 +01:00
Arty Bishop fb2cd85b11 Migrated to KotlinX Serialization library for JSON parsing 2026-04-24 17:18:06 +01:00
252 changed files with 35205 additions and 2370 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# GitHub Copilot Instructions
Read `AGENTS.md` first, then `CLAUDE.md`.
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+42 -37
View File
@@ -16,59 +16,64 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v5 uses: gradle/actions/setup-gradle@v6
- name: Assemble Artifacts - name: Assemble Artifacts
run: | run: ./gradlew assembleRelease bundleRelease
./gradlew assembleRelease
./gradlew bundleRelease
- name: Sign APK - name: Sign APK
uses: r0adkll/sign-android-release@v1 run: |
id: sign_apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
with: APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
releaseDirectory: app/build/outputs/apk/release BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
signingKeyBase64: ${{ secrets.KEY_STORE }} ${BUILD_TOOLS}apksigner sign \
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} --ks keystore.jks \
alias: ${{ secrets.KEY_ALIAS }} --ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
keyPassword: ${{ secrets.KEY_PASSWORD }} --ks-key-alias ${{ secrets.KEY_ALIAS }} \
env: --key-pass pass:${{ secrets.KEY_PASSWORD }} \
BUILD_TOOLS_VERSION: "36.0.0" --out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
- name: Sign Bundle - name: Sign Bundle
uses: r0adkll/sign-android-release@v1
id: sign_bundle
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEY_STORE }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename Artifacts
run: | run: |
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Deploy Bundle - name: Setup Ruby
uses: r0adkll/upload-google-play@v1 uses: ruby/setup-ruby@v1
with: with:
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}} ruby-version: '3.4'
packageName: com.rtbishop.look4sat
releaseFiles: app/build/outputs/apk/release/look4sat.aab - name: Deploy Bundle to Google Play
track: production run: |
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew gem install multi_json
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release - name: Create Release
run: | run: |
+5
View File
@@ -1,6 +1,9 @@
# Built application files # Built application files
*.ap_ *.ap_
# Hermes AI plans and metadata
.hermes/
# Files for the ART/Dalvik VM # Files for the ART/Dalvik VM
*.dex *.dex
@@ -15,6 +18,7 @@ out/
# Gradle files # Gradle files
.gradle/ .gradle/
build/ build/
.cxx/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties
@@ -64,3 +68,4 @@ fastlane/readme.md
/app/release/output-metadata.json /app/release/output-metadata.json
/app/release/ /app/release/
/.kotlin/sessions/ /.kotlin/sessions/
dist/
+122
View File
@@ -0,0 +1,122 @@
# Look4Sat AI Agent Instructions
This is the canonical project guide for all AI assistants working on Look4Sat.
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
---
## 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.
## Architecture
**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()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
| Module | Responsibility |
|----------------------|---------------------------------------------------------------------|
| `app` | Entry point. Aggregates all modules |
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
| `feature:map` | OSMDroid map with ground tracks |
| `feature:passes` | Pass predictions and upcoming events |
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
| `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.
## Build & Run
```shell
# Debug build
./gradlew assembleDebug
# Release build (minified, shrunk resources)
./gradlew assembleRelease
# Run tests
./gradlew test
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
## Key Libraries
- **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
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data 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).
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
- **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.
**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
**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.
## 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.
## Roadmap
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
## 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.
- 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.
## Copilot Working Mode: Code-Only
- Default to code changes only. Provide explanations in chat only.
- If documentation seems useful, ask first before creating files.
- Do NOT create any `.md` documentation files unless explicitly requested.
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
- Prefer minimal diffs focused on requested implementation.
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
Read `AGENTS.md` first, then follow the instructions there.
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
+32 -25
View File
@@ -1,35 +1,42 @@
# Look4Sat: Satellite tracker # Look4Sat-BA7OPF
[![Look4Sat CI](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml/badge.svg)](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml) [![Release](https://img.shields.io/github/v/release/atsunatsu/Look4Sat)](https://github.com/atsunatsu/Look4Sat/releases)
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat) **BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict ## 本仓库特色功能
<p float="left"> - **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/> - **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/> - **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/> - **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192"> - **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
</p> - **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
### Track satellite passes with ease! ## 上游仓库
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\ 本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
You can search the entire database by NORAD Catalog Number or the satellite's name.
Orbital positions and passes are calculated relative to your location.\ - 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
To get reliable data make sure to set the station position via the app Settings. - SGP4/SDP4 轨道预测,10 天过境预报
- 极坐标雷达图、地面轨迹图
- SSTV 图像解码
- 无广告、无跟踪、完全离线
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\ ## 许可证
It is now and always will be completely ad-free and open-source.
## Main features: GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
* Predicting satellite positions and passes for up to 10 days ## Star History
* Showing the list of currently active and upcoming satellite passes
* Showing the active pass progress, polar trajectory and transceivers info <a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
* Showing the satellite positional data, footprint and ground track on the map <picture>
* Custom TLE satellite data import is available via Three Line Element .txt files <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
* Offline first: calculations are made offline. Weekly TLE data update is recommended. <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
</picture>
</a>
+38
View File
@@ -1,3 +1,41 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
alias(libs.plugins.convention.applicationPlugin) alias(libs.plugins.convention.applicationPlugin)
} }
// Release signing credentials live in local.properties (gitignored) or
// environment variables — never hardcode passwords in VCS.
val releaseProps = Properties().apply {
val propsFile = rootProject.file("local.properties")
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
}
fun releaseCred(name: String): String =
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
android {
namespace = libs.versions.packageName.get()
defaultConfig {
applicationId = "cn.ba7opf.look4sat"
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
ndk {
abiFilters.add("armeabi-v7a")
abiFilters.add("arm64-v8a")
}
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
+24
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission <uses-permission
@@ -11,26 +12,49 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Look4Sat.SplashScreen"> android:theme="@style/Theme.Look4Sat.SplashScreen">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <intent-filter android:autoVerify="true">-->
<!-- <action android:name="android.intent.action.VIEW" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data android:scheme="https" />-->
<!-- <data android:host="github.com" />-->
<!-- <data android:pathPattern="/rt-bishop/Look4Sat/passes.*" />-->
<!-- </intent-filter>-->
</activity> </activity>
<meta-data <meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED" android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" /> android:value="com.rtbishop.look4sat" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -19,12 +19,21 @@ package com.rtbishop.look4sat
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -38,8 +47,37 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState()
setContent { setContent {
MainTheme(isDarkTheme = true) { MainScreen() } MainTheme(isDarkTheme = true) { NavRoot() }
}
}
private fun observeNightFilterState() {
val mainContainer = (applicationContext as IContainerProvider).getMainContainer()
lifecycleScope.launch {
mainContainer.settingsRepo.otherSettings
.map { it.stateOfNightMode }
.distinctUntilChanged()
.collect { nightMode -> applyNightFilter(nightMode) }
}
}
private fun applyNightFilter(enabled: Boolean) {
if (enabled) {
val nightMatrix = ColorMatrix(
floatArrayOf(
1f, 0f, 0f, 0f, 0f, // R → R
0f, 0f, 0f, 0f, 0f, // G → 0
0f, 0f, 0f, 0f, 0f, // B → 0
0f, 0f, 0f, 1f, 0f // A → A
)
)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, Paint().apply {
colorFilter = ColorMatrixColorFilter(nightMatrix)
})
} else {
window.decorView.setLayerType(View.LAYER_TYPE_NONE, null)
} }
} }
} }
@@ -17,10 +17,20 @@
*/ */
package com.rtbishop.look4sat package com.rtbishop.look4sat
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -35,12 +45,39 @@ class MainApplication : Application(), IContainerProvider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = MainContainer(this) container = MainContainer(this)
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours // trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() } container.appScope.launch { checkAutoUpdate() }
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
container.appScope.launch { checkLoTWAutoSync() }
// load satellite data on every app start // load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() } container.appScope.launch { container.satelliteRepo.initRepository() }
} }
private fun clearAmSatCacheOnAppBackground() {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var startedActivityCount = 0
override fun onActivityStarted(activity: Activity) {
if (startedActivityCount == 0) {
container.appScope.launch { container.amSatRepo.prefetchStatus() }
}
startedActivityCount += 1
}
override fun onActivityStopped(activity: Activity) {
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
})
}
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) { private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) { if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
@@ -51,4 +88,37 @@ class MainApplication : Application(), IContainerProvider {
} }
} }
} }
/**
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
* app start, but only pulls when due — LoTW credentials configured, the
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
* already synced today (ARRL limits report pulls, ~once a day per account).
* Incremental when the callsign is unchanged; a callsign change falls back
* to a full replace so no stale grids from another account linger.
* Failures are silent and simply retried on the next app start — the manual
* sync button still surfaces the explicit cause to the user.
*/
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
val settingsRepo = container.settingsRepo
val lotwSettings = settingsRepo.lotwSettings.value
if (!shouldAutoSyncLoTW(
isConfigured = lotwSettings.isConfigured,
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
today = lotwSyncToday(timeNow)
)
) return
val callsign = lotwSettings.callsign.trim().uppercase()
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
if (result is LoTWResult.Success) {
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
println("Periodic LoTW grid sync finished: $count worked grids")
} else {
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
}
}
} }
@@ -25,28 +25,34 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -57,129 +63,263 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.runtime.entryProvider
import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.rememberNavBackStack
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.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.mapDestination import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.passes.passesDestination import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.radar.radarDestination import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.radiocontrol.radioControlDestination import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.satellites.satellitesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.settings.settingsDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable @Composable
fun MainScreen(navController: NavHostController = rememberNavController()) { fun NavRoot(deeplink: String? = null) {
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) val rootBackStack = rememberNavBackStack(Screen.Passes)
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route val deeplinkResolver = DeeplinkResolver()
val startDestination = Screen.Passes.route LaunchedEffect(deeplink) {
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
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 }
// Elevation color thresholds must be provided at the root level so that BOTH
// the tab content (MainScreen) and the root-level RadarDestination see the
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
// in this NavDisplay, so a provider inside MainScreen never reaches it.
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
high = otherSettings.highElevation
)
) {
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { pushTransition },
popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar
)
}
entry<RadarDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
}
}
}
)
}
}
@Composable
fun MainScreen(
navigateToRadar: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
// Map sits at position 4 from the left, matching the upstream nav order
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
// appended before Settings.
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
// Observe radio tracking state for the status bar
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
// Activity-scoped so the map's award filter survives page switches;
// resets to VUCC only on cold start (fresh process).
val mapFilterViewModel: MapFilterViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MapFilterViewModel.factory()
)
NavigationSuiteScaffold( CompositionLocalProvider(
navigationSuiteItems = { LocalElevationThresholds provides ElevationThresholds(
items.forEach { low = otherSettings.lowElevation,
item( high = otherSettings.highElevation
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) }, )
label = { Text(stringResource(it.titleResId)) },
selected = currentDestination?.contains(it.route) ?: false,
onClick = {
if (currentDestination?.contains(it.route) ?: false) return@item
navController.navigate(it.route) {
popUpTo(startDestination) { saveState = false }
launchSingleTop = true
restoreState = false
}
})
}
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
), layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) { ) {
Column { NavigationSuiteScaffold(
NavHost( navigationSuiteItems = {
navController = navController, navItems.forEach { screen ->
startDestination = startDestination, val isSelected = when (currentKey) {
enterTransition = { fadeIn(animationSpec = tween(350)) }, is Screen.Satellites -> screen is Screen.Satellites
exitTransition = { fadeOut(animationSpec = tween(350)) }, is Screen.Passes -> screen is Screen.Passes
modifier = Modifier.weight(1f) is Screen.Map -> screen is Screen.Map
) { is Screen.AMSAT -> screen is Screen.AMSAT
satellitesDestination { navController.navigateUp() } is Screen.Mutual -> screen is Screen.Mutual
passesDestination { catNum: Int, aosTime: Long -> is Screen.Settings -> screen is Screen.Settings
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}" else -> false
navController.navigate(radarRoute) }
} item(
radarDestination( icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
navigateUp = { navController.navigateUp() }, label = { Text(stringResource(screen.titleResId)) },
navigateToRadioControl = { catNum, aosTime -> selected = isSelected,
val route = "${Screen.RadioControl.route}?catNum=$catNum&aosTime=$aosTime" onClick = {
navController.navigate(route) if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
} }
) },
radioControlDestination { navController.navigateUp() } navigationSuiteColors = NavigationSuiteDefaults.colors(
mapDestination() navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
settingsDestination() ),
} layoutType = when {
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
// Radio tracking status banner (above bottom navigation) !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
if (trackingState.isActive) { else -> NavigationSuiteType.ShortNavigationBarMedium
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") }
val alpha by infiniteTransition.animateFloat( ) {
initialValue = 1f, targetValue = 0.4f, Column {
animationSpec = infiniteRepeatable( NavDisplay(
animation = tween(1000, easing = LinearEasing), backStack = backStack,
repeatMode = RepeatMode.Reverse modifier = Modifier.weight(1f),
), label = "pulseAlpha" onBack = navigateBack,
) transitionSpec = { fadeTransition },
Row( popTransitionSpec = { fadeTransition },
verticalAlignment = Alignment.CenterVertically, predictivePopTransitionSpec = { fadeTransition },
modifier = Modifier entryDecorators = listOf(
.fillMaxWidth() // Required for saving Compose state per entry
.background(MaterialTheme.colorScheme.primaryContainer) rememberSaveableStateHolderNavEntryDecorator(),
.clickable { // Required for ViewModel scoping per entry
val pass = trackingState.currentPass rememberViewModelStoreNavEntryDecorator()
if (pass != null) { ),
val route = "${Screen.RadioControl.route}?catNum=${pass.catNum}&aosTime=${pass.aosTime}" entryProvider = entryProvider {
navController.navigate(route) entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination(
navigateToRadar = { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
}
)
}
entry<Screen.Map> {
MapDestination(
mapFilterViewModel = mapFilterViewModel,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill the
// match page for that grid and switch to its tab.
mutualViewModel.prefillMatchFromGrid(grid)
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Mutual)
}
)
}
entry<Screen.Mutual> {
MutualScreen(
viewModel = mutualViewModel,
navigateToRadar = { catNum, aosTime, pass ->
container.setMutualPassData(pass ?: MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
}
)
}
entry<Screen.AMSAT> { SatStatusDestination() }
entry<Screen.Settings> {
SettingsDestination()
} }
} }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
) )
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
navigateToRadar()
}
}
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF4CAF50).copy(alpha = alpha))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
val txOk = if (trackingState.txConnected) "TX" else ""
val rxOk = if (trackingState.rxConnected) "RX" else ""
Text(
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
} }
} }
} // end Column
} }
} }
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="." />
</paths>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -32,12 +32,15 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:domain")) implementation(project(":core:domain"))
implementation(project(":core:presentation")) implementation(project(":core:presentation"))
implementation(project(":feature:map")) implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes")) implementation(project(":feature:passes"))
implementation(project(":feature:radar")) implementation(project(":feature:radar"))
implementation(project(":feature:radiocontrol"))
implementation(project(":feature:satellites")) implementation(project(":feature:satellites"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
androidTestImplementation(libs.bundles.androidTest) androidTestImplementation(libs.bundles.androidTest)
} }
} }
@@ -33,8 +33,11 @@ internal class CoreDataPlugin : Plugin<Project> {
implementation(libs.androidx.room) implementation(libs.androidx.room)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.other.coroutines) implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp) implementation(libs.other.okhttp)
// android.jar's org.json is a stub ("not mocked") in unit tests;
// provide a real implementation so JSON parsing is testable.
testImplementation(libs.test.json)
} }
} }
} }
@@ -25,10 +25,11 @@ import org.gradle.kotlin.dsl.dependencies
internal class CoreDomainPlugin : Plugin<Project> { internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) { override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm) applyPlugin(libs.plugins.kotlin.jvm)
applyPlugin(libs.plugins.kotlin.serialization)
setupKotlin() setupKotlin()
dependencies { dependencies {
implementation(libs.other.coroutines) implementation(libs.kotlin.coroutines)
implementation(libs.other.json) implementation(libs.kotlin.serialization)
} }
} }
} }
@@ -24,12 +24,15 @@ import org.gradle.kotlin.dsl.dependencies
@Suppress("Unused") @Suppress("Unused")
internal class CorePresentationPlugin : Plugin<Project> { internal class CorePresentationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) { override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.serialization)
setupAndroidLib() setupAndroidLib()
setupCompose() setupCompose()
setupKotlin() setupKotlin()
dependencies { dependencies {
implementation(project(":core:domain")) implementation(project(":core:domain"))
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlin.serialization)
implementation(libs.compose.material3.adaptive)
} }
} }
} }
@@ -74,7 +74,7 @@ internal fun Project.setupAndroidApp() {
} }
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
localeFilters.addAll(listOf("en", "es", "ru", "si", "uk", "zh")) localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
} }
packaging { resources { excludes += listOf("META-INF/*") } } packaging { resources { excludes += listOf("META-INF/*") } }
} }
+2
View File
@@ -4,8 +4,10 @@ plugins {
alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
description = "Cleans the build directory"
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }
+2
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission <uses-permission
@@ -11,5 +12,6 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest> </manifest>
@@ -60,4 +60,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios") @Query("DELETE FROM radios")
suspend fun deleteRadios() suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
} }
@@ -19,19 +19,21 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false) @Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
//val MIGRATION_1_2 = object : Migration(1, 2) { /** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
// override fun migrate(database: SupportSQLiteDatabase) { * transceivers that were imported from a file, so that remote updates leave them alone. */
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))") val MIGRATION_1_2 = object : Migration(1, 2) {
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries") override fun migrate(db: SupportSQLiteDatabase) {
// database.execSQL("DROP TABLE entries") db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries") db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
// } }
//} }
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, val catnum: Int,
val bstar: Double val bstar: Double,
val ndot: Double = 0.0
) )
@@ -25,12 +25,14 @@ data class SatRadio(
@PrimaryKey val uuid: String, @PrimaryKey val uuid: String,
val info: String, val info: String,
val isAlive: Boolean, val isAlive: Boolean,
var downlinkLow: Long?, val downlinkLow: Long?,
var downlinkHigh: Long?, val downlinkHigh: Long?,
val downlinkMode: String?, val downlinkMode: String?,
var uplinkLow: Long?, val uplinkLow: Long?,
var uplinkHigh: Long?, val uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int? val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -17,6 +17,9 @@
*/ */
package com.rtbishop.look4sat.core.data.framework package com.rtbishop.look4sat.core.data.framework
import java.util.Locale
import kotlin.math.roundToLong
object Ft817CatProtocol { object Ft817CatProtocol {
const val CMD_SET_FREQ: Byte = 0x01 const val CMD_SET_FREQ: Byte = 0x01
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray { fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val freq10Hz = frequencyHz / 10 val freq10Hz = frequencyHz / 10
val bcd = ByteArray(4) val bcd = ByteArray(4)
val digits = String.format("%08d", freq10Hz) val digits = String.format(Locale.US, "%08d", freq10Hz)
for (i in 0 until 4) { for (i in 0 until 4) {
val high = digits[i * 2] - '0' val high = digits[i * 2] - '0'
val low = digits[i * 2 + 1] - '0' val low = digits[i * 2 + 1] - '0'
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70] * 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
*/ */
fun encodeCtcssToneBcd(toneHz: Double): ByteArray { fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01Hz = (toneHz * 10).toLong() val tone01Hz = (toneHz * 10).roundToLong()
val digits = String.format("%04d", tone01Hz) val digits = String.format(Locale.US, "%04d", tone01Hz)
val bcd = ByteArray(2) val bcd = ByteArray(2)
for (i in 0 until 2) { for (i in 0 until 2) {
val high = digits[i * 2] - '0' val high = digits[i * 2] - '0'
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
} }
fun buildSetModeCommand(mode: String): ByteArray? { fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE) return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
} }
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
class Ft817Controller( class Ft817Controller(
private val bluetoothManager: BluetoothManager, private val bluetoothManager: BluetoothManager,
@@ -39,10 +40,12 @@ class Ft817Controller(
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb") private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex() private val ioMutex = Mutex()
private val commandDelayMs = 200L private val commandDelayMs = 200L
private val maxAckReadFailures = 3
private var socket: BluetoothSocket? = null private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null private var inputStream: InputStream? = null
private var ackReadFailureCount = 0
override var isConnected: Boolean = false override var isConnected: Boolean = false
private set private set
@@ -57,6 +60,7 @@ class Ft817Controller(
socket = btSocket socket = btSocket
outputStream = btSocket.outputStream outputStream = btSocket.outputStream
inputStream = btSocket.inputStream inputStream = btSocket.inputStream
ackReadFailureCount = 0
isConnected = true isConnected = true
Log.i(tag, "Connected to $deviceAddress") Log.i(tag, "Connected to $deviceAddress")
true true
@@ -79,6 +83,7 @@ class Ft817Controller(
inputStream = null inputStream = null
outputStream = null outputStream = null
socket = null socket = null
ackReadFailureCount = 0
isConnected = false isConnected = false
Log.i(tag, "Disconnected from $deviceAddress") Log.i(tag, "Disconnected from $deviceAddress")
} }
@@ -112,8 +117,8 @@ class Ft817Controller(
ioMutex.withLock { ioMutex.withLock {
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand()) val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
if (!sent) return@withContext null if (!sent) return@withContext null
delay(commandDelayMs) delay(commandDelayMs.milliseconds)
val response = readResponse(5) ?: return@withContext null val response = readResponse() ?: return@withContext null
Ft817CatProtocol.parseReadResponse(response) Ft817CatProtocol.parseReadResponse(response)
} }
} }
@@ -127,44 +132,76 @@ class Ft817Controller(
} }
private suspend fun sendCommand(bytes: ByteArray): Boolean { private suspend fun sendCommand(bytes: ByteArray): Boolean {
return try { return withContext(Dispatchers.IO) {
outputStream?.write(bytes) ?: return false try {
outputStream?.flush() outputStream?.write(bytes) ?: return@withContext false
delay(commandDelayMs) outputStream?.flush()
true delay(commandDelayMs.milliseconds)
} catch (e: Exception) { true
Log.e(tag, "Send error: ${e.message}") } catch (e: Exception) {
isConnected = false Log.e(tag, "Send error: ${e.message}")
false isConnected = false
false
}
} }
} }
/** Send command and read the 1-byte ACK response (0x00 = OK). */ /** Send command and read the 1-byte ACK response (0x00 = OK). */
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean { private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
if (!sendCommand(bytes)) return false if (!sendCommand(bytes)) return false
return try { return withContext(Dispatchers.IO) {
val ack = inputStream?.read() ?: return false try {
ack == 0x00 val ack = inputStream?.read() ?: run {
} catch (e: Exception) { ackReadFailureCount = 0
Log.e(tag, "ACK read error: ${e.message}") isConnected = false
true // command was sent, ACK read failed - continue anyway return@withContext false
}
if (ack < 0) {
ackReadFailureCount = 0
Log.i(tag, "ACK stream closed by remote device")
isConnected = false
return@withContext false
}
ackReadFailureCount = 0
ack == 0x00
} catch (e: Exception) {
ackReadFailureCount += 1
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
if (ackReadFailureCount >= maxAckReadFailures) {
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
isConnected = false
false
} else {
true // Command was sent, treat transient ACK read failures as best-effort
}
}
} }
} }
private fun readResponse(length: Int): ByteArray? { private suspend fun readResponse(): ByteArray? {
return try { return withContext(Dispatchers.IO) {
val buffer = ByteArray(length) try {
var read = 0 val responseSize = 5
while (read < length) { val buffer = ByteArray(responseSize)
val count = inputStream?.read(buffer, read, length - read) ?: return null var read = 0
if (count < 0) return null while (read < responseSize) {
read += count val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
isConnected = false
return@withContext null
}
if (count < 0) {
Log.i(tag, "Response stream closed by remote device")
isConnected = false
return@withContext null
}
read += count
}
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
} }
buffer
} catch (e: Exception) {
Log.e(tag, "Read error: ${e.message}")
isConnected = false
null
} }
} }
} }
@@ -0,0 +1,364 @@
/*
* 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.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
/**
* Icom IC-705 CI-V controller over Bluetooth SPP.
*
* The IC-705 emits broadcast frames continuously (band scope, UTC, signal
* level, …). A reply to any command we send may therefore be buried in
* that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the
* entire accumulated buffer for the frame we expect rather than assuming
* the very next byte is the response.
*/
class Ic705Controller(
private val bluetoothManager: BluetoothManager,
private val deviceAddress: String
) : IRadioController {
private val tag = "IC705"
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val ioMutex = Mutex()
/** Time budget (ms) to wait for a response amid broadcast noise. */
private val ACK_TIMEOUT_MS = 500L
/** Polling interval while draining the input buffer. */
private val POLL_INTERVAL_MS = 20L
/** Small pause after writing a command before reading the response. */
private val WRITE_SETTLE_MS = 50L
private var socket: BluetoothSocket? = null
private var outputStream: OutputStream? = null
private var inputStream: InputStream? = null
override var isConnected: Boolean = false
private set
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
if (isConnected) return@withContext true
if (deviceAddress.isBlank()) return@withContext false
try {
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
btSocket.connect()
socket = btSocket
outputStream = btSocket.outputStream
inputStream = btSocket.inputStream
isConnected = true
// Enter VFO mode — frequency/mode commands return FA if the radio
// is in memory-channel mode. Safe to send regardless of current state.
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
true
} catch (e: Exception) {
Log.e(tag, "Connect error: ${e.message}")
isConnected = false
false
}
}
override suspend fun disconnect() {
withContext(Dispatchers.IO) {
try {
inputStream?.close()
outputStream?.close()
socket?.close()
} catch (e: Exception) {
Log.e(tag, "Disconnect error: ${e.message}")
} finally {
inputStream = null
outputStream = null
socket = null
isConnected = false
Log.i(tag, "Disconnected from $deviceAddress")
}
}
}
// ── IRadioController – standard operations ──────────────────────────────
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
ioMutex.withLock {
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
sendAndWaitAck(cmd)
}
}
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
Log.w(tag, "setMode: unknown mode '$mode'")
return@withContext false
}
Log.d(tag, "setMode: $mode")
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssMode: $enabled")
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
override suspend fun readFrequencyAndMode(): Pair<Long, String>? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadFreqCommand()
Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null
// Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter]
IcomCivProtocol.parseFreqModePayload(payload).also {
if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}")
else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}")
}
}
}
override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOn: not used for IC-705")
true
}
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
Log.w(tag, "pttOff: not used for IC-705")
true
}
// ── IRadioController – IC-705 extended operations ───────────────────────
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
return@withContext false
}
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
else IcomCivProtocol.buildSelectVfoBCommand()
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
*/
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
* In split mode the radio automatically switches active VFO on PTT, so
* always writing to the active VFO is the correct strategy.
*/
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
*/
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
ioMutex.withLock { sendAndWaitAck(cmd) }
}
/**
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
* Used for tuning detection in split mode.
*/
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readWorkingFreq: ${freq}Hz")
freq
}
}
/**
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
*/
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
ioMutex.withLock {
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
if (payload.size < 6) {
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
return@withContext null
}
val freqBcd = payload.sliceArray(1..5)
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
freq
}
}
// ── Internal I/O helpers ────────────────────────────────────────────────
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
*/
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
if (!write(cmd)) return false
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val ok = IcomCivProtocol.containsAck(buf)
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
return ok
}
/**
* Write [cmd] to the radio and drain the input stream for up to
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
* Returns the payload bytes of that frame, or null on timeout/error.
*/
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
if (!write(cmd)) return null
delay(WRITE_SETTLE_MS)
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
if (response == null) {
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
}
return response?.payload
}
/**
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
* Exits early as soon as a complete CI-V frame addressed to us is present
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
* timeout on responses that already arrived.
*/
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
val result = mutableListOf<Byte>()
val deadline = System.currentTimeMillis() + timeoutMs
val stream = inputStream ?: return ByteArray(0)
while (System.currentTimeMillis() < deadline) {
try {
val available = stream.available()
if (available > 0) {
val chunk = ByteArray(available)
val read = stream.read(chunk)
if (read > 0) {
result.addAll(chunk.take(read))
// Exit early once we have a complete frame for us
if (hasCompleteFrameForUs(result)) break
}
} else {
delay(POLL_INTERVAL_MS)
}
} catch (e: Exception) {
Log.e(tag, "Drain error: ${e.message}")
isConnected = false
break
}
}
return result.toByteArray()
}
/**
* Returns true if [buf] contains a complete CI-V frame addressed to the
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
* always the frame terminator.
*/
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
var i = 0
while (i < buf.size - 4) {
if (buf[i] == IcomCivProtocol.PREAMBLE &&
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
buf[i + 3] == IcomCivProtocol.ADDR_IC705
) {
for (k in i + 4 until buf.size) {
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
}
return false // header found but no FD yet
}
i++
}
return false
}
private fun write(bytes: ByteArray): Boolean {
return try {
outputStream?.write(bytes)
outputStream?.flush()
true
} catch (e: Exception) {
Log.e(tag, "Write error: ${e.message}")
isConnected = false
false
}
}
}
@@ -0,0 +1,352 @@
/*
* 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.framework
import java.util.Locale
/**
* Icom CI-V protocol encoder/decoder for the IC-705.
*
* Frame structure:
* FE FE <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] FD
*
* IC-705 default CI-V address : 0xA4
* Controller (us) address : 0xE0
*/
object IcomCivProtocol {
// ── Framing constants ──────────────────────────────────────────────────
const val PREAMBLE: Byte = 0xFE.toByte()
const val END_OF_MSG: Byte = 0xFD.toByte()
const val ACK_OK: Byte = 0xFB.toByte()
const val ACK_NG: Byte = 0xFA.toByte()
// ── Address constants ──────────────────────────────────────────────────
/** Default CI-V address of the IC-705. */
const val ADDR_IC705: Byte = 0xA4.toByte()
/** Default CI-V address of the controller (us). */
const val ADDR_CTRL: Byte = 0xE0.toByte()
// ── Command bytes ──────────────────────────────────────────────────────
/** Read operating frequency (main VFO). */
const val CMD_READ_FREQ: Byte = 0x03
/** Set operating frequency (main VFO). */
const val CMD_SET_FREQ: Byte = 0x05
/** Set operating mode. */
const val CMD_SET_MODE: Byte = 0x06
/** Select VFO / memory. */
const val CMD_SELECT_VFO: Byte = 0x07
/**
* Select operating mode (VFO vs memory-channel).
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
* memory-channel mode — frequency/mode commands return FA until it is.
*/
const val CMD_SELECT_OP_MODE: Byte = 0x08
/** Set repeater duplex / SPLIT. */
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
const val CMD_BAND_SELECT: Byte = 0x1A
/** Read/write CTCSS tone frequency. */
const val CMD_CTCSS_TONE: Byte = 0x1B
/** Read/write misc settings (used for enabling CTCSS encode). */
const val CMD_MISC_SETTING: Byte = 0x16
/** Read/write selected-VFO frequency (cmd 0x25). */
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
// ── Sub-command bytes ──────────────────────────────────────────────────
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
const val SUB_VFO_A: Byte = 0x00
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
const val SUB_VFO_B: Byte = 0x01
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
const val SUB_SPLIT_OFF: Byte = 0x00
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
const val SUB_SPLIT_ON: Byte = 0x01
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
const val SUB_SELECTED_VFO: Byte = 0x00
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
const val SUB_UNSELECTED_VFO: Byte = 0x01
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
// ── Mode bytes ────────────────────────────────────────────────────────
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
val MODE_TO_BYTE: Map<String, Byte> = mapOf(
"LSB" to 0x00,
"USB" to 0x01,
"AM" to 0x02,
"CW" to 0x03,
"RTTY" to 0x04,
"FM" to 0x05,
"WFM" to 0x06,
"CW-R" to 0x07,
"RTTY-R" to 0x08,
"DV" to 0x12,
"AFSK" to 0x05 // AFSK uses FM modulation
)
val BYTE_TO_MODE: Map<Byte, String> = MODE_TO_BYTE.entries.associate { it.value to it.key }
// ── Frequency BCD encoding ─────────────────────────────────────────────
/**
* Encode a frequency in Hz to the IC-705's 5-byte BCD format.
*
* The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution.
* Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB:
* [00, 00, 50, 45, 01]
*/
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
val digits = String.format(Locale.US, "%010d", frequencyHz)
val bcd = ByteArray(5)
for (i in 0 until 5) {
// digits are MSB first; we want pair index 0 = LSB pair
val pairIndex = 4 - i
val high = digits[pairIndex * 2] - '0'
val low = digits[pairIndex * 2 + 1] - '0'
bcd[i] = ((high shl 4) or low).toByte()
}
return bcd
}
/**
* Decode 5-byte BCD frequency (LSB pair first) to Hz.
*/
fun decodeFrequencyBcd(bcd: ByteArray): Long {
// Build digit string MSB→LSB by reversing the byte order
var freqHz = 0L
for (i in 4 downTo 0) {
val b = bcd[i].toInt() and 0xFF
val high = b shr 4
val low = b and 0x0F
freqHz = freqHz * 100 + high * 10 + low
}
return freqHz
}
/**
* Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution).
* 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70].
*/
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
val tone01 = (toneHz * 10).toLong()
val digits = String.format(Locale.US, "%04d", tone01)
return byteArrayOf(
((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(),
((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte()
)
}
// ── Message builders ───────────────────────────────────────────────────
/** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */
private fun frame(vararg payload: Byte): ByteArray {
return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) +
payload +
byteArrayOf(END_OF_MSG)
}
/** Set operating frequency via CMD 0x05 (main VFO). */
fun buildSetFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set selected-VFO frequency via CMD 0x25 sub 0x00.
* This updates whichever VFO is currently active (RX or TX after split).
*/
fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/**
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
* Use this to update the TX VFO when PTT is on.
*/
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
}
/** Read operating frequency (CMD 0x03). */
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
/**
* Select band via CMD 0x1A sub 0x00.
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
*/
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
val code = bandCodeForFrequency(frequencyHz) ?: return null
return frame(CMD_BAND_SELECT, 0x00, code)
}
/**
* Map a frequency in Hz to the IC-705 band stacking register code.
* Codes are BCD (band number in decimal expressed as hex nibbles).
*/
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
else -> null
}
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
fun buildSetModeCommand(mode: String): ByteArray? {
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
return frame(CMD_SET_MODE, modeByte)
}
/** Select VFO-A (CMD 0x07 sub 0x00). */
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
/** Select VFO-B (CMD 0x07 sub 0x01). */
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
/**
* Enter VFO operating mode (CMD 0x08 sub 0x00).
* Sent after connect — if the radio is in memory-channel mode frequency
* and mode commands return FA until this is issued.
*/
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
/** Enable or disable SPLIT mode (CMD 0x0F). */
fun buildSplitModeCommand(enable: Boolean): ByteArray {
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
return frame(CMD_DUPLEX_SPLIT, sub)
}
/**
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
*/
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
val value: Byte = if (enabled) 0x01 else 0x00
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
}
/**
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
*/
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
val bcd = encodeCtcssToneBcd(toneHz)
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
}
// ── Response parsing ───────────────────────────────────────────────────
/**
* Find and parse a complete CI-V response frame from a buffer.
*
* Returns the bytes between "FE FE E0 A4 <CMD>" and FD, or null if no
* complete frame was found. The search is tolerant of interleaved
* broadcast traffic.
*
* @param buf bytes accumulated from the radio
* @param expectCmd the command byte we are looking for in the reply, or
* null to accept any command response from the radio
*/
fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? {
var i = 0
while (i < buf.size - 5) {
// Look for FE FE preamble
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
// We only care about frames addressed to us from the radio
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Find the terminating FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break // incomplete frame, wait for more data
val payload = buf.copyOfRange(i + 5, fdIdx)
if (expectCmd == null || cmd == expectCmd) {
return ParsedResponse(cmd, payload, fdIdx + 1)
}
i = fdIdx + 1
}
return null
}
private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int {
for (k in startIndex until size) if (this[k] == b) return k
return -1
}
/**
* Check whether a buffer contains an OK acknowledgement (FB FD) from
* the radio. Tolerates broadcast noise before the ACK.
*/
fun containsAck(buf: ByteArray): Boolean {
var i = 0
while (i < buf.size - 5) {
if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue }
val dest = buf[i + 2]
val src = buf[i + 3]
val cmd = buf[i + 4]
if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue }
// Skip to FD
val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5)
if (fdIdx < 0) break
if (cmd == ACK_OK) return true
if (cmd == ACK_NG) return false
i = fdIdx + 1
}
return false
}
/**
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
*/
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null
return freqHz to mode
}
/** Hex dump of bytes, useful for debug logging. */
fun toHex(bytes: ByteArray): String =
bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) }
data class ParsedResponse(
val cmd: Byte,
val payload: ByteArray,
/** Index in the source buffer immediately after the FD terminator. */
val nextOffset: Int
)
}
@@ -17,8 +17,10 @@
*/ */
package com.rtbishop.look4sat.core.data.framework package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.IReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -31,18 +33,30 @@ class NetworkReporter(
private val rotatorServer: String, private val rotatorServer: String,
private val rotatorPort: Int, private val rotatorPort: Int,
private val frequencyServer: String, private val frequencyServer: String,
private val frequencyPort: Int private val frequencyPort: Int,
private val frequencyOffsetHz: Long = 0L
) : IReporter { ) : IReporter {
private val writeMutex = Mutex() private val writeMutex = Mutex()
private val connectionMutex = Mutex()
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
private var rotatorSocket: SocketChannel? = null private var rotatorSocket: SocketChannel? = null
private var rotatorConnected = false private var rotatorConnected = false
private var rotatorConnecting = false
private var frequencySocket: SocketChannel? = null private var frequencySocket: SocketChannel? = null
private var frequencyConnected = false private var frequencyConnected = false
private var frequencyConnecting = false
init {
// Keep only the latest frequency command to avoid stale backlog and effective lag.
reporterScope.launch {
for (command in frequencyCommands) {
ensureFrequencyConnected()
if (!frequencyConnected) continue
write(frequencySocket, command) { resetFrequencyConnection() }
}
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double) { override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
reporterScope.launch { reporterScope.launch {
@@ -53,51 +67,48 @@ class NetworkReporter(
.replace($$"$AZ", azimuth.toString()) .replace($$"$AZ", azimuth.toString())
.replace($$"$EL", el.toString()) .replace($$"$EL", el.toString())
.unescapeControlChars() .unescapeControlChars()
write(rotatorSocket, command) { rotatorConnected = false } write(rotatorSocket, command) { resetRotatorConnection() }
} }
} }
override fun reportFrequency(format: String, frequency: Long) { override fun reportFrequency(format: String, frequency: Long) {
reporterScope.launch { val clampedOffset = frequencyOffsetHz.coerceIn(
ensureFrequencyConnected() Constants.FREQ_OFFSET_MIN_HZ,
if (!frequencyConnected) return@launch Constants.FREQ_OFFSET_MAX_HZ
val command = format )
.replace($$"$FREQ", frequency.toString()) val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
.unescapeControlChars() val command = format
write(frequencySocket, command) { frequencyConnected = false } .replace($$"$FREQ", correctedFreq.toString())
} .unescapeControlChars()
frequencyCommands.trySend(command)
} }
private fun ensureRotatorConnected() { private suspend fun ensureRotatorConnected() {
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (rotatorConnected || rotatorServer.isBlank()) return
try { try {
rotatorConnecting = true resetRotatorConnection()
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort)) rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
rotatorConnected = true rotatorConnected = true
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort") println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter rotator connect error: ${e.message}") println("NetworkReporter rotator connect error: ${e.message}")
rotatorConnected = false resetRotatorConnection()
} finally {
rotatorConnecting = false
} }
} }
} }
private fun ensureFrequencyConnected() { private suspend fun ensureFrequencyConnected() {
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return connectionMutex.withLock {
reporterScope.launch { if (frequencyConnected || frequencyServer.isBlank()) return
try { try {
frequencyConnecting = true resetFrequencyConnection()
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort)) frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
frequencyConnected = true frequencyConnected = true
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort") println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter frequency connect error: ${e.message}") println("NetworkReporter frequency connect error: ${e.message}")
frequencyConnected = false resetFrequencyConnection()
} finally {
frequencyConnecting = false
} }
} }
} }
@@ -106,7 +117,9 @@ class NetworkReporter(
try { try {
writeMutex.withLock { writeMutex.withLock {
val buffer = ByteBuffer.wrap("$command\n".toByteArray()) val buffer = ByteBuffer.wrap("$command\n".toByteArray())
socket?.write(buffer) while (buffer.hasRemaining()) {
socket?.write(buffer)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
println("NetworkReporter write error: ${e.message}") println("NetworkReporter write error: ${e.message}")
@@ -114,6 +127,34 @@ class NetworkReporter(
} }
} }
private fun resetRotatorConnection() {
rotatorConnected = false
closeQuietly(rotatorSocket)
rotatorSocket = null
}
private fun resetFrequencyConnection() {
frequencyConnected = false
closeQuietly(frequencySocket)
frequencySocket = null
}
private fun closeQuietly(socket: SocketChannel?) {
try {
socket?.close()
} catch (_: Exception) {}
}
private fun Long.safeAdd(delta: Long): Long {
return when {
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
else -> this + delta
}
}
private fun String.unescapeControlChars(): String = private fun String.unescapeControlChars(): String =
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t") replace("""\r""", 13.toChar().toString())
.replace("""\n""", 10.toChar().toString())
.replace("""\t""", 9.toChar().toString())
} }
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.util.Log import android.util.Log
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -44,6 +47,8 @@ class RadioTrackingService(
) : IRadioTrackingService { ) : IRadioTrackingService {
private val tag = "RadioTracking" private val tag = "RadioTracking"
/** Delay between each step of the split-mode init sequence (ms). */
private val INIT_STEP_DELAY_MS = 200L
private val _state = MutableStateFlow(RadioTrackingState()) private val _state = MutableStateFlow(RadioTrackingState())
override val state: StateFlow<RadioTrackingState> = _state override val state: StateFlow<RadioTrackingState> = _state
@@ -51,227 +56,427 @@ class RadioTrackingService(
private var rxController: IRadioController? = null private var rxController: IRadioController? = null
private var trackingJob: Job? = null private var trackingJob: Job? = null
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connectRadios() { override suspend fun connectRadios() {
// Disconnect old controllers if any
txController?.disconnect() txController?.disconnect()
rxController?.disconnect() rxController?.disconnect()
// Read current addresses from settings
val rcSettings = settingsRepo.radioControlSettings.value val rcSettings = settingsRepo.radioControlSettings.value
val txAddr = rcSettings.txRadioAddress val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress val rxAddr = rcSettings.rxRadioAddress
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
val isSplit = isIcom && rcSettings.splitMode
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr") Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
if (txAddr.isBlank() && rxAddr.isBlank()) { if (isSplit) {
_state.update { it.copy(errorMessage = "No radio addresses configured. Set them in Settings → FT-817.") } // Single-radio split mode: only TX slot is used
return if (txAddr.isBlank()) {
} _state.update { it.copy(errorMessage = "No radio address configured in Settings") }
return
val tx = Ft817Controller(bluetoothManager, txAddr) }
val rx = Ft817Controller(bluetoothManager, rxAddr) val tx = makeController(isIcom, txAddr)
txController = tx txController = tx
rxController = rx rxController = null
_state.update { it.copy(errorMessage = null) }
_state.update { it.copy(errorMessage = null) } val txOk = tx.connect()
val txOk = if (txAddr.isNotBlank()) tx.connect() else false _state.update {
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false it.copy(
_state.update { txConnected = txOk,
it.copy( rxConnected = false,
txConnected = txOk, errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
rxConnected = rxOk, )
errorMessage = when { }
!txOk && !rxOk -> "Could not connect to TX and RX radios" Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
!txOk -> "Could not connect to TX radio ($txAddr)" } else {
!rxOk -> "Could not connect to RX radio ($rxAddr)" if (txAddr.isBlank() && rxAddr.isBlank()) {
else -> null _state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
} return
) }
val tx = makeController(isIcom, txAddr)
val rx = makeController(isIcom, rxAddr)
txController = tx
rxController = rx
_state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
_state.update {
it.copy(
txConnected = txOk,
rxConnected = rxOk,
errorMessage = when {
!txOk && !rxOk -> "Could not connect to TX and RX radios"
!txOk -> "Could not connect to TX radio ($txAddr)"
!rxOk -> "Could not connect to RX radio ($rxAddr)"
else -> null
}
)
}
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
} }
} }
private fun makeController(isIcom: Boolean, address: String): IRadioController =
if (isIcom) Ic705Controller(bluetoothManager, address)
else Ft817Controller(bluetoothManager, address)
override suspend fun disconnectRadios() { override suspend fun disconnectRadios() {
stopTracking() stopTracking()
txController?.disconnect() txController?.disconnect()
rxController?.disconnect() rxController?.disconnect()
txController = null txController = null
rxController = null rxController = null
_state.update { _state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
it.copy(
txConnected = false,
rxConnected = false,
isActive = false
)
}
} }
// ── Tracking ────────────────────────────────────────────────────────────
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) { override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
_state.update { _state.update {
it.copy( it.copy(
isActive = true, isActive = true,
currentPass = pass, currentPass = pass,
selectedTransponder = transponder, selectedTransponder = transponder,
txBaseFrequencyHz = txBaseFreqHz txBaseFrequencyHz = txBaseFreqHz
) )
} }
trackingJob?.cancel() trackingJob?.cancel()
trackingJob = appScope.launch {
// Set modes on both radios at tracking start
val tx = txController
val rx = rxController
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
if (tx != null && tx.isConnected && txMode != null) {
tx.setMode(txMode)
Log.i(tag, "TX mode set to $txMode")
}
if (rx != null && rx.isConnected && rxMode != null) {
rx.setMode(rxMode)
Log.i(tag, "RX mode set to $rxMode")
}
// Set CTCSS if FM
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0 val rcSettings = settingsRepo.radioControlSettings.value
var lastSetRxFreq = 0.0 val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning val isSplit = isIcom && rcSettings.splitMode
var lastReadFreq = 0L
var stableCount = 0
while (isActive) { if (isSplit) {
val currentState = _state.value trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
if (!currentState.isActive) break } else {
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
val tx = txController
val rx = rxController
val hasUplink = txBaseFreq != null
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// --- User is tuning: keep reading, wait for stabilization ---
val radio = if (tuningRadio == "tx") tx else rx
if (radio != null && radio.isConnected) {
val readResult = radio.readFrequencyAndMode()
if (readResult != null) {
val (freq, _) = readResult
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
stableCount++
} else {
stableCount = 0
lastReadFreq = freq
}
// Stable for 2 reads → user stopped turning
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * c / (c + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// --- Normal tracking: read, detect changes, command ---
// TX dial feedback
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
val readResult = tx.readFrequencyAndMode()
if (readResult != null) {
val (actualTxFreq, _) = readResult
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = actualTxFreq
stableCount = 0
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
}
}
}
// RX dial feedback (only if TX not tuning)
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
val readResult = rx.readFrequencyAndMode()
if (readResult != null) {
val (actualRxFreq, _) = readResult
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = actualRxFreq
stableCount = 0
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
}
}
}
}
// Compute Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else {
xpdr.downlinkLow
}
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
// Command radios (only when not tuning)
if (tuningRadio.isEmpty()) {
if (tx != null && tx.isConnected && txRadioFreq != null) {
tx.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rx != null && rx.isConnected && rxRadioFreq != null) {
rx.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = tx?.isConnected ?: false,
rxConnected = rx?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
} }
} }
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val tx = txController
val rx = rxController
// Initial setup: set band/mode/CTCSS on both radios
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
if (tx != null && tx.isConnected && txMode != null) {
Log.d(tag, "Setting TX mode: $txMode")
tx.setMode(txMode)
}
if (rx != null && rx.isConnected && rxMode != null) {
Log.d(tag, "Setting RX mode: $rxMode")
rx.setMode(rxMode)
}
if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone ->
Log.d(tag, "Setting CTCSS: ${tone}Hz")
tx?.setCtcssTone(tone)
tx?.setCtcssMode(true)
}
}
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = ""
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val txNow = txController
val rxNow = rxController
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
val radio = if (tuningRadio == "tx") txNow else rxNow
if (radio != null && radio.isConnected) {
val read = radio.readFrequencyAndMode()
if (read != null) {
val (freq, _) = read
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = freq }
if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
val read = txNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
val read = rxNow.readFrequencyAndMode()
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = read.first
stableCount = 0
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
}
}
}
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
if (tuningRadio.isEmpty()) {
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = txNow?.isConnected ?: false,
rxConnected = rxNow?.isConnected ?: false,
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── IC-705 split-radio tracking ─────────────────────────────────────────
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
val radio = txController ?: return
if (!radio.isConnected) return
val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
}
// Compute nominal base frequencies
val txCenter = when {
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null
}
val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else transponder.downlinkLow
val txBase = initialTxBaseFreqHz ?: txCenter
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
// ── Initial setup sequence ──────────────────────────────────────────
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
// ACK from each command gates the next — no fixed delays needed.
// VFO-A = RX (downlink)
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
radio.setVfo(vfoA = true)
if (rxNominal != null) {
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
radio.setBand(rxNominal)
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
radio.setFrequency(rxNominal)
}
if (rxMode != null) {
Log.d(tag, "Split init: VFO-A mode=$rxMode")
radio.setMode(rxMode)
}
// VFO-B = TX (uplink)
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
radio.setVfo(vfoA = false)
if (txBase != null) {
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
radio.setBand(txBase)
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
radio.setFrequency(txBase)
}
if (txMode != null) {
Log.d(tag, "Split init: VFO-B mode=$txMode")
radio.setMode(txMode)
}
if (txMode?.uppercase() == "FM") {
val tone = _state.value.ctcssTone
if (tone != null) {
Log.d(tag, "Split init: CTCSS=${tone}Hz")
radio.setCtcssTone(tone)
radio.setCtcssMode(true)
} else {
radio.setCtcssMode(false)
}
}
// Enable SPLIT on VFO-A (return display to RX VFO first)
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
radio.setVfo(vfoA = true)
radio.setSplitMode(enabled = true)
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
Log.i(tag, "IC-705 split init done — entering tracking loop")
// ── Tracking loop with tuning detection ─────────────────────────────
var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
var lastReadFreq = 0L
var stableCount = 0
while (currentCoroutineContext().isActive) {
val currentState = _state.value
if (!currentState.isActive) break
val satPass = currentState.currentPass ?: break
val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) {
// User is tuning — wait for frequency to stabilize
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
if (readFreq != null) {
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
else { stableCount = 0; lastReadFreq = readFreq }
if (stableCount >= 2) {
// Frequency stable — reverse-calculate base frequency
if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) {
txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "Split TX tuning done → base=$newBase")
}
} else if (tuningRadio == "rx") {
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
}
}
tuningRadio = ""
stableCount = 0
lastSetTxFreq = 0.0
lastSetRxFreq = 0.0
}
}
} else {
// Detect manual dial changes
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
val readTx = radio.readTxVfoFrequency()
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx"
lastReadFreq = readTx
stableCount = 0
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
}
}
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
val readRx = radio.readWorkingFrequency()
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
tuningRadio = "rx"
lastReadFreq = readRx
stableCount = 0
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
}
}
}
// Determine Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseCalc = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else xpdr.downlinkLow
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
if (radio.isConnected && tuningRadio.isEmpty()) {
// Update both VFOs every cycle — no PTT polling needed.
// 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 (txRadioFreq != null) {
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
radio.setTxVfoFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble()
}
}
_state.update {
it.copy(
txConnected = radio.isConnected,
rxConnected = false, // single radio
txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth),
elevation = Math.toDegrees(pos.elevation),
distance = pos.distance
)
}
delay(1000)
}
}
// ── Other IRadioTrackingService methods ─────────────────────────────────
override fun stopTracking() { override fun stopTracking() {
trackingJob?.cancel() trackingJob?.cancel()
trackingJob = null trackingJob = null
@@ -288,7 +493,6 @@ class RadioTrackingService(
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
} }
rxMode?.let { rx?.setMode(it) } rxMode?.let { rx?.setMode(it) }
if (transponder.uplinkMode?.uppercase() == "FM") { if (transponder.uplinkMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone -> _state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone) tx?.setCtcssTone(tone)
@@ -302,21 +506,17 @@ class RadioTrackingService(
transponder.uplinkLow != null -> transponder.uplinkLow!! transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null else -> null
} }
// Show nominal frequencies immediately
val rxNominal = if (txCenter != null) { val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder) TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else { } else transponder.downlinkLow
// Downlink-only transponder (beacon etc.) - use downlink directly
transponder.downlinkLow
}
_state.update { _state.update {
it.copy( it.copy(
selectedTransponder = transponder, selectedTransponder = transponder,
txBaseFrequencyHz = txCenter, txBaseFrequencyHz = txCenter,
txFrequencyHz = txCenter, txFrequencyHz = txCenter,
rxFrequencyHz = rxNominal, rxFrequencyHz = rxNominal,
txMode = transponder.uplinkMode, txMode = transponder.uplinkMode,
rxMode = transponder.downlinkMode rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let { m -> ?: transponder.uplinkMode?.let { m ->
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted) TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
} }
@@ -353,5 +553,4 @@ class RadioTrackingService(
} }
_state.update { it.copy(txMode = txMode, rxMode = rxMode) } _state.update { it.copy(txMode = txMode, rxMode = rxMode) }
} }
} }
@@ -1,64 +1,113 @@
/*
* 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.injection package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.content.Context import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import android.view.WindowManager
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer { class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource() private val localSource = provideLocalSource()
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") } private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo() override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo() override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo() override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val radioTrackingService: IRadioTrackingService by lazy { override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
} }
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
override fun provideSaveImage(): ISaveImage = SaveImage(context)
override fun provideBluetoothReporter(): IReporter { override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value val rc = settingsRepo.rcSettings.value
@@ -77,27 +126,37 @@ class MainContainer(private val context: Context) : IMainContainer {
rc.rotatorAddress, rc.rotatorAddress,
rc.rotatorPort.toIntOrNull() ?: 0, rc.rotatorPort.toIntOrNull() ?: 0,
rc.frequencyAddress, rc.frequencyAddress,
rc.frequencyPort.toIntOrNull() ?: 0 rc.frequencyPort.toIntOrNull() ?: 0,
rc.frequencyOffsetHz
) )
} }
override fun provideTxRadioController(): IRadioController { override fun provideTxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.txRadioAddress val settings = settingsRepo.radioControlSettings.value
return Ft817Controller(manager, address) val address = settings.txRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
} }
override fun provideRxRadioController(): IRadioController { override fun provideRxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.rxRadioAddress val settings = settingsRepo.radioControlSettings.value
return Ft817Controller(manager, address) val address = settings.rxRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
} }
override fun provideSensorsRepo(): ISensorsRepo { override fun provideSensorsRepo(): ISensorsRepo {
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) val displayManager = context.getSystemService(DisplayManager::class.java)
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager return SensorsRepo(manager, displayManager)
return SensorsRepo(manager,sensor,window)
} }
private fun provideDatabaseRepo(): IDatabaseRepo { private fun provideDatabaseRepo(): IDatabaseRepo {
@@ -109,7 +168,10 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build() val database = builder
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration(false)
.build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
} }
@@ -0,0 +1,288 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
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 com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(
private val remoteSource: IRemoteSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : IAmSatRepository {
private val statusCacheMutex = Mutex()
@Volatile
private var statusCache: SatStatusPage? = null
@Volatile
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
@Volatile
private var cacheGeneration = 0
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override fun getCachedStatus(): SatStatusPage? = statusCache
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
if (!forceRefresh) statusCache?.let { return it }
var cachedPage: SatStatusPage? = null
val inFlightFetch = statusCacheMutex.withLock {
if (!forceRefresh) {
val cached = statusCache
if (cached != null) {
cachedPage = cached
return@withLock null
}
}
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
fetchStatusFromRemote(cacheGeneration)
}.also { statusFetchInFlight = it }
}
cachedPage?.let { return it }
val fetch = inFlightFetch ?: return null
return try {
fetch.await()
} finally {
statusCacheMutex.withLock {
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
}
}
}
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return 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 null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
if (generation == cacheGeneration) statusCache = page
}
}
override suspend fun prefetchStatus() {
fetchStatus(forceRefresh = false)
}
override fun clearStatusCache() {
cacheGeneration += 1
statusCache = null
statusFetchInFlight = null
}
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (_: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (_: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (3 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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
formatDayLabel(utc)
}
return names.map { name ->
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 }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 3).map { d ->
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
val dayStart = nowSec - (d + 1) * 86400L
val dayEnd = nowSec - d * 86400L
val dayReports = byName[name].orEmpty()
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
.sortedByDescending { it.reportedTimeUtcSec }
val streakCount = if (dayReports.isEmpty()) 0 else {
val newestStatus = dayReports.first().report
dayReports.takeWhile { it.report == newestStatus }.size
}
SatDay(
dateLabel = labels[d],
slots = slots.subList(d * 12, (d + 1) * 12),
streakCount = streakCount
)
}
SatStatus(name = name, days = days)
}
}
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -40,59 +40,81 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private companion object { private val customSourceType = "Other"
val tleTypes = setOf("Amsat", "R4UAB", "Other")
val zippedTleTypes = setOf("McCants", "Classified")
}
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = dataParser.parseTLEStream(stream) val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries) localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
importedCount
} }
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
remoteSource.getFileStream(uri) var importedCount = 0
?.let { dataParser.parseJSONStream(it) } remoteSource.getFileStream(uri)?.let { stream ->
?.takeIf { it.isNotEmpty() } val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
?.let { localSource.insertRadios(transceivers, isCustom = true)
localSource.deleteRadios() importedCount = transceivers.size
localSource.insertRadios(it) }
}
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
importedCount
} }
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap { // Keep the raw URL as the status key (matches what the settings UI
putAll(Sources.satelliteDataUrls) // displays) while requesting with the normalized URL.
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl) val tleUrls = settings.satelliteUrls
}.filterValues { it.isNotEmpty() } .filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
.map { it to normalizeUrl(it) }
val radioUrls = buildList { .distinctBy { it.second }
add(Sources.RADIO_DATA_URL) val radioUrls = settings.transceiversUrls
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl) .filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
.map { it to normalizeUrl(it) }
.distinctBy { it.second }
val builtinTypesByUrl = Sources.satelliteDataUrls
.filterValues { it.isNotBlank() }
.mapValues { normalizeUrl(it.value) }
.entries
.associate { (type, url) -> url to type }
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
// launch all network requests concurrently, keeping the raw url as key for status reporting
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
// report the HTTP status code of every source (200, 404, ...)
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries
}.distinctBy { it.catnum }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database.
// Transceivers are a full snapshot: sources publish active entries only, so a retired
// transceiver simply disappears from the feed and has to be dropped locally as well.
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
// would bring them back if they were replaced here.
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios)
} }
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
// launch all network requests concurrently
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } }
// parse satellite data
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) ->
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites ->
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum })
}
}
// parse radio data
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) }
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
@@ -102,10 +124,34 @@ class DatabaseRepo(
setUpdateSuccessful(0L) setUpdateSuccessful(0L)
} }
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) { private fun normalizeUrl(url: String): String =
in tleTypes -> dataParser.parseTLEStream(stream) if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry })
else -> dataParser.parseCSVStream(stream) private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
val bufferedStream = stream.buffered()
return when {
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
else -> dataParser.parseTLEStream(bufferedStream)
}
}
private fun hasCsvHint(url: String): Boolean {
return url.contains("FORMAT=csv", ignoreCase = true) ||
url.endsWith(".csv", ignoreCase = true) ||
url.endsWith(".csv.zip", ignoreCase = true)
}
private fun looksLikeCsv(stream: InputStream): Boolean {
if (!stream.markSupported()) return false
stream.mark(4096)
val preview = ByteArray(4096)
val length = stream.read(preview)
stream.reset()
if (length <= 0) return false
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
return line.contains("OBJECT_NAME", ignoreCase = true) ||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
line.count { it == ',' } >= 4
} }
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
@@ -113,4 +159,7 @@ class DatabaseRepo(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
) )
} }
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
} }
@@ -0,0 +1,460 @@
/*
* 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.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.net.URLEncoder
import java.io.IOException
import javax.net.ssl.SSLException
/**
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
*
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
* truncated/expanded to the 4-char form used by the map overlay.
*
* ARRL rate-limits the report endpoint: one download in progress per user id, and
* frequent full-report pulls get refused. Failures are reported with an explicit
* cause (see LoTWResult) so the UI can tell the user what to do next.
*/
class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String,
since: String,
onProgress: (LoTWProgress) -> Unit
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password, since, onProgress).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
internal fun toResult(e: Throwable): LoTWResult = when (e) {
is CredentialsException -> LoTWResult.BadCredentials
is RateLimitException -> LoTWResult.RateLimited
is TimeoutException -> LoTWResult.Timeout
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
}
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
private fun parseBoth(
body: String
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
val grids = parseConfirmedGrids(body) ?: return null
val qsos = parseConfirmedGridQsos(body) ?: return null
val roamed = parseRoamedGrids(body) ?: return null
return Triple(grids, qsos, roamed)
}
private suspend fun fetchReportBody(
callsign: String,
password: String,
since: String,
onProgress: (LoTWProgress) -> Unit
): Result<String> {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
// Empty since -> full report. qso_qslsince with an early date forces a
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
// default" since-date and only returns confirmations newer than the
// account's last query — subsequent syncs would be incremental/empty.
val effectiveSince = since.ifBlank { "2000-01-01" }
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
}
return try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing — a busy server took 36.5 s to accept a connection in
// testing, so 30 s connect would have killed it).
//
// NOTE: readTimeout is NOT a total-download cap. It is the longest
// wait for the NEXT chunk of data (a stall timeout). A full report
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
// as long as chunks keep arriving it must never be cut off.
// 600 s tolerates long server-side pauses while it generates the
// report; a truly dead link (no data at all) still fails within it.
connection.connectTimeout = 60_000
connection.readTimeout = 600_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept-Encoding", "gzip")
connection.instanceFollowRedirects = true
onProgress(LoTWProgress(LoTWPhase.Connecting))
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
// report pull with HTTP 503 "Page request limit".
return if (code == 503 || code == 429) Result.failure(RateLimitException())
else Result.failure(IOException("HTTP $code"))
}
val stream = connection.inputStream
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
val body = readBodyWithProgress(stream, isGzip, onProgress)
connection.disconnect()
when {
// Login failure: HTTP 200 + HTML login page with the error text.
body.contains("incorrect", ignoreCase = true) &&
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
// Any other non-ADIF body: rate-limit refusal / server error page.
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
else -> Result.success(body)
}
} catch (e: java.util.concurrent.CancellationException) {
// Cancellation must propagate — it is not a network failure.
throw e
} catch (e: SocketTimeoutException) {
Result.failure(TimeoutException(e.message ?: "timed out"))
} catch (e: SSLException) {
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
} catch (e: Exception) {
println("LoTWRepository fetch failure: $e")
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
}
}
/**
* Streams the report body into a string while reporting download progress.
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
* meaningful progress bar and remaining-time estimate.
*/
private suspend fun readBodyWithProgress(
stream: java.io.InputStream,
isGzip: Boolean,
onProgress: (LoTWProgress) -> Unit
): String {
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
val sb = StringBuilder()
val buf = CharArray(8192)
var bytesRead = 0L
var expected = 0L
var qsoCount = 0L
var speed = 0L
var headerSeen = false
var lastSampleMs = System.currentTimeMillis()
var lastSampleBytes = 0L
var lastEmitMs = 0L
while (true) {
// Cooperative cancellation: lets the user abort a full sync that
// was started by mistake. Throws CancellationException once the
// job is cancelled; the blocking read below is fast while chunks
// keep arriving, so the abort lands on the next chunk boundary.
coroutineContext.ensureActive()
val n = reader.read(buf)
if (n <= 0) break
sb.append(buf, 0, n)
bytesRead += n
// The record count sits in the report header, long before the data.
if (!headerSeen) {
NUMREC_REGEX.find(sb)?.let { m ->
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
expected = qsoCount * AVG_RECORD_BYTES
headerSeen = true
}
}
// Sample speed every 500ms, EMA-smoothed.
val now = System.currentTimeMillis()
if (now - lastSampleMs >= 500) {
val elapsedS = (now - lastSampleMs) / 1000.0
if (elapsedS > 0) {
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
lastSampleMs = now
lastSampleBytes = bytesRead
}
}
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
if (now - lastEmitMs >= 250) {
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
lastEmitMs = now
}
}
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
return sb.toString()
}
internal class CredentialsException : Exception("bad callsign/password")
internal class RateLimitException : Exception("report refused (rate limit / server error)")
internal class TimeoutException(message: String) : Exception(message)
internal fun parseConfirmedGridQsos(
body: String
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
// One ADIF record = fields up to <EOR>. We buffer the fields we care
// about, then emit a GridQso on <EOR> when the record is a satellite
// QSO (PROP_MODE=SAT) carrying a grid.
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
var propMode: String? = null
var call = ""
var qsoDate = ""
var timeOn = ""
var satName = ""
var mode = ""
var bandUp = ""
var bandDown = ""
var dxcc: Int? = null
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
call = call, epochMs = epochMs, satName = satName,
mode = mode, bandUp = bandUp, bandDown = bandDown,
dxcc = dxcc, country = country, cqz = cqz, state = state,
myGrid = myGrid
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
}
}
fun resetRecord() {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
gridsInRecord.clear()
}
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
emitRecord()
resetRecord()
}
line.startsWith("<PROP_MODE:") ->
propMode = adifValue(line).uppercase()
line.startsWith("<CALL:") ->
call = adifValue(line).uppercase()
line.startsWith("<QSO_DATE:") ->
qsoDate = adifValue(line)
line.startsWith("<TIME_ON:") ->
timeOn = adifValue(line)
line.startsWith("<SAT_NAME:") ->
satName = adifValue(line)
line.startsWith("<MODE:") ->
mode = adifValue(line)
line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<DXCC:") ->
dxcc = adifValue(line).toIntOrNull()
line.startsWith("<COUNTRY:") ->
country = adifValue(line).ifBlank { null }
line.startsWith("<CQZ:") ->
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_GRIDSQUARE:") -> {
// Own-station grid (must not be confused with GRIDSQUARE —
// the opposite station's grid). Recorded per QSO so awards
// can be counted per operated grid.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
// several squares; split and keep every 4-char field.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) gridsInRecord.add(field.take(4))
}
}
}
}
return result
}
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
private fun adifValue(line: String): String =
line.substringAfter('>').substringBefore("E<").trim()
/**
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
* statistics match on the short CODE only (China 2-letter province pinyin,
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
* suffix here once, storing the clean code for every downstream consumer
* (persistence, AwardCalculator).
*/
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
val d = date.trim()
val t = time.trim().padEnd(6, '0').take(6)
val fmt = java.time.format.DateTimeFormatterBuilder()
.appendPattern("yyyyMMddHHmmss")
.toFormatter()
.withZone(java.time.ZoneOffset.UTC)
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
} catch (_: Exception) {
0L
}
internal fun parseConfirmedGrids(body: String): Set<String>? {
// LoTW answers with ADIF text; on bad credentials it returns a short error
// page without an <eoh> header terminator. Real reports always carry <eoh>
// (LoTW writes it lowercase). Match case-insensitively to be safe.
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
// ADIF fields of one QSO record span multiple lines and are terminated by
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
// in terrestrial contacts.
//
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
// sequential gate ("only add while propMode == SAT") silently drops every
// grid on real reports. Buffer the record and decide at <EOR> instead.
var propMode: String? = null
var pendingGrids = mutableListOf<String>()
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT") grids.addAll(pendingGrids)
propMode = null
pendingGrids = mutableListOf()
}
line.startsWith("<PROP_MODE:") -> {
propMode = adifValue(line).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated PAIR of grids
// ("EN52en,EN53fa") for contacts spanning two squares —
// split on ',' and take the 4-char field of each, or the
// second grid is silently dropped.
val value = adifValue(line)
value.split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) pendingGrids.add(field.take(4))
}
}
}
}
return grids
}
/**
* Distinct 4-char gridsquares the account itself operated from, taken from
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
* "roamed / activated" set shown as blue stripes on the map — the grids
* where the operator's own station was located during confirmed QSOs (a
* rover may log several distinct grids; the home grid is included too).
* Returns null when the body is not an ADIF report (mirrors the other parsers).
*/
internal fun parseRoamedGrids(body: String): Set<String>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
// within a record. A sequential gate ("only add while propMode == SAT")
// would silently drop every own grid on real reports; buffer the record
// and decide at <EOR> instead.
var propMode: String? = null
var myGrid: String? = null
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
propMode = null
myGrid = null
}
line.startsWith("<PROP_MODE:") -> {
propMode = adifValue(line).uppercase()
}
line.startsWith("<MY_GRIDSQUARE:") -> {
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
// opposite station's grid) — only own-station grids count.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4)
}
}
}
return grids
}
private companion object {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
/**
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
* before the download truly ends — an under-estimate made the bar sit
* at 100% while data was still flowing, read as "finished but stuck".
*/
const val AVG_RECORD_BYTES = 900L
}
}
@@ -34,8 +34,12 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone
class SatelliteRepo( class SatelliteRepo(
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
@@ -52,14 +56,38 @@ class SatelliteRepo(
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList()) private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites override val satellites: StateFlow<List<OrbitalObject>> = _satellites
private val _selectedPass = MutableStateFlow(0 to 0L)
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
override fun selectPass(catNum: Int, aosTime: Long) {
_selectedPass.value = catNum to aosTime
}
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> combine(
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } settingsRepo.selectedIds,
val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value settingsRepo.stationPosition,
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) // Recalculate passes when the UTC display toggle changes, so an existing
} // AOS time window is reapplied in the newly selected timezone.
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
val now = System.currentTimeMillis()
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
calculatePasses(
time = now - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
} }
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos { override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -98,7 +126,15 @@ class SatelliteRepo(
} }
} }
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) { override suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
) {
_isCalculating.value = true _isCalculating.value = true
// Normalize to the start of the current minute so that coarse 60-second stepping // 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 // in getLeoPass always begins from the same phase, producing stable AOS/LOS times
@@ -123,7 +159,12 @@ class SatelliteRepo(
val newPasses = ArrayList<OrbitalPass>() val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) { for (list in passLists) {
for (pass in list) { for (pass in list) {
if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) { if (
pass.losTime > time
&& pass.aosTime < timeFuture
&& pass.maxElevation > minElevation
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
) {
newPasses.add(pass) newPasses.add(pass)
} }
} }
@@ -135,21 +176,48 @@ class SatelliteRepo(
_isCalculating.value = false _isCalculating.value = false
} }
private fun isAosInRange(
aosTime: Long,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean
): Boolean {
// Follow the UTC display toggle: with UTC enabled the window is interpreted
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
// shows UTC times while the filter silently uses local time — the two
// disagree by the timezone offset (e.g. 8h for China).
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
TimeZone.getTimeZone("UTC")
} else {
TimeZone.getDefault()
}
val offsetMillis = tz.getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> { private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
val passes = mutableListOf<OrbitalPass>() val passes = mutableListOf<OrbitalPass>()
val endDate = time + hours * 60L * 60L * 1000L val endDate = time + hours * 60L * 60L * 1000L
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt() val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
val decayed = this.data.hasDecayed(time)
var startDate = time var startDate = time
var shouldRewind = true var shouldRewind = true
var lastAosDate: Long var lastAosDate: Long
var count = 0 var count = 0
if (this.willBeSeen(pos)) { if (this.willBeSeen(pos)) {
if (this.data.isDeepSpace) { if (this.data.isDeepSpace) {
passes.add(getGeoPass(this, pos, time)) passes.add(getGeoPass(this, pos, time, decayed))
} else { } else {
do { do {
if (count > 0) shouldRewind = false if (count > 0) shouldRewind = false
val pass = getLeoPass(this, pos, startDate, shouldRewind) val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
lastAosDate = pass.aosTime lastAosDate = pass.aosTime
passes.add(pass) passes.add(pass)
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
@@ -160,17 +228,17 @@ class SatelliteRepo(
return passes return passes
} }
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass { private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
val satPos = sat.getPosition(pos, time) val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L val aos = time - 24 * 60L * 60L * 1000L
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2 val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
val az = satPos.azimuth.toDegrees().round(1) val az = satPos.azimuth.toDegrees().round(1)
val elev = satPos.elevation.toDegrees().round(1) val elev = satPos.elevation.toDegrees().round(1)
val alt = satPos.altitude val alt = satPos.altitude
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat) return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
} }
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass { private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt() val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
var calendarTimeMillis = time var calendarTimeMillis = time
var elevation: Double var elevation: Double
@@ -234,6 +302,6 @@ class SatelliteRepo(
val alt = tcaPos.altitude val alt = tcaPos.altitude
val elev = maxElevation.toDegrees().round(1) val elev = maxElevation.toDegrees().round(1)
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat) return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
} }
} }
@@ -111,12 +111,29 @@ class SelectionRepo(
/** /**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow, * Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item. * and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/ */
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> { private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items if (query.isBlank()) return items
val catnum = query.toIntOrNull() val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum } if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase() val tokens = query.split(' ')
return items.filter { it.name.lowercase().contains(lowerQuery) } .map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
} }
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
} }
@@ -22,22 +22,32 @@ import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.view.Surface import android.view.Surface
import android.view.WindowManager
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.math.round import kotlin.math.round
private const val SMOOTHING_FACTOR = 0.15f
private const val SENSOR_RATE_US = 16_000
class SensorsRepo( class SensorsRepo(
private val sensorManager: SensorManager, private val sensorManager: SensorManager,
private val sensor: Sensor?, private val displayManager: DisplayManager?
private val windowManager: WindowManager ) : ISensorsRepo, SensorEventListener {
) : SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f)) private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
private val _compassAccuracy = MutableStateFlow(
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
else CompassAccuracy.UNRELIABLE
)
private val rotationMatrix = FloatArray(9) private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9) private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3) private val orientationValues = FloatArray(3)
@@ -45,11 +55,8 @@ class SensorsRepo(
private var smoothPitch = 0f private var smoothPitch = 0f
private var hasInitialReading = false private var hasInitialReading = false
companion object { override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
private const val SMOOTHING_FACTOR = 0.15f override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
}
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float { override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField( return GeomagneticField(
@@ -62,24 +69,45 @@ class SensorsRepo(
override fun enableSensor() { override fun enableSensor() {
hasInitialReading = false hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) } if (rotationSensor == null || magneticSensor == null) {
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
return
}
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
if (!rotationRegistered || !magneticRegistered) {
sensorManager.unregisterListener(this)
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
}
} }
override fun disableSensor() = sensorManager.unregisterListener(this) override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
updateAccuracy(accuracy)
}
}
override fun onSensorChanged(event: SensorEvent) { override fun onSensorChanged(event: SensorEvent) {
if (event.sensor == sensor) updateOrientation(event.values) when (event.sensor.type) {
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
}
}
private fun updateAccuracy(accuracy: Int) {
_compassAccuracy.value = when (accuracy) {
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
else -> CompassAccuracy.UNRELIABLE
}
} }
private fun getDisplayRotation(): Int { private fun getDisplayRotation(): Int {
return try { return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
@Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation
} catch (_: Exception) {
Surface.ROTATION_0
}
} }
private fun remapForRotation(rotation: Int) { private fun remapForRotation(rotation: Int) {
@@ -101,30 +129,23 @@ class SensorsRepo(
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9) if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} }
private fun updateOrientation(rotationVector: FloatArray) { private fun handleSensorEvent(event: SensorEvent) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector) SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
remapForRotation(getDisplayRotation()) remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues) SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat() val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
val pitch = (orientationValues[1] * RAD2DEG).toFloat() val pitch = (orientationValues[1] * RAD2DEG).toFloat()
val magneticAzimuth = (azimuth + 360f) % 360f
if (!hasInitialReading) { if (!hasInitialReading) {
smoothAzimuth = magneticAzimuth smoothAzimuth = azimuth
smoothPitch = pitch smoothPitch = pitch
hasInitialReading = true hasInitialReading = true
} else { } else {
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth) smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
smoothPitch = lowPass(smoothPitch, pitch) smoothPitch = lowPass(smoothPitch, pitch)
} }
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
_orientation.value = Pair(
round(smoothAzimuth * 10) / 10,
round(smoothPitch * 10) / 10
)
} }
/** Standard exponential low-pass filter. */
private fun lowPass(previous: Float, current: Float): Float { private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous) return previous + SMOOTHING_FACTOR * (current - previous)
} }
@@ -135,9 +156,10 @@ class SensorsRepo(
*/ */
private fun lowPassAngle(previous: Float, current: Float): Float { private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous var delta = current - previous
// Normalise delta into the range (-180, 180]
while (delta > 180f) delta -= 360f while (delta > 180f) delta -= 360f
while (delta <= -180f) delta += 360f while (delta <= -180f) delta += 360f
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
} }
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
} }
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -37,6 +38,10 @@ import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.json.JSONObject
import java.util.Locale
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.Sources
class SettingsRepo( class SettingsRepo(
private val locationManager: LocationManager, private val locationManager: LocationManager,
@@ -53,7 +58,11 @@ class SettingsRepo(
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat" private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace" private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead" private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterHoursBefore = "filterHoursBefore"
private val keyFilterMinElevation = "filterMinElevation" private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
private val keyFilterAosInvert = "filterAosInvert"
private val keyNumberOfRadios = "numberOfRadios" private val keyNumberOfRadios = "numberOfRadios"
private val keyNumberOfSatellites = "numberOfSatellites" private val keyNumberOfSatellites = "numberOfSatellites"
private val keyRotatorAddress = "rotatorAddress" private val keyRotatorAddress = "rotatorAddress"
@@ -64,14 +73,20 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress" private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort" private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat" private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds" private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes" private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes" private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate" private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors" private val keyStateOfSensors = "stateOfSensors"
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
private val keyStateOfSweep = "stateOfSweep" private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc" private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme" private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude" private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude" private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude" private val keyStationLongitude = "stationLongitude"
@@ -80,11 +95,22 @@ class SettingsRepo(
private val keyUpdateTimestamp = "updateTimestamp" private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl" private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl" private val keyTransceiversUrl = "transceiversUrl"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
@@ -111,20 +137,194 @@ class SettingsRepo(
} }
private fun getSelectedTypes(): List<String> { private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, null) val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return listOf("Amateur") if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma) return typesString.split(separatorComma)
} }
//endregion //endregion
//region # Passes filter settings //region # Wavelog worked-grids settings
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogToken = "wavelogToken"
private val keyWorkedGrids = "workedGrids"
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
override fun updateWavelogSettings(settings: WavelogSettings) {
preferences.edit {
putString(keyWavelogUrl, settings.url.trim())
putString(keyWavelogToken, settings.token.trim())
}
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
}
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
token = preferences.getString(keyWavelogToken, null).orEmpty()
)
override fun getWorkedGrids(): Set<String> {
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setWorkedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyWorkedGrids, array.toString()) }
}
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
private val keyWorkedGridQsos = "workedGridQsos"
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
for (grid in root.keys()) {
val array = root.optJSONArray(grid) ?: continue
val list = (0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
com.rtbishop.look4sat.core.domain.model.GridQso(
call = o.optString("c"),
epochMs = o.optLong("t"),
satName = o.optString("s"),
mode = o.optString("m"),
bandUp = o.optString("bu"),
bandDown = o.optString("bd"),
// New award fields: absent in pre-award data -> null.
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
country = o.optString("cty").ifBlank { null },
cqz = o.optInt("cq", 0).takeIf { it > 0 },
state = o.optString("st").ifBlank { null },
// Own-grid field: absent in pre-myGrid data -> null.
myGrid = o.optString("mg").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
}
result
} catch (_: Exception) {
emptyMap()
}
}
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
val root = org.json.JSONObject()
for ((grid, list) in qsos) {
val array = org.json.JSONArray()
for (q in list) {
array.put(
org.json.JSONObject()
.put("c", q.call)
.put("t", q.epochMs)
.put("s", q.satName)
.put("m", q.mode)
.put("bu", q.bandUp)
.put("bd", q.bandDown)
.put("dx", q.dxcc ?: 0)
.put("cty", q.country ?: "")
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
)
}
root.put(grid, array)
}
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
}
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
// satellite QSOs only). Stored as a JSONArray like workedGrids.
private val keyRoamedGrids = "roamedGrids"
override fun getRoamedGrids(): Set<String> {
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setRoamedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
get() = _lotwSettings
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
preferences.edit {
putString(keyLoTWCall, settings.callsign.trim().uppercase())
putString(keyLoTWPass, settings.password)
}
_lotwSettings.value = settings.copy(
callsign = settings.callsign.trim().uppercase(),
password = settings.password
)
}
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
password = preferences.getString(keyLoTWPass, null).orEmpty()
)
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
// decide incremental (same callsign) vs full (first time / callsign change)
// report requests and to merge increments into the stored grid data.
private val keyLastLotwSyncDate = "lotwLastSyncDate"
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
override fun getLastLotwSyncDate(): String =
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
override fun setLastLotwSyncDate(date: String) =
preferences.edit { putString(keyLastLotwSyncDate, date) }
override fun getLastLotwSyncCallsign(): String =
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
override fun setLastLotwSyncCallsign(callsign: String) =
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings()) private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit { override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace) putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead) putInt(keyFilterHoursAhead, settings.hoursAhead)
putInt(keyFilterHoursBefore, settings.hoursBefore)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits()) putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings _passesSettings.value = settings
} }
@@ -132,10 +332,23 @@ class SettingsRepo(
private fun getPassesSettings(): PassesSettings { private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true) val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
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 selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes) return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes,
hoursBefore
)
} }
//endregion //endregion
@@ -277,6 +490,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) { override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit { preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState) putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress) putString(keyRotatorAddress, settings.rotatorAddress)
@@ -286,6 +503,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress) putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort) putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat) putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState) putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat) putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName) putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -294,7 +512,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat) putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress) putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
} }
_rcSettings.value = settings _rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
} }
private fun getRCSettings(): RCSettings = RCSettings( private fun getRCSettings(): RCSettings = RCSettings(
@@ -306,6 +524,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1", frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532", frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ", 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), bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL", bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default", bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -325,12 +545,20 @@ class SettingsRepo(
val new = transform(current) val new = transform(current)
preferences.edit { preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate) putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors) putBoolean(keyStateOfSensors, new.stateOfSensors)
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
putBoolean(keyStateOfSweep, new.stateOfSweep) putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc) putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme) putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
} }
new new
} }
@@ -338,12 +566,20 @@ class SettingsRepo(
private fun getOtherSettings(): OtherSettings = OtherSettings( private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true), stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true), stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true), stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false), stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true) shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
) )
//endregion //endregion
@@ -352,21 +588,108 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
// Normalize the enabled lists so they are positionally aligned with the URL lists.
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
// invariant intact even when a default empty list is used to construct the model.
val normalized = settings.copy(
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit { preferences.edit {
putBoolean(keyUseCustomTle, settings.useCustomTLE) putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keyTleUrl, settings.tleUrl) putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrl, settings.transceiversUrl) putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
} }
_dataSourcesSettings.value = settings _dataSourcesSettings.value = normalized
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false), satelliteUrls = getDataSourceUrls(
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false), key = keySatelliteUrls,
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "", defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: "" legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
).migrateSatnogsTleSource(),
transceiversUrls = getDataSourceUrls(
key = keyTransceiversUrls,
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTransceivers,
legacyUrlKey = keyTransceiversUrl
),
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
) )
/** Read the persisted per-source enabled flags (empty when never stored). */
private fun readEnabledFlags(key: String): List<Boolean> {
return preferences.getString(key, null)
?.split(separatorComma)
?.mapNotNull { it.trim() }
?.filter { it == "true" || it == "false" }
?.map { it == "true" }
?: emptyList()
}
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
private fun getDataSourceUrls(
key: String,
defaultUrls: List<String>,
legacyEnabledKey: String,
legacyUrlKey: String
): List<String> {
val stored = preferences.getString(key, null)
?.split(separatorUrl)
?.map { it.trim() }
?.filter { it.isNotBlank() }
if (stored != null) return stored
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
(defaultUrls + legacyCustomUrl).distinct()
} else {
defaultUrls
}
}
private fun List<String>.migrateSatnogsTleSource(): List<String> {
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
if (celestrakSatnogsIndex < 0) return this
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
preferences.edit {
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
putBoolean(keySatnogsTleSourceMigration, true)
}
return migrated
}
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
private fun isSameSourceUrl(first: String, second: String): Boolean =
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
private fun normalizeSourceUrl(url: String): String {
val trimmed = url.trim()
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion //endregion
//region # Radio control settings //region # Radio control settings
@@ -377,6 +700,7 @@ class SettingsRepo(
private val keyTxRadioName = "txRadioName" private val keyTxRadioName = "txRadioName"
private val keyRxRadioName = "rxRadioName" private val keyRxRadioName = "rxRadioName"
private val keyRadioBaudRate = "radioBaudRate" private val keyRadioBaudRate = "radioBaudRate"
private val keyRadioSplitMode = "radioSplitMode"
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings()) private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
@@ -390,18 +714,71 @@ class SettingsRepo(
putString(keyTxRadioName, settings.txRadioName) putString(keyTxRadioName, settings.txRadioName)
putString(keyRxRadioName, settings.rxRadioName) putString(keyRxRadioName, settings.rxRadioName)
putInt(keyRadioBaudRate, settings.baudRate) putInt(keyRadioBaudRate, settings.baudRate)
putBoolean(keyRadioSplitMode, settings.splitMode)
} }
_radioControlSettings.value = settings _radioControlSettings.value = settings
} }
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings( private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
enabled = preferences.getBoolean(keyRadioControlEnabled, false), enabled = preferences.getBoolean(keyRadioControlEnabled, false),
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818", radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "", txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "", rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio", txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio", rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
baudRate = preferences.getInt(keyRadioBaudRate, 4800) baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
) )
//endregion //endregion
//region # Per-satellite calculator offset settings
private val keySatelliteOffsets = "satelliteOffsets"
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val stored = try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
if (stored.isNotEmpty()) return stored
// Lazy migration from the fork's old UI-layer implementation, which stored
// one SharedPreferences entry per satellite directly from TransceiversPage.
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
val legacy = preferences.getString(legacyKey, "").orEmpty()
if (legacy.isNotEmpty()) {
setSatelliteOffset(catnum, legacy)
}
return legacy
}
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) {
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
}
preferences.edit {
putString(keySatelliteOffsets, updated)
remove("$keyLegacySatelliteOffsetPrefix$catnum")
}
}
//endregion
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
} }
@@ -0,0 +1,106 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class UpdateRepository(
private val remoteSource: IRemoteSource
) : IUpdateRepository {
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
// Use the GitHub releases web page instead of the REST API endpoint:
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
// failures. The web endpoint redirects to the latest tag with no such limit.
// When GitHub is unreachable (common on mainland-China networks without a
// proxy), fall back to community GitHub accelerator mirrors that proxy the
// same page; each mirror resolves the identical tag and asset URLs.
for (baseUrl in LATEST_RELEASE_URLS) {
val result = remoteSource.getNetworkStream(baseUrl)
val stream = result.stream ?: continue
val parsed = try {
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
} catch (e: Exception) {
println("UpdateRepository parse failure: $e")
null
}
if (parsed != null) return@withContext parsed
}
null
}
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
if (url.isBlank()) return@withContext false
val result = remoteSource.getNetworkStream(url)
val stream = result.stream ?: return@withContext false
try {
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
true
} catch (e: Exception) {
println("UpdateRepository download failure: $e")
false
}
}
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
// The redirect target is the latest release's tag page. Extract the tag
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
// unambiguous. The <title> also holds the release name, but that is free
// text and cannot be used to build the asset URL.
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
?.groupValues?.get(1) ?: return null
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
?.groupValues?.get(1) ?: return null
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// Release notes live in the page's markdown-body section (first occurrence
// is the release description). Strip HTML tags for plain-text display.
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1) ?: ""
val body = raw.replace(Regex("<[^>]+>"), "")
.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.trim()
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
// versioned filename, no "-release" suffix). When the page was fetched
// through an accelerator mirror (https://<mirror>/https://github.com/...),
// download through the same mirror — raw github.com is unreachable on the
// networks that needed the mirror in the first place.
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
val apkUrl = if (mirrorPrefix.isEmpty()) {
"$DOWNLOAD_BASE_URL/$tag/$apkName"
} else {
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
}
return LatestRelease(
versionTag = tag,
title = title,
body = body,
apkUrl = apkUrl
)
}
private companion object {
// Web pages are used instead of api.github.com to avoid the 60 req/hour
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
// tried first; if it is unreachable (no proxy on mainland networks) the
// accelerator mirrors are tried in order. Download URLs always point at
// the mirrors too — raw github.com release downloads are equally blocked
// without a proxy, so a mirror-resolved tag must be downloaded via the
// same mirror.
val LATEST_RELEASE_URLS = listOf(
"https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
)
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
}
}
@@ -0,0 +1,97 @@
/*
* 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.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
* POST {base}/index.php/api/logbook_get_worked_grids
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
*
* The token field carries "<api_key>:<logbook_id>" so one credential line
* configures both values (the dialog explains this to the user).
*/
class WavelogRepository : IWavelogRepository {
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
val parts = token.trim().split(":")
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
try {
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
val body = JSONObject().apply {
put("key", apiKey)
put("logbook_id", logbookId)
}.toString().toByteArray()
connection.outputStream.use { it.write(body) }
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val response = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(response)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
for (i in 0 until array.length()) {
val grid = array.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
) {
result.add(grid)
}
}
result
} catch (_: Exception) {
null
}
}
}
@@ -51,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun OrbitalData.toEntity() = FrameworkEntry( private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() } private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,21 +73,22 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios() return look4SatDao.getRadiosWithId(id).toDomainRadios()
} }
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.deleteRadios() look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
look4SatDao.insertRadios(radios.toFrameworkRadios())
} }
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
override suspend fun deleteRadios() = look4SatDao.deleteRadios() override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio( private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
) )
private fun FrameworkRadio.toDomain() = DomainRadio( private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() } private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -20,10 +20,13 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver import android.content.ContentResolver
import androidx.core.net.toUri import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream import java.io.InputStream
class RemoteSource( class RemoteSource(
@@ -42,12 +45,74 @@ class RemoteSource(
} }
} }
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().body.byteStream() val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
val code = response.code
response.close()
return@withContext NetworkResult(code, null)
}
// Return the body stream directly as the caller is responsible for closing it.
// Closing the stream returns the connection to OkHttp's pool.
val body = response.body
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null null
} }
} }
@@ -0,0 +1,63 @@
/*
* 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.usecase
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import androidx.annotation.RequiresPermission
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
class AudioCapture : IAudioCapture {
override val sampleRate: Int = 44100
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate) // at least 1 second buffer
@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
override fun audioFlow(): Flow<FloatArray> = flow {
val recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize * 4 // bytes for float
)
try {
recorder.startRecording()
val chunkSize = sampleRate / 10 // ~100ms chunks
val buffer = FloatArray(chunkSize)
while (currentCoroutineContext().isActive) {
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
} finally {
recorder.stop()
recorder.release()
}
}.flowOn(Dispatchers.IO)
}
@@ -0,0 +1,84 @@
/*
* 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.usecase
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
//Saves images to the device gallery using MediaStore (API 29+) or direct file write (API < 29)
class SaveImage(private val context: Context) : ISaveImage {
override suspend fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean {
return withContext(Dispatchers.IO) {
try {
val bitmap = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filename = "SSTV_${modeName}_$timestamp.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveWithMediaStore(bitmap, filename)
} else {
saveToExternalStorage(bitmap, filename)
}
bitmap.recycle()
true
} catch (_: Exception) {
false
}
}
}
private fun saveWithMediaStore(bitmap: Bitmap, filename: String) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Look4Sat")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IllegalStateException("Failed to create MediaStore entry")
resolver.openOutputStream(uri)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
private fun saveToExternalStorage(bitmap: Bitmap, filename: String) {
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val dir = File(picturesDir, "Look4Sat")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, filename)
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
}
}
@@ -1,30 +1,29 @@
package com.rtbishop.look4sat.core.data package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.data.framework.Ft817CatProtocol import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class Ft817CatProtocolTest { class Ft817CatProtocolTest {
@Test @Test
fun encodeFrequencyBcd_145500000() { fun encodeFrequencyBcd_145500000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L) val bcd = Ft817CatProtocol.encodeFrequencyBcd(145500000L)
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd) assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00), bcd)
} }
@Test @Test
fun encodeFrequencyBcd_435100000() { fun encodeFrequencyBcd_435100000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L) val bcd = Ft817CatProtocol.encodeFrequencyBcd(435100000L)
assertContentEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd) assertArrayEquals(byteArrayOf(0x43, 0x51, 0x00, 0x00), bcd)
} }
@Test @Test
fun encodeFrequencyBcd_7074000() { fun encodeFrequencyBcd_7074000() {
val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L) val bcd = Ft817CatProtocol.encodeFrequencyBcd(7074000L)
assertContentEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd) assertArrayEquals(byteArrayOf(0x00, 0x70, 0x74, 0x00), bcd)
} }
@Test @Test
@@ -42,21 +41,21 @@ class Ft817CatProtocolTest {
val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L) val cmd = Ft817CatProtocol.buildSetFreqCommand(145500000L)
assertEquals(5, cmd.size) assertEquals(5, cmd.size)
assertEquals(0x01.toByte(), cmd[4]) assertEquals(0x01.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd) assertArrayEquals(byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01), cmd)
} }
@Test @Test
fun buildSetModeCommand_usb() { fun buildSetModeCommand_usb() {
val cmd = Ft817CatProtocol.buildSetModeCommand("USB") val cmd = Ft817CatProtocol.buildSetModeCommand("USB")
assertNotNull(cmd) assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd) assertArrayEquals(byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x07), cmd)
} }
@Test @Test
fun buildSetModeCommand_fm() { fun buildSetModeCommand_fm() {
val cmd = Ft817CatProtocol.buildSetModeCommand("FM") val cmd = Ft817CatProtocol.buildSetModeCommand("FM")
assertNotNull(cmd) assertNotNull(cmd)
assertContentEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd) assertArrayEquals(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x07), cmd)
} }
@Test @Test
@@ -67,19 +66,19 @@ class Ft817CatProtocolTest {
@Test @Test
fun encodeCtcssTone_67_0() { fun encodeCtcssTone_67_0() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0) val bcd = Ft817CatProtocol.encodeCtcssToneBcd(67.0)
assertContentEquals(byteArrayOf(0x06, 0x70), bcd) assertArrayEquals(byteArrayOf(0x06, 0x70), bcd)
} }
@Test @Test
fun encodeCtcssTone_74_4() { fun encodeCtcssTone_74_4() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4) val bcd = Ft817CatProtocol.encodeCtcssToneBcd(74.4)
assertContentEquals(byteArrayOf(0x07, 0x44), bcd) assertArrayEquals(byteArrayOf(0x07, 0x44), bcd)
} }
@Test @Test
fun encodeCtcssTone_141_3() { fun encodeCtcssTone_141_3() {
val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3) val bcd = Ft817CatProtocol.encodeCtcssToneBcd(141.3)
assertContentEquals(byteArrayOf(0x14, 0x13), bcd) assertArrayEquals(byteArrayOf(0x14, 0x13), bcd)
} }
@Test @Test
@@ -87,13 +86,13 @@ class Ft817CatProtocolTest {
val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0) val cmd = Ft817CatProtocol.buildSetCtcssToneCommand(67.0)
assertEquals(5, cmd.size) assertEquals(5, cmd.size)
assertEquals(0x0B.toByte(), cmd[4]) assertEquals(0x0B.toByte(), cmd[4])
assertContentEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd) assertArrayEquals(byteArrayOf(0x06, 0x70, 0x00, 0x00, 0x0B), cmd)
} }
@Test @Test
fun buildCtcssModeCommand_enable() { fun buildCtcssModeCommand_enable() {
val cmd = Ft817CatProtocol.buildCtcssModeCommand(true) val cmd = Ft817CatProtocol.buildCtcssModeCommand(true)
assertContentEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd) assertArrayEquals(byteArrayOf(0x2A, 0x00, 0x00, 0x00, 0x0A), cmd)
} }
@Test @Test
@@ -108,6 +107,7 @@ class Ft817CatProtocolTest {
val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01) val response = byteArrayOf(0x14, 0x55, 0x00, 0x00, 0x01)
val result = Ft817CatProtocol.parseReadResponse(response) val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result) assertNotNull(result)
result ?: return
assertEquals(145500000L, result.first) assertEquals(145500000L, result.first)
assertEquals("USB", result.second) assertEquals("USB", result.second)
} }
@@ -117,6 +117,7 @@ class Ft817CatProtocolTest {
val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08) val response = byteArrayOf(0x14, 0x60, 0x00, 0x00, 0x08)
val result = Ft817CatProtocol.parseReadResponse(response) val result = Ft817CatProtocol.parseReadResponse(response)
assertNotNull(result) assertNotNull(result)
result ?: return
assertEquals(146000000L, result.first) assertEquals(146000000L, result.first)
assertEquals("FM", result.second) assertEquals("FM", result.second)
} }
@@ -135,7 +136,7 @@ class Ft817CatProtocolTest {
@Test @Test
fun buildPttCommands() { fun buildPttCommands() {
val on = Ft817CatProtocol.buildPttOnCommand() val on = Ft817CatProtocol.buildPttOnCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on) assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x08), on)
val off = Ft817CatProtocol.buildPttOffCommand() val off = Ft817CatProtocol.buildPttOffCommand()
assertEquals(0x88.toByte(), off[4]) assertEquals(0x88.toByte(), off[4])
@@ -144,6 +145,6 @@ class Ft817CatProtocolTest {
@Test @Test
fun buildReadCommand() { fun buildReadCommand() {
val cmd = Ft817CatProtocol.buildReadFreqModeCommand() val cmd = Ft817CatProtocol.buildReadFreqModeCommand()
assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd) assertArrayEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x03), cmd)
} }
} }
@@ -0,0 +1,171 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class AmSatRepositoryTest {
@Test
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
val cachedPage = SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
repository.seedStatusCache(cachedPage)
val firstPage = repository.fetchStatus()
val secondPage = repository.fetchStatus()
assertSame(cachedPage, firstPage)
assertSame(cachedPage, secondPage)
assertSame(cachedPage, repository.getCachedStatus())
assertEquals(0, remoteSource.catalogRequests)
assertEquals(0, remoteSource.reportRequests)
repository.clearStatusCache()
assertNull(repository.getCachedStatus())
repository.fetchStatus()
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun forceRefreshBypassesCachedPage() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
repository.seedStatusCache(
SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
)
repository.fetchStatus(forceRefresh = true)
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
val repository = AmSatRepository(remoteSource)
val prefetch = async { repository.prefetchStatus() }
val pageFetch = async { repository.fetchStatus() }
prefetch.await()
val page = pageFetch.await()
assertSame(page, repository.getCachedStatus())
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
report("r1", "AO-7", "Heard", nowSec - 3600),
report("r2", "AO-7", "Heard", nowSec - 5400),
report("r3", "AO-7", "Not Heard", nowSec - 7000),
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
report("r4", "AO-7", "Heard", nowSec - 63000),
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
report("r5", "AO-7", "Heard", nowSec - 90000),
report("r6", "AO-7", "Not Heard", nowSec - 100000)
// day 2 无报告 → 0
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
val status = page?.statuses?.single()
assertEquals("AO-7", status?.name)
assertEquals(3, status?.days?.size)
assertEquals(2, status?.days?.get(0)?.streakCount)
assertEquals(1, status?.days?.get(1)?.streakCount)
assertEquals(0, status?.days?.get(2)?.streakCount)
}
@Test
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
report("r1", "AO-7", "Heard", nowSec - 1800),
report("r2", "AO-7", "Heard", nowSec - 3600),
report("r3", "AO-7", "Heard", nowSec - 5400),
report("r4", "AO-7", "Heard", nowSec - 30000)
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
cacheField.isAccessible = true
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(
private val responseDelayMillis: Long = 0L,
private val reportsJson: String = """{"data":[]}"""
) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
override suspend fun getAmSatCatalog(): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
catalogRequests += 1
return """{"data":[{"name":"AO-7"}]}"""
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return reportsJson
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private fun amSatReportsJson(vararg reports: String): String =
"""{"data":[${reports.joinToString(",")}]}"""
private fun report(id: String, name: String, status: String, epochSec: Long): String =
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
private fun isoUtc(epochSec: Long): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(epochSec * 1000))
@@ -0,0 +1,322 @@
/*
* 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.GridQso
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.OrbitalData
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 com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher)
@Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTLEFromFile(uri)
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
}
@Test
fun `manual satellite import keeps tle support`() = runTest(dispatcher) {
val uri = "content://look4sat/import/legacy"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTLEFromFile(uri)
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
}
@Test
fun `custom data source imports omm csv from web`() = runTest(dispatcher) {
val customCsvUrl = "https://example.com/custom-omm.csv"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[customCsvUrl] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(customCsvUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
}
@Test
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { jamxTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
}
private fun validCsvStream(): InputStream = """
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,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun jamxTleStream(): InputStream = """
JAMX-0825b
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream()
}
private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): NetworkResult =
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size
override suspend fun getEntriesList(): List<SatItem> = emptyList()
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries
}
override suspend fun deleteEntries() {
insertedEntries.clear()
}
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = insertedRadios.size
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { !it.isCustom }
}
override suspend fun deleteRadios() {
insertedRadios.clear()
}
}
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
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,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: 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 fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
satelliteTypeIdsByType[type] = ids
}
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) {
dataSourcesSettings.value = settings
}
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override fun updateDataSourcesStatus(status: Map<String, Int>) {
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
private val workedGrids = MutableStateFlow(emptySet<String>())
override fun setWorkedGrids(grids: Set<String>) {
workedGrids.value = grids
}
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
private val roamedGrids = MutableStateFlow(emptySet<String>())
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
return DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = emptyList()
)
}
@@ -0,0 +1,312 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWRepositoryTest {
private val repo = LoTWRepository()
private fun report(vararg records: String): String =
buildString {
append("<ADIF_VERS:5>3.1.4\n")
append("<PROGRAMID:8>look4sat\n")
append("<eoh>\n")
records.forEach { append(it).append("\n") }
}
@Test
fun parseRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseCollectsOnlySatelliteGrids() {
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
val satQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260820\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
val groundQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260821\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("NL47"), result)
}
@Test
fun parseSupportsVuccGridPairsAndSixCharGrids() {
val satQso = "<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso))
assertEquals(setOf("OM60", "EN52", "EN53"), result)
}
@Test
fun parsePropModeDoesNotLeakAcrossRecords() {
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
// record must not inherit the satellite flag after its own <EOR>.
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("OL62"), result)
}
@Test
fun parseHandlesAlphabeticalFieldOrder() {
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
// sequential gate (add only while propMode == SAT) dropped every grid on
// real reports — the buffered parser must collect them regardless of
// field order and filter at <EOR>.
val satQso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>NL47\n" +
"<MODE:3>FM\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
"<EOR>\n"
val groundQso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>PM95\n" +
"<QSO_DATE:8>20260821\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("NL47", "EN52", "EN53"), result)
}
@Test
fun parseReturnsEmptySetForReportWithoutGrids() {
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
}
// region parseConfirmedGridQsos
private val satQsoFull = "<CALL:5>A50QO\n" +
"<QSO_DATE:8>20260820\n" +
"<TIME_ON:6>113045\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<MODE:2>CW\n" +
"<BAND:3>70CM\n" +
"<BAND_RX:3>2M\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
@Test
fun parseQsosExtractsSatelliteDetail() {
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
val qsos = result["NL47"]!!
assertEquals(1, qsos.size)
val qso = qsos[0]
assertEquals("A50QO", qso.call)
assertEquals("FO-29", qso.satName)
assertEquals("CW", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("V/U", qso.bandLabel)
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
}
@Test
fun parseQsosExcludesGroundQsos() {
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
assertEquals(1, result["NL47"]!!.size)
}
@Test
fun parseQsosVuccPairFeedsBothGrids() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
assertEquals(setOf("EN52", "EN53"), result.keys)
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
}
@Test
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
// VUCC allows up to four grids in one contact.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
result.values.forEach { assertEquals(1, it.size) }
}
@Test
fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseQsosCapturesOwnGridPerQso() {
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
// alphabetically); the per-record buffer must still attach it to the QSO.
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
assertEquals("OL62", result["NL47"]!!.first().myGrid)
assertEquals("OL72", result["OK48"]!!.first().myGrid)
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@Test
fun parseRoamedGridsCollectsOwnStationGrids() {
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
// operated from (the "roamed/activated" set shown as blue stripes).
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
}
@Test
fun parseRoamedGridsIgnoresGroundQsos() {
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
}
@Test
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsTruncatesSixCharToFour() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
// while propMode == SAT) silently returned an EMPTY set on real reports —
// this is the exact bug that made the blue roamed stripes never appear.
val qso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>PM95\n" +
"<MY_GRIDSQUARE:6>OL72XX\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>IO-86\n" +
"<EOR>\n"
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsRejectsBodyWithoutEoh() {
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@Test
fun failureClassificationMapsEachException() {
// LoTWRepository.toResult() must keep each failure cause distinct so
// the UI can show a specific message per cause.
assertEquals(
LoTWResult.BadCredentials,
repo.toResult(LoTWRepository.CredentialsException())
)
assertEquals(
LoTWResult.RateLimited,
repo.toResult(LoTWRepository.RateLimitException())
)
assertEquals(
LoTWResult.Timeout,
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
)
val net = repo.toResult(java.io.IOException("HTTP 500"))
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
}
@Test
fun parseQsosExtractsAwardFields() {
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
// the parser must strip the name suffix and keep the bare code.
val qso = "<CALL:5>BG7XYZ\n" +
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["OL62"]!!.first()
assertEquals(318, parsed.dxcc)
assertEquals("CHINA", parsed.country)
assertEquals(24, parsed.cqz)
assertEquals("GD", parsed.state)
}
@Test
fun parseQsosStripsStateNameSuffixForAllEntities() {
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
// the 2-digit code). US states also arrive as "CODE // Name".
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
assertEquals("34", result["PM95"]!!.first().state)
assertEquals("CA", result["EM40"]!!.first().state)
}
@Test
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["PM95"]!!.first()
assertNull(parsed.dxcc)
assertNull(parsed.country)
assertNull(parsed.cqz)
assertNull(parsed.state)
}
@Test
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
assertNull(result["PM95"]!!.first().dxcc)
assertNull(result["PM95"]!!.first().cqz)
assertNull(result["PM95"]!!.first().state)
}
// endregion
}
@@ -0,0 +1,197 @@
/*
* 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.
*/
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.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoSearchTest {
private val sampleItems = listOf(
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
SatItem(catnum = 99999, name = "FO-29"),
)
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("ao7")
val results = repo.getEntriesFlow().first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
// key guarantee is that AO-7 — which was unreachable before because of
// the dashes/brackets — is now found.
assertTrue(results.map { it.catnum }.contains(7530))
assertEquals(7530, results.first().catnum)
}
@Test
fun `query without separators matches name with only dashes`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("fo29")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(99999), results.map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zarya iss")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `exact name query still matches`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(7530), results.map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("funcube")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(39444), results.map { it.catnum })
}
@Test
fun `numeric query matches catnum exactly`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("25544")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `no query returns all items`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("")
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.size, results.size)
}
@Test
fun `unmatched query returns nothing`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zzzznomatch")
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
private fun createRepo(items: List<SatItem>): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items),
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<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>, isCustom: Boolean) = Unit
override suspend fun deleteManagedRadios() = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepoForSearch : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
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,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: 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 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) = Unit
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
override fun setRoamedGrids(grids: Set<String>) = Unit
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
}
@@ -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.cw
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -0,0 +1,114 @@
/*
* 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.cw
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -0,0 +1,165 @@
/*
* 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.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -0,0 +1,102 @@
/*
* 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.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -0,0 +1,87 @@
/*
* 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.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -0,0 +1,48 @@
/*
* 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.cw
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
*/
internal class CwFilter {
private var z1 = 0f
companion object {
private const val PI_F = 3.141592653589793f
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
@@ -0,0 +1,54 @@
/*
* 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.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -0,0 +1,53 @@
/*
* 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.cw
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -0,0 +1,61 @@
/*
* 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.cw
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -0,0 +1,170 @@
/*
* 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.cw
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -0,0 +1,194 @@
/*
* 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
/**
* Award progress computed from the confirmed-QSO store.
*
* @param type which award
* @param workedKeys matching keys for the boundary assets: each is compared
* against the region "code" field of the award's GeoJSON
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
* @param count distinct worked units (grids / entities / provinces / ...)
* @param target units required for the award
*/
data class AwardProgress(
val type: AwardType,
val workedKeys: Set<String>,
val count: Int,
val target: Int
)
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
object AwardTargets {
const val DXCC = 100
const val VUCC = 100
const val WAPC = 34
const val WAJA = 47
const val WAZ = 40
const val WAS = 50
}
/**
* Derives six-award progress from the confirmed QSO store.
*
* Award rules (ADIF / award-program semantics):
* - VUCC: distinct 4-char gridsquares.
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
* to a callsign-prefix guess when LoTW omitted it).
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
* - WAZ: distinct CQ zones.
* - WAS: distinct US states (STATE abbreviations).
*
* The ADIF STATE field's meaning depends on the DXCC entity
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
*/
object AwardCalculator {
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
val all = qsosByGrid.values.flatten()
return listOf(
AwardProgress(
AwardType.VUCC,
workedKeys = qsosByGrid.keys,
count = qsosByGrid.size,
target = AwardTargets.VUCC
),
calculateDxcc(all),
calculateWapc(all),
calculateWaja(all),
calculateWaz(all),
calculateWas(all)
)
}
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
val code = q.dxcc ?: prefixEntityCode(q.call)
if (code != null) keys.add(code.toString())
}
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
}
private fun calculateWapc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
when (entityOf(q)) {
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
Entity.HONG_KONG -> keys.add(HK_CODE)
Entity.MACAO -> keys.add(MO_CODE)
Entity.TAIWAN -> keys.add(TW_CODE)
else -> {}
}
}
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
}
private fun calculateWaja(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.JAPAN) continue
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
}
private fun calculateWaz(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
}
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
}
private fun calculateWas(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.USA) continue
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
}
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
318 -> Entity.CHINA
321 -> Entity.HONG_KONG
152 -> Entity.MACAO
386 -> Entity.TAIWAN
339 -> Entity.JAPAN
291 -> Entity.USA
else -> prefixEntity(q.call)
}
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
private fun prefixEntity(call: String): Entity {
val c = call.uppercase().substringBefore('/')
return when {
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
c.startsWith("VR") -> Entity.HONG_KONG
c.startsWith("XX9") -> Entity.MACAO
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
else -> Entity.OTHER
}
}
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
private fun prefixEntityCode(call: String): Int? {
return when (prefixEntity(call)) {
Entity.CHINA -> 318
Entity.HONG_KONG -> 321
Entity.MACAO -> 152
Entity.TAIWAN -> 386
Entity.JAPAN -> 339
Entity.USA -> 291
else -> null
}
}
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
private val JP_PREFIXES =
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
private val US_PREFIXES =
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
private val CN_STATES = setOf(
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
)
private val US_STATES = setOf(
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
"VA", "WA", "WV", "WI", "WY"
)
private const val HK_CODE = "HK"
private const val MO_CODE = "MO"
private const val TW_CODE = "TW"
}
@@ -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
}
@@ -0,0 +1,78 @@
/*
* 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
/**
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
* so the map can show, for any worked grid, which callsigns were worked there
* and when.
*
* @param call opposite station callsign (as logged by the opposite op)
* @param epochMs QSO date+time in UTC milliseconds
* @param satName satellite name (e.g. "FO-29")
* @param mode ADIF mode (FM / CW / SSB ...)
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
* LoTW did not include it
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
* <DXCC>), or null when LoTW omitted it
* @param country DXCC entity name (ADIF <COUNTRY>), or null
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
* @param state Primary administrative subdivision of the opposite station
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
* China -> province code ("GD"), Japan -> prefecture number
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
*/
data class GridQso(
val call: String,
val epochMs: Long,
val satName: String,
val mode: String,
val bandUp: String,
val bandDown: String,
val dxcc: Int? = null,
val country: String? = null,
val cqz: Int? = null,
val state: String? = null,
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
val myGrid: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
get() {
val up = bandAbbrev(bandUp)
val down = bandAbbrev(bandDown)
return when {
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
up.isNotEmpty() -> up
else -> ""
}
}
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
"70CM" -> "U"
"2M" -> "V"
"10M" -> "A"
"6M" -> "V"
"1.2G" -> "L"
"2.4G" -> "S"
"23CM" -> "L"
"13CM" -> "S"
else -> band.trim().uppercase()
}
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.model
/** Latest release info fetched from the fork's GitHub releases API. */
data class LatestRelease(
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
val title: String, // release name, may be empty
val body: String, // release notes / update description
val apkUrl: String? // first .apk asset download URL, null if absent
)
@@ -17,16 +17,22 @@
*/ */
package com.rtbishop.look4sat.core.domain.model package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SatRadio( data class SatRadio(
val uuid: String, @SerialName("uuid") val uuid: String,
val info: String, @SerialName("description") val info: String,
val isAlive: Boolean, @SerialName("alive") val isAlive: Boolean,
var downlinkLow: Long?, @SerialName("downlink_low") val downlinkLow: Long?,
var downlinkHigh: Long?, @SerialName("downlink_high") val downlinkHigh: Long?,
val downlinkMode: String?, @SerialName("mode") val downlinkMode: String?,
var uplinkLow: Long?, @SerialName("uplink_low") val uplinkLow: Long?,
var uplinkHigh: Long?, @SerialName("uplink_high") val uplinkHigh: Long?,
val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String?,
val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean,
val catnum: Int? @SerialName("norad_cat_id") val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -0,0 +1,38 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
)
/** One satellite, 3 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 3 days (newest first)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -27,7 +27,12 @@ data class PassesSettings(
val showDeepSpace: Boolean = true, val showDeepSpace: Boolean = true,
val hoursAhead: Int, val hoursAhead: Int,
val minElevation: Double, val minElevation: Double,
val selectedModes: List<String> val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>,
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
val hoursBefore: Int = 0
) )
data class RCSettings( data class RCSettings(
@@ -39,6 +44,7 @@ data class RCSettings(
val frequencyAddress: String, val frequencyAddress: String,
val frequencyPort: String, val frequencyPort: String,
val frequencyFormat: String, val frequencyFormat: String,
val frequencyOffsetHz: Long = 0L,
val bluetoothRotatorState: Boolean, val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String, val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String, val bluetoothRotatorName: String,
@@ -54,16 +60,47 @@ data class OtherSettings(
val stateOfSweep: Boolean, val stateOfSweep: Boolean,
val stateOfUtc: Boolean, val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean, val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
/** Grid mode: label worked (green) cells with the FIRST callsign worked
* in that grid instead of the Maidenhead code; non-worked cells get no
* label at all. */
val stateOfMapFirstCall: Boolean = false,
val shouldSeeWarning: Boolean, val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
val compassOffsetDegrees: Float = 0f
) )
data class DataSourcesSettings( data class DataSourcesSettings(
val useCustomTLE: Boolean, val satelliteUrls: List<String>,
val useCustomTransceivers: Boolean, val transceiversUrls: List<String>,
val tleUrl: String, val satelliteEnabled: List<Boolean> = emptyList(),
val transceiversUrl: String val transceiversEnabled: List<Boolean> = emptyList()
) ) {
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class WavelogSettings(
val url: String = "",
val token: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
data class LoTWSettings(
val callsign: String = "",
val password: String = ""
) {
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
}
data class RadioControlSettings( data class RadioControlSettings(
val enabled: Boolean, val enabled: Boolean,
@@ -72,12 +109,20 @@ data class RadioControlSettings(
val rxRadioAddress: String, val rxRadioAddress: String,
val txRadioName: String, val txRadioName: String,
val rxRadioName: String, val rxRadioName: String,
val baudRate: Int val baudRate: Int,
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
val splitMode: Boolean = false
) { ) {
companion object { companion object {
val SUPPORTED_RADIOS = listOf( const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
"Yaesu FT-817/818", const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
"Yaesu FT-857/897" const val MODEL_ICOM_IC705 = "Icom IC-705"
)
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
/** Baud rates available for Yaesu radios. */
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
} }
} }
@@ -0,0 +1,671 @@
/*
* 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 com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toRadians
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Standalone celestial computations extracted from PREDICT v2.2.5.
* Provides Sun position, Moon position, satellite visibility classification,
* orbital metadata, RA/Dec conversion, and rise/set finding for Sun and Moon.
*
* All angles are in degrees unless noted. Time is Unix epoch milliseconds.
*
* Shared math utilities (thetaGJD, modulus, mod2PI, deltaET, millisToDaynum,
* solarPositionECI, eciToGeodetic) live in OrbitalMath.kt in the same package.
*/
object CelestialComputer {
// ── Result types ──
/** Sun position as seen from a ground observer. */
data class SunPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees, >0 above horizon
val distance: Double, // normalized: 1.0 + ((range - AU) / AU)
val rangeRate: Double, // km/s
val latitude: Double, // sub-solar point latitude, degrees
val longitude: Double, // sub-solar point longitude, degrees
val rightAscension: Double, // degrees
val declination: Double // degrees
)
/** Moon position as seen from a ground observer. */
data class MoonPosition(
val azimuth: Double, // degrees, 0=N, 90=E
val elevation: Double, // degrees
val rightAscension: Double, // degrees
val declination: Double, // degrees
val gha: Double, // Greenwich Hour Angle, degrees
val angularDiameter: Double, // apparent diameter relative to Earth's diameter
val radialVelocity: Double // m/s, Doppler radial velocity for EME
)
/**
* 3-state satellite visibility classification.
* - [VISIBLE]: satellite is sunlit, observer is in darkness (sun below -12°) — optically visible
* - [DAYLIGHT]: satellite is sunlit, observer is in daylight
* - [ECLIPSED]: satellite is in Earth's shadow
*/
enum class SatVisibility { VISIBLE, DAYLIGHT, ECLIPSED }
/** Orbital metadata not typically included in pass data. */
data class OrbitalMetadata(
val footprintDiameter: Double, // km, ground coverage circle diameter
val orbitNumber: Long, // current orbit/revolution number
val betaAngle: Double, // degrees, angle between orbital plane and Sun
val orbitalPhase: Double // 0-256 phase within current orbit
)
// ── Sun position ──
/**
* Compute the Sun's full position as seen from [observer] at [timeMillis].
* Includes az/el, RA/Dec, sub-solar lat/lon, range, and range rate.
* Based on FindSun() from PREDICT v2.2.5.
*/
fun getSunPosition(observer: GeoPos, timeMillis: Long): SunPosition {
val daynum = millisToDaynum(timeMillis)
val julUtc = daynum + 2444238.5
val sunVec = solarPositionECI(julUtc)
val zeroVel = doubleArrayOf(0.0, 0.0, 0.0)
val obsGeo = observerGeodetic(observer)
// Az, El, Range, RangeRate
val obsSet = computeObsAngles(julUtc, sunVec, zeroVel, obsGeo)
// Lat/Lon of sub-solar point
val latLon = eciToGeodetic(julUtc, sunVec)
// RA/Dec
val raDec = calculateRADec(julUtc, sunVec, zeroVel, obsGeo)
return SunPosition(
azimuth = obsSet[0].toDegrees(),
elevation = obsSet[1].toDegrees(),
distance = 1.0 + ((obsSet[2] - ASTRONOMICAL_UNIT) / ASTRONOMICAL_UNIT),
rangeRate = 1000.0 * obsSet[3],
latitude = latLon[0].toDegrees(),
longitude = latLon[1].toDegrees().let { if (it > 180.0) it - 360.0 else it },
rightAscension = raDec[0].toDegrees(),
declination = raDec[1].toDegrees()
)
}
// ── Moon position ──
/**
* Compute the Moon's position as seen from [observer] at [timeMillis].
* Full Meeus lunar ephemeris from PREDICT v2.2.5 with expanded terms
* and radial velocity approximation for EME Doppler.
*/
fun getMoonPosition(observer: GeoPos, timeMillis: Long): MoonPosition {
val daynum = millisToDaynum(timeMillis)
val jd = daynum + 2444238.5
var t = (jd - 2415020.0) / 36525.0
val t2 = t * t
val t3 = t2 * t
var l1 = 270.434164 + 481267.8831 * t - 0.001133 * t2 + 0.0000019 * t3
var mSun = 358.475833 + 35999.0498 * t - 0.00015 * t2 - 0.0000033 * t3
var m1 = 296.104608 + 477198.8491 * t + 0.009192 * t2 + 0.0000144 * t3
var d = 350.737486 + 445267.1142 * t - 0.001436 * t2 + 0.0000019 * t3
var ff = 11.250889 + 483202.0251 * t - 0.003211 * t2 - 0.0000003 * t3
val om = (259.183275 - 1934.142 * t + 0.002078 * t2 + 0.0000022 * t3) * DEG2RAD
val correction512 = sin((51.2 + 20.2 * t) * DEG2RAD)
val ss = 0.003964 * sin((346.56 + 132.87 * t - 0.0091731 * t2) * DEG2RAD)
l1 += 0.000233 * correction512 + ss + 0.001964 * sin(om)
mSun -= 0.001778 * correction512
m1 += 0.000817 * correction512 + ss + 0.002541 * sin(om)
d += 0.002011 * correction512 + ss + 0.001964 * sin(om)
ff += ss - 0.024691 * sin(om) - 0.004328 * sin(om + (275.05 - 2.3 * t) * DEG2RAD)
val ex = 1.0 - 0.002495 * t - 0.00000752 * t2
l1 = primeAngle(l1); mSun = primeAngle(mSun); m1 = primeAngle(m1)
d = primeAngle(d); ff = primeAngle(ff)
val mR = mSun * DEG2RAD
val m1R = m1 * DEG2RAD
val dR = d * DEG2RAD
val ffR = ff * DEG2RAD
// Ecliptic longitude — expanded v225 terms
var l = l1 + 6.28875 * sin(m1R) + 1.274018 * sin(2 * dR - m1R) + 0.658309 * sin(2 * dR)
l += 0.213616 * sin(2 * m1R) - ex * 0.185596 * sin(mR) - 0.114336 * sin(2 * ffR)
l += 0.058793 * sin(2 * dR - 2 * m1R) + ex * 0.057212 * sin(2 * dR - mR - m1R) + 0.05332 * sin(2 * dR + m1R)
l += ex * 0.045874 * sin(2 * dR - mR) + ex * 0.041024 * sin(m1R - mR) - 0.034718 * sin(dR)
l -= ex * 0.030465 * sin(mR + m1R) + 0.015326 * sin(2 * dR - 2 * ffR) - 0.012528 * sin(2 * ffR + m1R)
l -= 0.01098 * sin(2 * ffR - m1R) + 0.010674 * sin(4 * dR - m1R) + 0.010034 * sin(3 * m1R)
l += 0.008548 * sin(4 * dR - 2 * m1R) - ex * 0.00791 * sin(mR - m1R + 2 * dR)
l -= ex * 0.006783 * sin(2 * dR + mR)
l += 0.005162 * sin(m1R - dR) + ex * 0.005 * sin(mR + dR) + ex * 0.004049 * sin(m1R - mR + 2 * dR)
l += 0.003996 * sin(2 * m1R + 2 * dR) + 0.003862 * sin(4 * dR) + 0.003665 * sin(2 * dR - 3 * m1R)
l += ex * 0.002695 * sin(2 * m1R - mR) + 0.002602 * sin(m1R - 2 * ffR - 2 * dR)
l += ex * 0.002396 * sin(2 * dR - mR - 2 * m1R)
l -= 0.002349 * sin(m1R + dR) + ex * ex * 0.002249 * sin(2 * dR - 2 * mR)
l -= ex * 0.002125 * sin(2 * m1R + mR)
l -= ex * ex * 0.002079 * sin(2 * mR) + ex * ex * 0.002059 * sin(2 * dR - m1R - 2 * mR)
l -= 0.001773 * sin(m1R + 2 * dR - 2 * ffR)
l += ex * 0.00122 * sin(4 * dR - mR - m1R) - 0.00111 * sin(2 * m1R + 2 * ffR) + 0.000892 * sin(m1R - 3 * dR)
l -= ex * 0.000811 * sin(mR + m1R + 2 * dR) + ex * 0.000761 * sin(4 * dR - mR - 2 * m1R)
l += ex * ex * 0.000717 * sin(m1R - 2 * mR)
l += ex * ex * 0.000704 * sin(m1R - 2 * mR - 2 * dR) + ex * 0.000693 * sin(mR - 2 * m1R + 2 * dR)
l += ex * 0.000598 * sin(2 * dR - mR - 2 * ffR) + 0.00055 * sin(m1R + 4 * dR)
l += 0.000538 * sin(4 * m1R) + ex * 0.000521 * sin(4 * dR - mR) + 0.000486 * sin(2 * m1R - dR)
l -= 0.001595 * sin(2 * ffR + 2 * dR)
// Ecliptic latitude — expanded v225 terms
var b =
5.128189 * sin(ffR) + 0.280606 * sin(m1R + ffR) + 0.277693 * sin(m1R - ffR) + 0.173238 * sin(2 * dR - ffR)
b += 0.055413 * sin(2 * dR + ffR - m1R) + 0.046272 * sin(2 * dR - ffR - m1R) + 0.032573 * sin(2 * dR + ffR)
b += 0.017198 * sin(2 * m1R + ffR) + 9.266999e-03 * sin(2 * dR + m1R - ffR) + 0.008823 * sin(2 * m1R - ffR)
b += ex * 0.008247 * sin(2 * dR - mR - ffR) + 0.004323 * sin(2 * dR - ffR - 2 * m1R)
b += 0.0042 * sin(2 * dR + ffR + m1R)
b += ex * 0.003372 * sin(ffR - mR - 2 * dR) + ex * 0.002472 * sin(2 * dR + ffR - mR - m1R)
b += ex * 0.002222 * sin(2 * dR + ffR - mR)
b += 0.002072 * sin(2 * dR - ffR - mR - m1R) + ex * 0.001877 * sin(ffR - mR + m1R)
b += 0.001828 * sin(4 * dR - ffR - m1R)
b -= ex * 0.001803 * sin(ffR + mR) - 0.00175 * sin(3 * ffR)
b += ex * 0.00157 * sin(m1R - mR - ffR) - 0.001487 * sin(ffR + dR)
b -= ex * 0.001481 * sin(ffR + mR + m1R) + ex * 0.001417 * sin(ffR - mR - m1R)
b += ex * 0.00135 * sin(ffR - mR) + 0.00133 * sin(ffR - dR)
b += 0.001106 * sin(ffR + 3 * m1R) + 0.00102 * sin(4 * dR - ffR) + 0.000833 * sin(ffR + 4 * dR - m1R)
b += 0.000781 * sin(m1R - 3 * ffR) + 0.00067 * sin(ffR + 4 * dR - 2 * m1R)
b += 0.000606 * sin(2 * dR - 3 * ffR)
b += 0.000597 * sin(2 * dR + 2 * m1R - ffR) + ex * 0.000492 * sin(2 * dR + m1R - mR - ffR)
b += 0.00045 * sin(2 * m1R - ffR - 2 * dR)
b += 0.000439 * sin(3 * m1R - ffR) + 0.000423 * sin(ffR + 2 * dR + 2 * m1R)
b += 0.000422 * sin(2 * dR - ffR - 3 * m1R)
b -= ex * 0.000367 * sin(mR + ffR + 2 * dR - m1R) - ex * 0.000353 * sin(mR + ffR + 2 * dR)
b += 0.000331 * sin(ffR + 4 * dR)
b += ex * 0.000317 * sin(2 * dR + ffR - mR + m1R) + ex * ex * 0.000306 * sin(2 * dR - 2 * mR - ffR)
b -= 0.000283 * sin(m1R + 3 * ffR)
val w1 = 0.0004664 * cos(om)
val w2 = 0.0000754 * cos(om + (275.05 - 2.3 * t) * DEG2RAD)
val bt = b * (1.0 - w1 - w2)
// Parallax — expanded v225 terms
var p =
0.950724 + 0.051818 * cos(m1R) + 0.009531 * cos(2 * dR - m1R) + 0.007843 * cos(2 * dR) + 0.002824 * cos(2 * m1R)
p += 0.000857 * cos(2 * dR + m1R) + ex * 0.000533 * cos(2 * dR - mR) + ex * 0.000401 * cos(2 * dR - mR - m1R)
p += 0.000173 * cos(3 * m1R) + 0.000167 * cos(4 * dR - m1R) - ex * 0.000111 * cos(mR)
p += 0.000103 * cos(4 * dR - 2 * m1R) - 0.000084 * cos(2 * m1R - 2 * dR) - ex * 0.000083 * cos(2 * dR + mR)
p += 0.000079 * cos(2 * dR + 2 * m1R)
p += 0.000072 * cos(4 * dR) + ex * 0.000064 * cos(2 * dR - mR + m1R) - ex * 0.000063 * cos(2 * dR + mR - m1R)
p += ex * 0.000041 * cos(mR + dR) + ex * 0.000035 * cos(2 * m1R - mR) - 0.000033 * cos(3 * m1R - 2 * dR)
p -= 0.00003 * cos(m1R + dR) - 0.000029 * cos(2 * ffR - 2 * dR) - ex * 0.000029 * cos(2 * m1R + mR)
p += ex * ex * 0.000026 * cos(2 * dR - 2 * mR) - 0.000023 * cos(2 * ffR - 2 * dR + m1R)
p += ex * 0.000019 * cos(4 * dR - mR - m1R)
val bRad = bt * DEG2RAD
val lm = l * DEG2RAD
val moonDx = 3.0 / (PI * p)
// Ecliptic → equatorial
val z = (jd - 2415020.5) / 365.2422
val ob = (23.452294 - (0.46845 * z + 5.9e-07 * z * z) / 3600.0).toRadians()
val dec = asin(sin(bRad) * cos(ob) + cos(bRad) * sin(ob) * sin(lm))
var ra = acos(cos(bRad) * cos(lm) / cos(dec)); if (lm > PI) ra = TWO_PI - ra
val n = observer.latitude * DEG2RAD
t = (jd - 2451545.0) / 36525.0
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
while (teg > 360.0) teg -= 360.0
// LST = GMST + east longitude (positive east convention)
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
val h = th - ra
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
// Moon radial velocity approximation (from "Amateur Radio Software", GM4ANB, RSGB 1985)
val mm = fixAngle(1.319238 + daynum * 0.228027135)
val radT2 = 0.10976
val radT1 = mm + radT2 * sin(mm)
var dv = 0.01255 * moonDx * moonDx * sin(radT1) * (1.0 + radT2 * cos(mm))
dv *= 4449.0
val earthR = 6378.0
val moonDist = 384401.0
val radT3 = earthR * moonDist * (cos(dec) * cos(n) * sin(h)) /
sqrt(moonDist * moonDist - moonDist * earthR * sin(el))
val moonDv = dv + radT3 * 0.0753125
val moonRa = ra / DEG2RAD
var moonGha = teg - moonRa
if (moonGha < 0.0) moonGha += 360.0
return MoonPosition(
azimuth = azVal / DEG2RAD,
elevation = el / DEG2RAD,
rightAscension = moonRa,
declination = dec / DEG2RAD,
gha = moonGha,
angularDiameter = moonDx,
radialVelocity = moonDv
)
}
// ── Satellite visibility ──
/**
* Classify satellite visibility given its eclipse state and the Sun's elevation
* at the observer's location.
*
* @param isEclipsed whether the satellite is in Earth's shadow
* @param sunElevationDeg Sun elevation at observer in degrees
* @param satElevationDeg satellite elevation at observer in degrees (must be >= 0)
*/
fun classifyVisibility(
isEclipsed: Boolean,
sunElevationDeg: Double,
satElevationDeg: Double
): SatVisibility {
if (isEclipsed) return SatVisibility.ECLIPSED
return if (sunElevationDeg <= -12.0 && satElevationDeg >= 0.0) SatVisibility.VISIBLE
else SatVisibility.DAYLIGHT
}
// ── Orbital metadata ──
/**
* Compute orbital metadata for a satellite at its current position.
*
* @param altitudeKm satellite altitude in km
* @param meanMotion revolutions per day from TLE
* @param bstar drag term from TLE
* @param meanAnomaly mean anomaly at epoch (radians)
* @param revNumAtEpoch revolution number at TLE epoch
* @param ageDays days since TLE epoch (julUTC - julEpoch)
* @param phase orbital phase in radians (from SGP4/SDP4 output)
* @param satPosECI satellite ECI position [x, y, z]
* @param satVelECI satellite ECI velocity [vx, vy, vz]
* @param sunPosECI sun ECI position [x, y, z]
*/
fun computeOrbitalMetadata(
altitudeKm: Double,
meanMotion: Double,
bstar: Double,
meanAnomaly: Double,
revNumAtEpoch: Int,
ageDays: Double,
phase: Double,
satPosECI: DoubleArray,
satVelECI: DoubleArray,
sunPosECI: DoubleArray
): OrbitalMetadata {
// Footprint diameter (km)
val footprint = 12756.33 * acos(EARTH_RADIUS / (EARTH_RADIUS + altitudeKm))
// Orbit number
val xmnpda = 1.44E3
val orbitNum = floor(
(meanMotion * xmnpda / TWO_PI + ageDays * bstar) * ageDays + meanAnomaly / TWO_PI
).toLong() + revNumAtEpoch
// Beta angle: angle between orbital plane and Sun direction
// Orbital plane normal = cross(pos, vel)
val nx = satPosECI[1] * satVelECI[2] - satPosECI[2] * satVelECI[1]
val ny = satPosECI[2] * satVelECI[0] - satPosECI[0] * satVelECI[2]
val nz = satPosECI[0] * satVelECI[1] - satPosECI[1] * satVelECI[0]
val nMag = sqrt(nx * nx + ny * ny + nz * nz)
val sMag = sqrt(sunPosECI[0] * sunPosECI[0] + sunPosECI[1] * sunPosECI[1] + sunPosECI[2] * sunPosECI[2])
val dotNS = nx * sunPosECI[0] + ny * sunPosECI[1] + nz * sunPosECI[2]
val betaAngle = if (nMag > 0 && sMag > 0) {
(PI / 2.0 - acos(dotNS / (nMag * sMag))).toDegrees()
} else 0.0
// Phase (0-256 scale, matching PREDICT convention)
val orbitalPhase = 256.0 * (phase / TWO_PI)
return OrbitalMetadata(footprint, orbitNum, betaAngle, orbitalPhase)
}
// ── Satellite status checks ──
/** Check if a satellite is geostationary (mean motion ≈ 1.0027 rev/day). */
fun isGeostationary(meanMotion: Double): Boolean = abs(meanMotion - 1.0027) < 0.0002
/**
* Check if a satellite has likely decayed based on drag and time since epoch.
*
* @param meanMotion revolutions per day
* @param drag first derivative of mean motion / 2 (from TLE line 1)
* @param epochDaynum TLE epoch as daynum (days since 31Dec79)
* @param currentDaynum current time as daynum
*/
fun hasDecayed(meanMotion: Double, drag: Double, epochDaynum: Double, currentDaynum: Double): Boolean {
return epochDaynum + ((16.666666 - meanMotion) / (10.0 * abs(drag))) < currentDaynum
}
// ── Rise/Set finding ──
/** Rise and set times for a celestial body. */
data class RiseSetTimes(
val riseTimeMillis: Long, // 0 if not found
val setTimeMillis: Long // 0 if not found
)
/**
* Find the next sunrise and sunset times from [startMillis] for [observer].
* Uses elevation threshold of -0.8333° to match the standard civil definition:
* upper limb on geometric horizon with standard atmospheric refraction (~0.57°)
* and solar semidiameter (~0.27°) corrections applied, matching USNO/timeanddate.com.
*/
fun findSunRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
// Standard civil threshold: center elevation when upper limb meets geometric horizon
// -0.8333° = standard refraction (~0.5667°) + solar semidiameter (~0.2667°)
val threshold = 0.8333
var daynum = millisToDaynum(startMillis)
var sunPos = getSunPosition(observer, daynumToMillis(daynum))
// Phase 1: if sun is above threshold, fast-forward to well past sunset into night
if (sunPos.elevation > -threshold) {
var guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) {
daynum += 0.008 // fixed ~11.5 min steps past the setting sun
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Now advance until sun is clearly below minimum (deep night)
guard = 0
while (sunPos.elevation > -12.0 && guard++ < 500) {
daynum += 0.02
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
// Phase 2: advance until sun starts rising toward threshold (elevation increasing)
var guard = 0
while (sunPos.elevation < -threshold && guard++ < 500) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 3: converge symmetrically on elevation = -threshold (sunrise)
var sunrise = 0.0
guard = 0
while (sunrise == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunrise = daynum
} else {
daynum -= 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunrise == 0.0) sunrise = daynum
// 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) {
daynum += 0.008
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
// Phase 5: converge symmetrically on elevation = -threshold (sunset)
var sunset = 0.0
guard = 0
while (sunset == 0.0 && guard++ < 200) {
val delta = sunPos.elevation + threshold
if (abs(delta) < 0.01) {
sunset = daynum
} else {
daynum += 0.004 * delta
sunPos = getSunPosition(observer, daynumToMillis(daynum))
}
}
if (sunset == 0.0) sunset = daynum
return RiseSetTimes(daynumToMillis(sunrise), daynumToMillis(sunset))
}
/**
* Find the next moonrise and moonset times from [startMillis] for [observer].
* Uses the adaptive iteration from PREDICT v2.2.5's PredictMoon().
*/
fun findMoonRiseSet(observer: GeoPos, startMillis: Long): RiseSetTimes {
var daynum = millisToDaynum(startMillis)
var moonPos = getMoonPosition(observer, daynumToMillis(daynum))
// If moon is already up, move forward until it sets
var guard = 0
if (moonPos.elevation > 0) {
while (moonPos.elevation > 0 && guard++ < 1000) {
daynum += 0.004 * sin(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
daynum += 0.4
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
// Find moonrise
var moonrise = 0.0
guard = 0
while (moonrise == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonrise = daynum
} else {
daynum -= 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonrise == 0.0) moonrise = daynum
// Find moonset from moonrise
daynum = moonrise
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
guard = 0
while (moonPos.elevation > -1 && guard++ < 1000) {
daynum += 0.04 * cos(DEG2RAD * (moonPos.elevation + 0.5))
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
var moonset = 0.0
guard = 0
while (moonset == 0.0 && guard++ < 1000) {
if (abs(moonPos.elevation) < 0.03) {
moonset = daynum
} else {
daynum += 0.004 * moonPos.elevation
moonPos = getMoonPosition(observer, daynumToMillis(daynum))
}
}
if (moonset == 0.0) moonset = daynum
return RiseSetTimes(daynumToMillis(moonrise), daynumToMillis(moonset))
}
// ── Visual magnitude estimation ──
/**
* Estimate the apparent visual magnitude of a satellite.
*
* Uses the standard formula from McCants/Heavens-Above:
* apparentMag = stdMag + 5 * log10(range / 1000) - 15 * log10(cos(phaseAngle / 2))
*
* @param rangeKm slant range from observer to satellite in km
* @param phaseAngleDeg Sun-satellite-observer angle in degrees
* @param stdMag intrinsic/standard magnitude (default 4.0)
* @return estimated apparent visual magnitude
*/
fun estimateVisualMagnitude(rangeKm: Double, phaseAngleDeg: Double, stdMag: Double = 4.0): Double {
if (rangeKm <= 0) return stdMag
val halfPhaseRad = phaseAngleDeg.toRadians() / 2.0
val cosHalfPhase = cos(halfPhaseRad)
val phaseTerm = if (cosHalfPhase > 1e-6) -15.0 * log10(cosHalfPhase) else 99.0
return stdMag + 5.0 * log10(rangeKm / 1000.0) + phaseTerm
}
/**
* Compute the phase angle (Sun-satellite-observer) in degrees.
*
* @param satPosECI satellite ECI position [x, y, z] in km
* @param sunPosECI sun ECI position [x, y, z] in km
* @param obsPosECI observer ECI position [x, y, z] in km
* @return phase angle in degrees (0 = fully illuminated face toward observer)
*/
fun computePhaseAngle(satPosECI: DoubleArray, sunPosECI: DoubleArray, obsPosECI: DoubleArray): Double {
val toSunX = sunPosECI[0] - satPosECI[0]
val toSunY = sunPosECI[1] - satPosECI[1]
val toSunZ = sunPosECI[2] - satPosECI[2]
val toObsX = obsPosECI[0] - satPosECI[0]
val toObsY = obsPosECI[1] - satPosECI[1]
val toObsZ = obsPosECI[2] - satPosECI[2]
val dot = toSunX * toObsX + toSunY * toObsY + toSunZ * toObsZ
val magSun = sqrt(toSunX * toSunX + toSunY * toSunY + toSunZ * toSunZ)
val magObs = sqrt(toObsX * toObsX + toObsY * toObsY + toObsZ * toObsZ)
if (magSun == 0.0 || magObs == 0.0) return 90.0
val cosAngle = (dot / (magSun * magObs)).coerceIn(-1.0, 1.0)
return acos(cosAngle).toDegrees()
}
// ── Doppler ──
/**
* Compute Doppler shift for a given base frequency and range rate.
*
* @param frequencyHz base frequency in Hz
* @param rangeRateKmS range rate in km/s (negative = approaching)
* @return shifted frequency in Hz
*/
fun dopplerShift(frequencyHz: Double, rangeRateKmS: Double): Double {
return frequencyHz * (299792.458 - rangeRateKmS) / 299792.458
}
// ── Internal helpers ──
private fun observerGeodetic(pos: GeoPos): DoubleArray {
// [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
// LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
}
/**
* Convert az/el observation to Right Ascension / Declination.
* Returns [ra_rad, dec_rad].
* Based on Calculate_RADec() from PREDICT v2.2.5 (Escobal method).
*/
private fun calculateRADec(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray
): DoubleArray {
val obsSet = computeObsAngles(julUtc, targetPos, targetVel, obsGeo)
val az = obsSet[0]
val el = obsSet[1]
val phi = obsGeo[0]
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val sinPhi = sin(phi)
val cosPhi = cos(phi)
val lxh = -cos(az) * cos(el)
val lyh = sin(az) * cos(el)
val lzh = sin(el)
val sx = sinPhi * cosTheta
val ex2 = -sinTheta
val zx = cosTheta * cosPhi
val sy = sinPhi * sinTheta
val zy = sinTheta * cosPhi
val sz = -cosPhi
val lx = sx * lxh + ex2 * lyh + zx * lzh
val ly = sy * lxh + cosTheta * lyh + zy * lzh
val lz = sz * lxh + 0.0 * lyh + sinPhi * lzh
val dec = asin(lz)
val cosDelta = sqrt(1.0 - lz * lz)
val sinAlpha = ly / cosDelta
val cosAlpha = lx / cosDelta
val ra = mod2PI(atan2(sinAlpha, cosAlpha))
return doubleArrayOf(ra, dec)
}
/**
* Compute observer look-angles (az, el, range, rangeRate) to a target at ECI position.
* Returns [azimuth_rad, elevation_rad, range_km, rangeRate_km/s].
* Azimuth is north-referenced (0=N, π/2=E), matching OrbitalObject's convention.
*/
private fun computeObsAngles(
julUtc: Double,
targetPos: DoubleArray,
targetVel: DoubleArray,
obsGeo: DoubleArray // [lat_rad, lon_rad, alt_km]
): DoubleArray {
val theta = mod2PI(thetaGJD(julUtc) + obsGeo[1])
val c = 1.0 / sqrt(1 + FLAT_FACT * (FLAT_FACT - 2) * sin(obsGeo[0]).pow(2))
val sq = (1 - FLAT_FACT).pow(2) * c
val achcp = (EARTH_RADIUS * c + obsGeo[2]) * cos(obsGeo[0])
val ox = achcp * cos(theta)
val oy = achcp * sin(theta)
val oz = (EARTH_RADIUS * sq + obsGeo[2]) * sin(obsGeo[0])
val ovx = -MFACTOR * oy
val ovy = MFACTOR * ox
val rx = targetPos[0] - ox
val ry = targetPos[1] - oy
val rz = targetPos[2] - oz
val rMag = sqrt(rx * rx + ry * ry + rz * rz)
val rvx = targetVel[0] - ovx
val rvy = targetVel[1] - ovy
val rvz = targetVel[2]
val sinLat = sin(obsGeo[0])
val cosLat = cos(obsGeo[0])
val sinTheta = sin(theta)
val cosTheta = cos(theta)
val topS = sinLat * cosTheta * rx + sinLat * sinTheta * ry - cosLat * rz
val topE = -sinTheta * rx + cosTheta * ry
val topZ = cosLat * cosTheta * rx + cosLat * sinTheta * ry + sinLat * rz
// Match north-based convention (0=N, 90=E) used by OrbitalObject.calculateObs
// Must use atan(-topE / topS) not atan2(-topE, topS) — they differ in quadrant handling
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
val el = asin(topZ / rMag)
val rangeRate = (rx * rvx + ry * rvy + rz * rvz) / rMag
return doubleArrayOf(azim, el, rMag, rangeRate)
}
private const val MFACTOR = 7.292115E-5
private fun primeAngle(x: Double) = x - 360.0 * floor(x / 360.0)
private fun fixAngle(x: Double): Double {
var a = x; while (a > TWO_PI) a -= TWO_PI; return a
}
}
@@ -21,6 +21,7 @@ const val ASTRONOMICAL_UNIT = 1.49597870691E8
const val DEG2RAD = 0.017453292519943295 const val DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232 const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137 const val EARTH_RADIUS = 6378.137
const val EARTH_ROT_PER_SID_DAY = 1.00273790934
const val EPSILON = 1.0E-12 const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3 const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6 const val J3_HARMONIC = -2.53881E-6
@@ -27,7 +27,8 @@ data class OrbitalData(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, val catnum: Int,
val bstar: Double val bstar: Double,
val ndot: Double = 0.0
) { ) {
val xincl: Double = incl * DEG2RAD val xincl: Double = incl * DEG2RAD
val xnodeo: Double = raan * DEG2RAD val xnodeo: Double = raan * DEG2RAD
@@ -37,4 +38,29 @@ data class OrbitalData(
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
}
private fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
} }
@@ -0,0 +1,141 @@
/*
* 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 kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.sin
import kotlin.math.sqrt
// ── Shared orbital math utilities ──
// Used by both CelestialComputer (sun/moon/celestial) and OrbitalObject (SGP4/SDP4).
// Package-internal — not part of the public API.
/**
* Greenwich Mean Sidereal Time from Julian Date, in radians [0, 2π).
* Identical algorithm used in PREDICT v2.2.5 for both solar and satellite calculations.
*/
internal fun thetaGJD(jd: Double): Double {
val ut = fraction(jd + 0.5)
val aJD = jd - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * EARTH_ROT_PER_SID_DAY * ut, SEC_PER_DAY)
return TWO_PI * gmst / SEC_PER_DAY
}
/** Fractional part of [arg]. */
internal fun fraction(arg: Double): Double = arg - floor(arg)
/** Modulo: returns [arg1] mod [arg2], result always in [0, arg2). */
internal fun modulus(arg1: Double, arg2: Double): Double {
var r = arg1
val i = floor(r / arg2).toInt()
r -= i * arg2
if (r < 0.0) r += arg2
return r
}
/** Reduce [value] to [0, 2π). */
internal fun mod2PI(value: Double): Double {
var r = value
val i = (r / TWO_PI).toInt()
r -= i * TWO_PI
if (r < 0.0) r += TWO_PI
return r
}
/**
* Delta-ET: difference between Universal Time and Ephemeris Time (seconds).
* Based on least-squares fit from 1950 to 1991 (PREDICT v2.2.5).
*/
internal fun deltaET(year: Double): Double =
26.465 + 0.747622 * (year - 1950) + 1.886913 * sin(TWO_PI * (year - 1975) / 33)
/**
* Convert Unix epoch milliseconds to daynum (days since 31 Dec 1979 00:00:00 UTC).
*/
internal fun millisToDaynum(timeMillis: Long): Double =
(timeMillis - 315446400000L) / 86400000.0
/** Convert daynum back to Unix epoch milliseconds. */
internal fun daynumToMillis(daynum: Double): Long =
((daynum + 3651.0) * 86400000.0).toLong()
/**
* Compute the Sun's ECI position vector at [julUtc] (Julian UTC).
* Returns [x, y, z, magnitude] in km.
* Based on Calculate_Solar_Position() / FindSun() from PREDICT v2.2.5.
*/
internal fun solarPositionECI(julUtc: Double): DoubleArray {
val mjd = julUtc - 2415020.0
val year = 1900 + mjd / 365.25
val t = (mjd + deltaET(year) / SEC_PER_DAY) / 36525.0
val mDeg = mod360(358.47583 + mod360(35999.04975 * t) - (0.000150 + 0.0000033 * t) * t * t)
val m = mDeg * DEG2RAD
val lDeg = mod360(279.69668 + mod360(36000.76892 * t) + 0.0003025 * t * t)
val l = lDeg * DEG2RAD
val e = 0.01675104 - (0.0000418 + 0.000000126 * t) * t
val cDeg = (1.919460 - (0.004789 + 0.000014 * t) * t) * sin(m) +
(0.020094 - 0.000100 * t) * sin(2 * m) + 0.000293 * sin(3 * m)
val c = cDeg * DEG2RAD
val oDeg = mod360(259.18 - 1934.142 * t)
val o = oDeg * DEG2RAD
val lsa = mod2PI(l + c - (0.00569 - 0.00479 * sin(o)) * DEG2RAD)
val nu = mod2PI(m + c)
var r = 1.0000002 * (1.0 - e * e) / (1.0 + e * cos(nu))
val epsDeg = 23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * t) * t) * t + 0.00256 * cos(o)
val eps = epsDeg * DEG2RAD
r *= ASTRONOMICAL_UNIT
return doubleArrayOf(r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps), r)
}
/**
* Convert ECI position [eciPos] = [x, y, z] (km) to geodetic [lat_rad, lon_rad, alt_km].
* Based on Calculate_LatLonAlt() from PREDICT v2.2.5.
*/
internal fun eciToGeodetic(julUtc: Double, eciPos: DoubleArray): DoubleArray {
val thetaPos = atan2(eciPos[1], eciPos[0])
val lon = mod2PI(thetaPos - thetaGJD(julUtc))
val r = sqrt(eciPos[0] * eciPos[0] + eciPos[1] * eciPos[1])
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
var lat = atan2(eciPos[2], r)
var phi: Double
var c: Double
var i = 0
do {
phi = lat
c = 1.0 / sqrt(1.0 - e2 * sin(phi) * sin(phi))
lat = atan2(eciPos[2] + EARTH_RADIUS * c * e2 * sin(phi), r)
} while (i++ < 10 && abs(lat - phi) >= 1E-10)
val alt = r / cos(lat) - EARTH_RADIUS * c
if (lat > PI_2) lat -= TWO_PI
return doubleArrayOf(lat, lon, alt)
}
// Private helpers
private fun mod360(x: Double): Double {
var r = x
val i = (r / 360.0).toInt()
r -= i * 360.0
if (r < 0.0) r += 360.0
return r
}
@@ -314,14 +314,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return 1.0 / value return 1.0 / value
} }
// Calculates the modulus of 2 * PI // Delegates to package-level mod2PI in OrbitalMath.kt
internal fun mod2PI(value: Double): Double { internal fun mod2PI(value: Double): Double = com.rtbishop.look4sat.core.domain.predict.mod2PI(value)
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Solves Keplers' Equation // Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) { internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
@@ -423,19 +417,8 @@ abstract class OrbitalObject(val data: OrbitalData) {
return acos(dot(v1, v2) / (v1.w * v2.w)) return acos(dot(v1, v2) / (v1.w * v2.w))
} }
/** // Delegates to package-level deltaET in OrbitalMath.kt
* The function Delta_ET has been added to allow calculations on the private fun deltaEt(year: Double): Double = deltaET(year)
* position of the sun. It provides the difference between UT (approximately
* the same as UTC) and ET (now referred to as TDT) This function is based
* on the least squares fit of data from 1950 to 1991 and will need to be
* updated periodically.
*
* Values determined using data from 1950-1991 in the 1990 Astronomical
* Almanac. See DELTA_ET.WQ1 for details.
*/
private fun deltaEt(year: Double): Double {
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
}
private fun radians(degrees: Double): Double { private fun radians(degrees: Double): Double {
return degrees * DEG2RAD return degrees * DEG2RAD
@@ -446,23 +429,13 @@ abstract class OrbitalObject(val data: OrbitalData) {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
} }
// Returns fractional part of double argument
private fun fraction(arg: Double): Double {
return arg - floor(arg)
}
// Calculates scalar magnitude of a vector4 argument // Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) { private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z)) v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
} }
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double { private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double =
var returnValue = arg1 com.rtbishop.look4sat.core.domain.predict.modulus(arg1, arg2)
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
// Multiplies the vector v1 by the scalar k // Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) { private fun scaleVector(k: Double, v: Vector4) {
@@ -470,13 +443,6 @@ abstract class OrbitalObject(val data: OrbitalData) {
magnitude(v) magnitude(v)
} }
private fun thetaGJD(theJD: Double): Double { // Delegates to package-level thetaGJD in OrbitalMath.kt
val earthRotPerSidDay = 1.00273790934 private fun thetaGJD(theJD: Double): Double = com.rtbishop.look4sat.core.domain.predict.thetaGJD(theJD)
val ut = fraction(theJD + 0.5)
val aJD = theJD - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
return TWO_PI * gmst / SEC_PER_DAY
}
} }
@@ -25,9 +25,10 @@ data class OrbitalPass(
val altitude: Int = 1000, val altitude: Int = 1000,
val maxElevation: Double = 75.0, val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject, val orbitalObject: OrbitalObject,
val progress: Float = 0.0f val progress: Float = 0.0f,
val hasDecayed: Boolean = false
) { ) {
val catNum: Int = orbitalObject.data.catnum val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
} }
@@ -63,10 +63,8 @@ data class OrbitalPos(
val sinBeta = sin(beta) val sinBeta = sin(beta)
for (azimuth in 0..720) { for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD val rads = azimuth * DEG2RAD
val sinRads = sin(rads) val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads))
val cosRads = cos(rads) val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads)
val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * sin(lat))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG)) rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
} }
return rangeCirclePoints return rangeCirclePoints
@@ -0,0 +1,23 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Cached AMSAT status page for the current foreground session, if any. */
fun getCachedStatus(): SatStatusPage?
/** Fetch and parse the AMSAT status page; null on failure. */
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
/** Warm the foreground-session cache without forcing a network reload. */
suspend fun prefetchStatus()
/** Clear the foreground-session status cache. */
fun clearStatusCache()
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
}
@@ -18,8 +18,8 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
interface IDatabaseRepo { interface IDatabaseRepo {
suspend fun updateTLEFromFile(uri: String) suspend fun updateTLEFromFile(uri: String): Int
suspend fun updateTransceiversFromFile(uri: String) suspend fun updateTransceiversFromFile(uri: String): Int
suspend fun updateFromRemote() suspend fun updateFromRemote()
suspend fun clearAllData() suspend fun clearAllData()
} }
@@ -0,0 +1,72 @@
/*
* 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.repository
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
interface ILoTWRepository {
/**
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
* Returns the 4-char grid set with an explicit failure cause on error.
*/
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
/**
* Same report, but keeps the per-QSO detail of every confirmed satellite
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
* gridsquare. Failure cause is kept explicit.
*
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
* date for a full report, or the last sync date for an
* incremental pull. Empty falls back to a full report.
* @param onProgress streamed download progress (phase + bytes + estimate),
* invoked from the IO dispatcher; may be called on every
* read chunk.
*/
suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String,
since: String = "",
onProgress: (LoTWProgress) -> Unit = {}
): LoTWResult
}
/** Streamed progress of a LoTW report download. */
data class LoTWProgress(
val phase: LoTWPhase,
/** Bytes read so far (uncompressed body). */
val bytesRead: Long = 0,
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
val expectedBytes: Long = 0,
/** Smoothed download speed in bytes/second; 0 while unknown. */
val speedBps: Long = 0,
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
val qsoCount: Long = 0
) {
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
val fraction: Float
get() = if (expectedBytes > 0 && bytesRead > 0)
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
/** Estimated seconds remaining; 0 while size or speed is unknown. */
val remainingSeconds: Long
get() = if (expectedBytes > 0 && speedBps > 0)
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
}
enum class LoTWPhase { Connecting, Downloading }
@@ -1,8 +1,28 @@
/*
* 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.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
interface IMainContainer { interface IMainContainer {
val appScope: CoroutineScope val appScope: CoroutineScope
@@ -10,6 +30,13 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter fun provideBluetoothReporter(): IReporter
@@ -17,9 +44,29 @@ interface IMainContainer {
fun provideSensorsRepo(): ISensorsRepo fun provideSensorsRepo(): ISensorsRepo
fun provideTxRadioController(): IRadioController fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController fun provideRxRadioController(): IRadioController
val radioTrackingService: IRadioTrackingService fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
} }
data class MutualPassData(
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val trackSamples: List<TrackSampleData> = emptyList(),
val startTime: Long = 0L,
val endTime: Long = 0L,
val maxElev: Double = 10.0,
val labelA: String = "你",
val labelB: String = "友台"
)
/** Minimal track sample for cross-module sharing (angles in degrees). */
data class TrackSampleData(
val time: Long = 0L,
val azimuthA: Double,
val elevationA: Double,
val azimuthB: Double,
val elevationB: Double
)
interface IContainerProvider { interface IContainerProvider {
fun getMainContainer(): IMainContainer fun getMainContainer(): IMainContainer
} }
@@ -38,4 +38,51 @@ interface IRadioController {
suspend fun pttOn(): Boolean suspend fun pttOn(): Boolean
suspend fun pttOff(): Boolean suspend fun pttOff(): Boolean
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
/**
* Select the band matching [frequencyHz] via the band stacking register.
* Must be called before [setFrequency] and [setMode] when first tracking.
* Default: no-op (Yaesu radios auto-switch band via frequency).
*/
suspend fun setBand(frequencyHz: Long): Boolean = false
/**
* Select the active VFO.
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
*/
suspend fun setVfo(vfoA: Boolean): Boolean = false
/**
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
* Default: not supported.
*/
suspend fun setSplitMode(enabled: Boolean): Boolean = false
/**
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [setFrequency].
*/
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
/**
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
* Default: delegates to [setWorkingFrequency].
*/
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
/**
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
* Default: delegates to [readFrequencyAndMode].
*/
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
/**
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
* Used for tuning detection in split mode.
* Default: delegates to [readWorkingFrequency].
*/
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
} }
@@ -34,11 +34,25 @@ interface ISatelliteRepo {
/** Whether the repo is currently calculating passes. */ /** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean> val isCalculating: StateFlow<Boolean>
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
val selectedPass: StateFlow<Pair<Int, Long>>
/** Set the currently selected pass. */
fun selectPass(catNum: Int, aosTime: Long)
/** Load satellite objects from DB based on the current selection. */ /** Load satellite objects from DB based on the current selection. */
suspend fun initRepository() suspend fun initRepository()
/** Recalculate passes with the given filter parameters. */ /** Recalculate passes with the given filter parameters. */
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) suspend fun calculatePasses(
time: Long,
hoursAhead: Int,
minElevation: Double,
aosStartMinute: Int,
aosEndMinute: Int,
invertAosTimeWindow: Boolean,
modes: List<String>
)
/** Get the current position of a single satellite. */ /** Get the current position of a single satellite. */
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
enum class CompassAccuracy {
UNAVAILABLE,
UNRELIABLE,
LOW,
MEDIUM,
HIGH
}
interface ISensorsRepo { interface ISensorsRepo {
val orientation: StateFlow<Pair<Float, Float>> val sensorData: StateFlow<Pair<Float, Float>>
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
val compassAccuracy: StateFlow<CompassAccuracy>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor() fun enableSensor()
fun disableSensor() fun disableSensor()
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -73,8 +74,47 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings) fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion //endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion //endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
//region # Wavelog worked-grids settings
val wavelogSettings: StateFlow<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
//endregion
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
fun getLastLotwSyncDate(): String
fun setLastLotwSyncDate(date: String)
/** Callsign of the last successful LoTW sync (empty when never synced). */
fun getLastLotwSyncCallsign(): String
fun setLastLotwSyncCallsign(callsign: String)
//endregion
} }
@@ -0,0 +1,12 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.LatestRelease
import java.io.File
interface IUpdateRepository {
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
suspend fun getLatestRelease(): LatestRelease?
/** Download the release APK from [url] into [dest]; true on success. */
suspend fun downloadApk(url: String, dest: File): Boolean
}
@@ -0,0 +1,28 @@
/*
* 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.repository
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
interface IWavelogRepository {
/**
* Fetch worked grids for the configured Wavelog URL/token.
* Returns the grid set, or null on any failure.
*/
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
}
@@ -0,0 +1,169 @@
/*
* 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.repository
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
* and the automatic sync on app start (MainApplication), so the two paths can
* never drift apart:
*
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
* deduped by call + QSO time).
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
*/
/**
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
* gate granularity.
*/
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyyMMdd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/**
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
* ignored by LoTW, which falls back to its own system-supplied default),
* and it keeps enough precision to display the last-sync time like the
* ephemeris update time.
*/
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
fun lotwCursorDate(cursor: String): String = when {
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
else -> cursor.take(8)
}
/** qso_qslsince value for the API: passes the full timestamp through and
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
fun lotwCursorApi(cursor: String): String = when {
cursor.isBlank() -> ""
cursor.length >= 10 && cursor[4] == '-' -> cursor
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
}
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
fun lotwCursorEpochMs(cursor: String): Long = try {
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
SimpleDateFormat(pattern, Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.parse(cursor)?.time ?: 0L
} catch (_: Exception) {
0L
}
/**
* Resolves the effective sync mode for a given request:
* - an explicit [LoTWSyncMode.Full] request is always honored;
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
* matches the last sync — a first sync, an unknown/empty record or a
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
* another account linger.
* With [requested] == null (automatic sync) it picks incremental whenever
* possible and full otherwise.
*/
fun resolveLoTWSyncMode(
lastSyncCallsign: String,
callsign: String,
requested: LoTWSyncMode?
): LoTWSyncMode {
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
}
/**
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
* least one prior manual sync (the first sync stays manual/full), and not
* already synced today (ARRL limits report pulls, ~once a day per account).
*/
fun shouldAutoSyncLoTW(
isConfigured: Boolean,
autoLotwSyncEnabled: Boolean,
lastSyncDate: String,
today: String
): Boolean {
val lastDate = lotwCursorDate(lastSyncDate)
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
}
/**
* Applies a successful LoTW report to the stored grid data and advances the
* sync cursor so the next incremental pull asks LoTW for only the
* confirmations since today. Persistence runs on the IO dispatcher — building
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
* thread, and for large accounts (multi-MB JSON) that froze the main thread
* after a manual sync ('bar done but app stuck').
*
* @return the resulting number of worked grids.
*/
suspend fun applyLoTWGridResult(
settingsRepo: ISettingsRepo,
result: LoTWResult.Success,
mode: LoTWSyncMode,
callsign: String,
now: Long = System.currentTimeMillis()
): Int {
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
settingsRepo.setLastLotwSyncCallsign(callsign)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
return mergedGrids.size
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
fun mergeGridQsos(
existing: Map<String, List<GridQso>>,
fresh: Map<String, List<GridQso>>
): Map<String, List<GridQso>> {
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
fresh.forEach { (grid, list) ->
val target = merged.getOrPut(grid) { mutableListOf() }
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
list.forEach { qso ->
if (known.add(qso.call to qso.epochMs)) target.add(qso)
}
}
return merged
}
@@ -0,0 +1,51 @@
/*
* 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.repository
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
sealed class LoTWResult {
/** Report downloaded and parsed successfully. */
data class Success(
val grids: Set<String>,
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
/** Distinct 4-char gridsquares the account itself operated from
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
val roamedGrids: Set<String> = emptySet()
) : LoTWResult()
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
data object BadCredentials : LoTWResult()
/**
* Report endpoint refused the request without a usable error page. LoTW
* limits downloads to one in progress per user id and flags accounts that
* pull the full report too often; the server answers those with a bare
* non-ADIF body (no <eoh>).
*/
data object RateLimited : LoTWResult()
/** Connect/read timed out — typically a slow route to the ARRL servers. */
data object Timeout : LoTWResult()
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
@@ -31,6 +31,9 @@ interface ILocalSource {
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>) suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
/** Delete all non-custom transceivers, keeping manually imported ones. */
suspend fun deleteManagedRadios()
suspend fun deleteRadios() suspend fun deleteRadios()
} }
@@ -19,7 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream import java.io.InputStream
/** Result of a network download: the HTTP status code and the response body stream. */
data class NetworkResult(val code: Int, val stream: InputStream?) {
companion object {
/** Code used when a request fails before any HTTP response is received. */
const val CONNECTION_ERROR = -1
}
}
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
} }
@@ -18,9 +18,11 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf( val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv", "Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv", "Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv", "Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
@@ -36,7 +38,7 @@ object Sources {
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv", "OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv", "Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv", "Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv", "CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv", "Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv", "Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv", "Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
@@ -46,7 +48,11 @@ object Sources {
"Amsat" to "https://amsat.org/tle/current/nasabare.txt", "Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip", "Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip", "McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt", "ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter "Other" to "" // key for sats filter
) )
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json"
)
} }
@@ -0,0 +1,816 @@
/*
* 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.sstv
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.round
import kotlin.math.sqrt
class SstvFrame(
val scopePixels: IntArray?,
val scopeWidth: Int,
val scopeHeight: Int,
val imagePixels: IntArray?,
val imageWidth: Int,
val imageHeight: Int,
val modeName: String,
val imageComplete: Boolean,
val inputRms: Float,
val appliedGain: Float,
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
/**
* Decoder quality metrics for diagnostics and logging. Useful for profiling decode
* performance on noisy recordings.
*/
data class SstvQualityMetrics(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
enum class LineRecoveryStrategy {
Look4SatLimited,
Robot36Compatible
}
class SstvDiagnosticsHandle internal constructor(
val enabled: Boolean,
val metrics: StateFlow<SstvQualityMetrics?>
)
class SstvDecoder(
sampleRate: Int = 44100,
scopeWidth: Int = 320,
scopeHeight: Int = 256,
private val channelSelect: Int = 0,
targetRmsLevel: Float = 0.25f,
private val includeScopeData: Boolean = false,
private val enableRmsNormalization: Boolean = true,
preFilterCutoffHz: Double = 500.0,
enablePreFilter: Boolean = true,
private val enableDiagnosticsHandle: Boolean = false,
lineRecoveryStrategy: LineRecoveryStrategy = LineRecoveryStrategy.Look4SatLimited
) {
private val scopeBuffer = PixelBuffer(scopeWidth, scopeHeight * 2)
private val imageBuffer = PixelBuffer(scopeWidth, scopeHeight)
private val decoder = DecoderEngine(scopeBuffer, imageBuffer, "Raw", sampleRate, lineRecoveryStrategy)
private val _frames = MutableSharedFlow<SstvFrame>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private var lastInputRms = 0f
private var lastAppliedGain = 1f
private val _qualityMetrics = MutableStateFlow<SstvQualityMetrics?>(null)
private val preFilter = if (enablePreFilter) HighPassFilter(preFilterCutoffHz, sampleRate.toDouble()) else null
private val diagnosticsHandle = SstvDiagnosticsHandle(enableDiagnosticsHandle, _qualityMetrics)
val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = decoder.allModes.map { it.name }
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) {
// Optional pre-filtering: remove DC offset and subsonic noise that can mask
// weak signals and corrupt the RMS normalization baseline.
preFilter?.apply(samples)
// Optional RMS normalization: bring input to a consistent level so the
// FM demodulator operates in a predictable region. However, this amplifies
// noise proportionally. Aggressive RMS targets (e.g., 0.25) can hurt weak
// signals by boosting noise floor. Safer defaults: 0.35-0.50 for noisy inputs.
// Disable entirely for direct line-level inputs (e.g., receiver discriminator).
val gain = if (enableRmsNormalization) normalise(samples) else GainInfo(0f, 1f)
lastInputRms = gain.inputRms
lastAppliedGain = gain.appliedGain
val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame()
}
fun lockMode(modeName: String) = decoder.setMode(modeName)
fun clearPixels() {
imageBuffer.line = -1
imageBuffer.pixels.fill(0)
decoder.resetQuality()
if (enableDiagnosticsHandle) _qualityMetrics.value = null
}
fun getDiagnosticsHandle(): SstvDiagnosticsHandle? = diagnosticsHandle.takeIf { it.enabled }
/**
* Export quality metrics for logging/diagnostics. Useful for profiling decode
* performance on noisy recordings. Returns null before first frame is emitted.
*/
@Suppress("unused")
fun getQualityMetrics(): SstvQualityMetrics? {
if (enableDiagnosticsHandle) return _qualityMetrics.value
val q = decoder.quality()
return SstvQualityMetrics(
syncHitRate = q.syncHitRate,
predictedLineBursts = q.predictedLineBursts,
maxPredictedStreak = q.maxPredictedStreak,
timingErrorSamples = q.timingErrorSamples
)
}
private fun emitFrame() {
val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height
val quality = decoder.quality()
if (enableDiagnosticsHandle) {
_qualityMetrics.value = SstvQualityMetrics(
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
}
val imageComplete = imageBuffer.line >= imageHeight && imageBuffer.line > 0
// Copy only the active image region — imageBuffer.pixels is pre-allocated
// at the maximum possible size (PD-290: 800×616), so we must not copyOf()
// the entire array and send padding pixels to the observer.
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null
val modeName = decoder.currentMode.name
val scopePixels = if (includeScopeData) scopeBuffer.pixels.copyOf() else null
val scopeWidth = if (includeScopeData) scopeBuffer.width else 0
val scopeHeight = if (includeScopeData) scopeBuffer.height else 0
_frames.tryEmit(
SstvFrame(
scopePixels = scopePixels,
scopeWidth = scopeWidth,
scopeHeight = scopeHeight,
imagePixels = imagePixels,
imageWidth = imageWidth,
imageHeight = imageHeight,
modeName = modeName,
imageComplete = imageComplete,
inputRms = lastInputRms,
appliedGain = lastAppliedGain,
syncHitRate = quality.syncHitRate,
predictedLineBursts = quality.predictedLineBursts,
maxPredictedStreak = quality.maxPredictedStreak,
timingErrorSamples = quality.timingErrorSamples
)
)
}
// Target RMS level for the normalizer. 0.25 leaves headroom while keeping the
// FM demodulator well above its noise floor regardless of input gain.
// TUNING GUIDE:
// - 0.20-0.25: Aggressive, best for clean direct-coupled inputs, worst for mic noise
// - 0.35-0.40: Moderate, good balance for typical phone/mic inputs (RECOMMENDED)
// - 0.50-0.60: Conservative, best for noisy environments, reduces amplitude resolution
// Disable RMS normalization entirely if using a professional receiver discriminator output.
private val targetRms = targetRmsLevel.coerceIn(0.05f, 0.8f)
private class GainInfo(
val inputRms: Float,
val appliedGain: Float
)
// Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs
// both decode reliably. The guard prevents amplifying pure silence into noise.
private fun normalise(buffer: FloatArray): GainInfo {
var sumSq = 0f
for (s in buffer) sumSq += s * s
val rms = sqrt(sumSq / buffer.size)
if (rms > 1e-6f) {
val gain = targetRms / rms
for (i in buffer.indices) buffer[i] *= gain
return GainInfo(rms, gain)
}
return GainInfo(rms, 1f)
}
}
/**
* Simple high-pass filter to remove DC offset and subsonic interference before RMS
* normalization. Improves noise floor estimation and prevents low-freq noise from
* corrupting the gain calculation.
*
* Design: First-order butterworth (pole at cutoff frequency). Fast, minimal latency,
* suitable for real-time preprocessing.
*/
internal class HighPassFilter(cutoffHz: Double, sampleRateHz: Double) {
private val alpha: Float
private var prevInput = 0f
private var prevOutput = 0f
init {
// First-order pole placement: alpha = wc / (wc + ws) where wc = 2*pi*fc, ws = 2*pi*fs
val omega = 2f * PI.toFloat() * (cutoffHz / sampleRateHz).toFloat()
alpha = omega / (omega + 1f)
}
fun apply(buffer: FloatArray) {
for (i in buffer.indices) {
val input = buffer[i]
prevOutput = alpha * (prevOutput + input - prevInput)
buffer[i] = prevOutput
prevInput = input
}
}
}
internal class DecoderQuality(
val syncHitRate: Float,
val predictedLineBursts: Int,
val maxPredictedStreak: Int,
val timingErrorSamples: Int
)
internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
internal class SyncPulseDetector(sampleRate: Int) {
companion object {
const val SYNC_FREQ = 1200.0
const val BLACK_FREQ = 1500.0
const val WHITE_FREQ = 2300.0
}
var detectedWidth: SyncPulseWidth = SyncPulseWidth.NineMs; private set
var pulseOffset: Int = 0; private set
var freqOffset: Float = 0f; private set
private val bandwidth = WHITE_FREQ - BLACK_FREQ
private val fm = FmDemodulator(bandwidth, sampleRate.toDouble())
private val min5ms: Int = round(0.0025 * sampleRate).toInt()
private val max5ms: Int = round(0.007 * sampleRate).toInt()
private val max9ms: Int = round(0.0145 * sampleRate).toInt()
private val max20ms: Int = round(0.025 * sampleRate).toInt()
private val filterDelay: Int
private val avgFilter: MovingAverage
private val delayLine: Delay
private val lowPass: ComplexFirFilter
private val oscillator: Phasor
private val syncFreqValue: Float
private val syncFreqTolerance: Float
private val trigger: SchmittTrigger
private var counter = 0
private var baseBand = Complex()
init {
val filterLen = round(0.0025 * sampleRate).toInt() or 1
filterDelay = (filterLen - 1) / 2
avgFilter = MovingAverage(filterLen)
delayLine = Delay(filterLen)
val loFreq = 1000.0
val hiFreq = 2800.0
val cutoff = (hiFreq - loFreq) / 2
val lpLen = round(0.002 * sampleRate).toInt() or 1
lowPass = ComplexFirFilter(lpLen)
for (i in 0 until lpLen)
lowPass.taps[i] = (WindowFunctions.kaiser(2.0, i, lpLen) * WindowFunctions.sinc(
cutoff,
sampleRate.toDouble(),
i,
lpLen
)).toFloat()
val center = (loFreq + hiFreq) / 2
oscillator = Phasor(-center, sampleRate.toDouble())
syncFreqValue = ((SYNC_FREQ - center) * 2 / bandwidth).toFloat()
syncFreqTolerance = (50 * 2 / bandwidth).toFloat()
val porchFreq = 1500.0
val hiThresh = (SYNC_FREQ + porchFreq) / 2
val loThresh = (SYNC_FREQ + hiThresh) / 2
trigger = SchmittTrigger(
((loThresh - center) * 2 / bandwidth).toFloat(),
((hiThresh - center) * 2 / bandwidth).toFloat()
)
}
fun process(buffer: FloatArray, channelSelect: Int): Boolean {
var detected = false
val channels = if (channelSelect > 0) 2 else 1
// NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency
// value for every mono sample (channelSelect == 0). DecoderEngine.process()
// reads back these values to populate scanLineBuffer. Callers must not reuse
// the buffer after this call.
for (i in 0 until buffer.size / channels) {
when (channelSelect) {
1 -> baseBand.set(buffer[2 * i])
2 -> baseBand.set(buffer[2 * i + 1])
3 -> baseBand.set(buffer[2 * i] + buffer[2 * i + 1])
4 -> baseBand.set(buffer[2 * i], buffer[2 * i + 1])
else -> baseBand.set(buffer[i])
}
baseBand = lowPass.filter(baseBand.mul(oscillator.rotate()))
val freq = fm.demodulate(baseBand)
val avg = avgFilter.avg(freq)
val delayed = delayLine.push(avg)
buffer[i] = freq
if (!trigger.process(avg)) {
++counter
} else if (counter !in min5ms..max20ms || abs(delayed - syncFreqValue) > syncFreqTolerance) {
counter = 0
} else {
detectedWidth = when {
counter < max5ms -> SyncPulseWidth.FiveMs
counter < max9ms -> SyncPulseWidth.NineMs
else -> SyncPulseWidth.TwentyMs
}
pulseOffset = i - filterDelay
freqOffset = delayed - syncFreqValue
detected = true
counter = 0
}
}
return detected
}
}
internal class DecoderEngine(
private val scopeBuffer: PixelBuffer,
private val imageBuffer: PixelBuffer,
rawName: String,
sampleRate: Int,
lineRecoveryStrategy: LineRecoveryStrategy
) {
private val pixelBuffer = PixelBuffer(800, 2)
private val detector = SyncPulseDetector(sampleRate)
private val pulseFilter: MovingAverage
private val pulseFilterDelay: Int
private val scanLineBuffer: FloatArray
private val scratch: FloatArray
private val sync5ms = IntArray(5)
private val sync9ms = IntArray(5)
private val sync20ms = IntArray(5)
private val lines5ms = IntArray(4)
private val lines9ms = IntArray(4)
private val lines20ms = IntArray(4)
private val offsets5ms = FloatArray(5)
private val offsets9ms = FloatArray(5)
private val offsets20ms = FloatArray(5)
private val visFreqs = FloatArray(10)
private val scanLineMin: Int
private val syncTolerance: Int
private val lineTolerance: Int
private val leaderLen: Int
private val leaderTol: Int
private val transition: Int
private val visBitLen: Int
private val visLen: Int
private val rawMode: SstvMode
private val modes5ms: ArrayList<SstvMode>
private val modes9ms: ArrayList<SstvMode>
private val modes20ms: ArrayList<SstvMode>
var currentMode: SstvMode; private set
val allModes: List<SstvMode> get() = modes5ms + modes9ms + modes20ms
private var lockMode = false
private var sample = 0
private var leaderBreak = 0
private var lastSync = 0
private var curLineSamples: Int
private var lastOffset = 0f
private var syncChecks = 0
private var syncHits = 0
private var predictedLineBursts = 0
private var predictedStreak = 0
private var maxPredictedStreak = 0
private var timingErrorSamples = 0
// Look4Sat strategy limits synthetic lines to avoid visible vertical collapse.
// Robot36 strategy preserves legacy behavior by allowing unlimited synthesis.
private val maxConsecutivePredictedLines = when (lineRecoveryStrategy) {
LineRecoveryStrategy.Look4SatLimited -> 2
LineRecoveryStrategy.Robot36Compatible -> Int.MAX_VALUE
}
init {
imageBuffer.line = -1
// Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints)
// so that handleHeader/processPulse can reuse the array with a fill(0) instead
// of allocating a fresh IntArray on every new image, reducing GC pressure.
imageBuffer.pixels = IntArray(800 * 616)
val pfLen = round(0.0025 * sampleRate).toInt() or 1
pulseFilterDelay = (pfLen - 1) / 2
pulseFilter = MovingAverage(pfLen)
scanLineBuffer = FloatArray(round(7.0 * sampleRate).toInt())
scratch = FloatArray(round(1.1 * sampleRate).toInt())
leaderLen = round(0.3 * sampleRate).toInt()
leaderTol = round(0.06 * sampleRate).toInt()
transition = round(0.0005 * sampleRate).toInt()
visBitLen = round(0.03 * sampleRate).toInt()
visLen = round(0.3 * sampleRate).toInt()
scanLineMin = round(0.05 * sampleRate).toInt()
syncTolerance = round(0.03 * sampleRate).toInt()
lineTolerance = round(0.001 * sampleRate).toInt()
rawMode = RawMode(rawName, sampleRate)
val robot36 = Robot36Mode(sampleRate)
currentMode = robot36
curLineSamples = robot36.scanLineSamples
modes5ms = arrayListOf(
RgbMode.wraaseSc2180(sampleRate),
RgbMode.martin("1", 44, 0.146432, sampleRate),
RgbMode.martin("2", 40, 0.073216, sampleRate)
)
modes9ms = arrayListOf(
robot36, Robot72Mode(sampleRate),
RgbMode.scottie("1", 60, 0.138240, sampleRate),
RgbMode.scottie("2", 56, 0.088064, sampleRate),
RgbMode.scottie("DX", 76, 0.3456, sampleRate)
)
modes20ms = arrayListOf(
PdMode("50", 93, 320, 256, 0.09152, sampleRate),
PdMode("90", 99, 320, 256, 0.17024, sampleRate),
PdMode("120", 95, 640, 496, 0.1216, sampleRate),
PdMode("160", 98, 512, 400, 0.195584, sampleRate),
PdMode("180", 96, 640, 496, 0.18304, sampleRate),
PdMode("240", 97, 640, 496, 0.24448, sampleRate),
PdMode("290", 94, 800, 616, 0.2288, sampleRate)
)
}
fun process(recordBuffer: FloatArray, channelSelect: Int): Boolean {
var newLines = false
val detected = detector.process(recordBuffer, channelSelect)
syncChecks++
if (detected) {
syncHits++
predictedStreak = 0
}
var syncIdx = sample + detector.pulseOffset
val channels = if (channelSelect > 0) 2 else 1
for (j in 0 until recordBuffer.size / channels) {
if (sample >= scanLineBuffer.size) {
shift(curLineSamples)
syncIdx -= curLineSamples
if (sample >= scanLineBuffer.size) sample = scanLineBuffer.size - 1
}
scanLineBuffer[sample++] = recordBuffer[j]
}
if (detected) {
when (detector.detectedWidth) {
SyncPulseWidth.FiveMs -> newLines = processPulse(modes5ms, offsets5ms, sync5ms, lines5ms, syncIdx)
SyncPulseWidth.NineMs -> {
leaderBreak = syncIdx; newLines = processPulse(modes9ms, offsets9ms, sync9ms, lines9ms, syncIdx)
}
SyncPulseWidth.TwentyMs -> {
leaderBreak = syncIdx; newLines = processPulse(modes20ms, offsets20ms, sync20ms, lines20ms, syncIdx)
}
}
} else if (handleHeader()) {
predictedStreak = 0
newLines = true
} else if (sample > lastSync + (curLineSamples * 5) / 4) {
newLines = decodePredictedLine()
}
return newLines
}
fun setMode(name: String) {
val mode = allModes.firstOrNull { it.name == name }
if (mode == currentMode) {
lockMode = true; return
}
if (mode != null) {
lockMode = true; imageBuffer.line = -1; currentMode = mode; curLineSamples = mode.scanLineSamples; return
}
lockMode = false
}
fun quality(): DecoderQuality {
val hitRate = if (syncChecks > 0) syncHits.toFloat() / syncChecks else 0f
return DecoderQuality(
syncHitRate = hitRate,
predictedLineBursts = predictedLineBursts,
maxPredictedStreak = maxPredictedStreak,
timingErrorSamples = timingErrorSamples
)
}
fun resetQuality() {
syncChecks = 0
syncHits = 0
predictedLineBursts = 0
predictedStreak = 0
maxPredictedStreak = 0
timingErrorSamples = 0
}
private fun mean(a: IntArray): Double = a.sumOf { it.toDouble() } / a.size
private fun stdDev(a: IntArray, m: Double): Double {
var s = 0.0; for (v in a) s += (v - m) * (v - m); return sqrt(s / a.size)
}
private fun meanF(a: FloatArray): Float {
var s = 0f; for (v in a) s += v; return s / a.size
}
private fun detectMode(modes: ArrayList<SstvMode>, samples: Int): SstvMode {
var best: SstvMode = rawMode
var bestD = Int.MAX_VALUE
for (m in modes) {
val d = abs(samples - m.scanLineSamples); if (d <= lineTolerance && d < bestD) {
bestD = d; best = m
}
}
return best
}
// scopeBuffer is twice the display height. Each decoded scan line is written
// to both the current rolling position (top half, wraps at height/2) and the
// same row offset in the bottom half. The UI displays a window that always
// spans the half-height boundary, giving a seamless non-wrapping scroll effect.
private fun copyUnscaled() {
val w = minOf(scopeBuffer.width, pixelBuffer.width)
for (row in 0 until pixelBuffer.height) {
val line = scopeBuffer.width * scopeBuffer.line
pixelBuffer.pixels.copyInto(scopeBuffer.pixels, line, row * pixelBuffer.width, row * pixelBuffer.width + w)
scopeBuffer.pixels.fill(0, line + w, line + scopeBuffer.width)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
private fun copyScaled(scale: Int) {
for (row in 0 until pixelBuffer.height) {
val line = scopeBuffer.width * scopeBuffer.line
for (col in 0 until pixelBuffer.width) for (i in 0 until scale) scopeBuffer.pixels[line + col * scale + i] =
pixelBuffer.pixels[pixelBuffer.width * row + col]
scopeBuffer.pixels.fill(0, line + pixelBuffer.width * scale, line + scopeBuffer.width)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
repeat(scale - 1) {
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels, scopeBuffer.width * scopeBuffer.line, line, line + scopeBuffer.width
)
scopeBuffer.pixels.copyInto(
scopeBuffer.pixels,
scopeBuffer.width * (scopeBuffer.line + scopeBuffer.height / 2),
line,
line + scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
}
private fun copyLines(ok: Boolean) {
if (!ok) return
var finish = false
if (imageBuffer.line in 0 until imageBuffer.height && imageBuffer.width == pixelBuffer.width) {
val w = imageBuffer.width
for (row in 0 until pixelBuffer.height) {
if (imageBuffer.line >= imageBuffer.height) break
pixelBuffer.pixels.copyInto(imageBuffer.pixels, imageBuffer.line * w, row * w, row * w + w)
imageBuffer.line++
}
finish = imageBuffer.line == imageBuffer.height
}
val scale = scopeBuffer.width / pixelBuffer.width
if (scale <= 1) copyUnscaled() else copyScaled(scale)
if (finish) drawLines(0xff000000.toInt(), 10)
}
private fun decodePredictedLine(): Boolean {
// Avoid long streaks of synthetic lines; once we exceed the cap we wait for
// real sync to reduce visible vertical compression on weak/noisy signals.
if (predictedStreak >= maxConsecutivePredictedLines) return false
val expectedSync = lastSync + curLineSamples
timingErrorSamples = sample - expectedSync
val decoded = currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
lastSync,
curLineSamples,
lastOffset
)
copyLines(decoded)
lastSync = expectedSync
predictedLineBursts++
predictedStreak++
if (predictedStreak > maxPredictedStreak) maxPredictedStreak = predictedStreak
return decoded
}
private fun drawLines(color: Int, count: Int) {
repeat(count) {
scopeBuffer.pixels.fill(
color,
scopeBuffer.line * scopeBuffer.width,
(scopeBuffer.line + 1) * scopeBuffer.width
)
scopeBuffer.pixels.fill(
color,
(scopeBuffer.line + scopeBuffer.height / 2) * scopeBuffer.width,
(scopeBuffer.line + 1 + scopeBuffer.height / 2) * scopeBuffer.width
)
scopeBuffer.line = (scopeBuffer.line + 1) % (scopeBuffer.height / 2)
}
}
private fun adjust(pulses: IntArray, shift: Int) {
for (i in pulses.indices) pulses[i] -= shift
}
private fun shift(amount: Int) {
if ((amount <= 0) || (amount > sample)) return
sample -= amount; leaderBreak -= amount; lastSync -= amount
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount)
// Discard already-decoded samples by sliding the live region back to index 0.
// System.arraycopy handles the overlapping regions correctly and is a native
// memcpy on JVM, so this is fast despite moving the full remaining window.
scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
}
private fun handleHeader(): Boolean {
if (leaderBreak < visBitLen + leaderTol || sample < leaderBreak + leaderLen + leaderTol + visLen + visBitLen) return false
val bp = leaderBreak; leaderBreak = 0
var preFreq = 0f
for (i in 0 until leaderTol) preFreq += scanLineBuffer[bp - visBitLen - leaderTol + i]
val toneFreq = 1900f
val center = 1900f
val tol = 50f
val halfBw = 400f
preFreq = preFreq * halfBw / leaderTol + center
if (abs(preFreq - toneFreq) > tol) return false
var ldrFreq = 0f
for (i in transition until leaderLen - leaderTol) ldrFreq += scanLineBuffer[bp + i]
val ldrOffset = ldrFreq / (leaderLen - transition - leaderTol)
ldrFreq = ldrOffset * halfBw + center
if (abs(ldrFreq - toneFreq) > tol) return false
val stopFreq = 1200f
val pulseThr = ((stopFreq + toneFreq) / 2 - center) / halfBw
var vBegin = bp + leaderLen - leaderTol
val vEnd = bp + leaderLen + leaderTol + visBitLen
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[vBegin++] - ldrOffset) }
while (++vBegin < vEnd) if (pulseFilter.avg(scanLineBuffer[vBegin] - ldrOffset) < pulseThr) break
if (vBegin >= vEnd) return false
vBegin -= pulseFilterDelay
val visEnd = vBegin + visLen
visFreqs.fill(0f)
for (j in 0 until 10) for (i in transition until visBitLen - transition) visFreqs[j] += scanLineBuffer[vBegin + visBitLen * j + i] - ldrOffset
for (i in 0 until 10) visFreqs[i] = visFreqs[i] * halfBw / (visBitLen - 2 * transition) + center
if (abs(visFreqs[0] - stopFreq) > tol || abs(visFreqs[9] - stopFreq) > tol) return false
for (i in 1 until 9) if (abs(visFreqs[i] - 1100f) > tol && abs(visFreqs[i] - 1300f) > tol) return false
var vis = 0
for (i in 0 until 8) vis = vis or ((if (visFreqs[i + 1] < stopFreq) 1 else 0) shl i)
var chk = true; for (i in 0 until 8) chk = chk xor ((vis and (1 shl i)) != 0)
vis = vis and 127; if (!chk) return false
val syncThr = ((1200f + 1500f) / 2 - center) / halfBw
var sIdx = visEnd - visBitLen
val sMax = visEnd + visBitLen
repeat(pulseFilter.length) { pulseFilter.avg(scanLineBuffer[sIdx++] - ldrOffset) }
while (++sIdx < sMax) if (pulseFilter.avg(scanLineBuffer[sIdx] - ldrOffset) > syncThr) break
if (sIdx >= sMax) return false
sIdx -= pulseFilterDelay
val mode: SstvMode
val pulses: IntArray
val lines: IntArray
val f5 = modes5ms.firstOrNull { it.visCode == vis }
val f9 = modes9ms.firstOrNull { it.visCode == vis }
val f20 = modes20ms.firstOrNull { it.visCode == vis }
when {
f5 != null -> {
mode = f5; pulses = sync5ms; lines = lines5ms
}
f9 != null -> {
mode = f9; pulses = sync9ms; lines = lines9ms
}
f20 != null -> {
mode = f20; pulses = sync20ms; lines = lines20ms
}
else -> {
if (!lockMode) drawLines(0xffff0000.toInt(), 8); return false
}
}
if (lockMode && mode != currentMode) return false
mode.resetState()
imageBuffer.width = mode.width; imageBuffer.height = mode.height
imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0
currentMode = mode
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples
if (mode.firstSyncPulseIndex > 0) oldest -= curLineSamples
for (i in pulses.indices) pulses[i] = oldest + i * curLineSamples
lines.fill(curLineSamples)
shift(lastSync + mode.firstPixelSampleIndex)
drawLines(0xff00ff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
return true
}
private fun processPulse(
modes: ArrayList<SstvMode>,
freqOffs: FloatArray,
syncPulses: IntArray,
lineLen: IntArray,
latest: Int
): Boolean {
predictedStreak = 0
for (i in 1 until syncPulses.size) syncPulses[i - 1] = syncPulses[i]
syncPulses[syncPulses.size - 1] = latest
for (i in 1 until lineLen.size) lineLen[i - 1] = lineLen[i]
lineLen[lineLen.size - 1] = syncPulses.last() - syncPulses[syncPulses.size - 2]
for (i in 1 until freqOffs.size) freqOffs[i - 1] = freqOffs[i]
freqOffs[freqOffs.size - 1] = detector.freqOffset
if (lineLen[0] == 0) return false
val m = mean(lineLen)
val lineSamples = round(m).toInt()
if (lineSamples < scanLineMin || lineSamples > scratch.size) return false
if (stdDev(lineLen, m) > lineTolerance) return false
var changed = false
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false
// Try continuous decoding
if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) {
currentMode.resetState()
imageBuffer.width = currentMode.width
imageBuffer.height = currentMode.height
imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height)
imageBuffer.line = 0
drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10)
}
} else {
val prev = currentMode; currentMode = detectMode(modes, lineSamples)
changed =
currentMode != prev || abs(curLineSamples - lineSamples) > lineTolerance || abs(lastSync + lineSamples - syncPulses.last()) > syncTolerance
}
if (changed) {
drawLines(0xff000000.toInt(), 10); drawLines(0xff00ffff.toInt(), 8); drawLines(0xff000000.toInt(), 10)
}
val offset = meanF(freqOffs)
if (syncPulses[0] >= lineSamples && changed) {
val end = syncPulses[0]
val extra = end / lineSamples
val first = end - extra * lineSamples
var p = first; while (p < end) {
copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
p,
lineSamples,
offset
)
); p += lineSamples
}
}
val start = if (changed) 0 else lineLen.size - 1
for (i in start until lineLen.size) copyLines(
currentMode.decodeScanLine(
pixelBuffer,
scratch,
scanLineBuffer,
scopeBuffer.width,
syncPulses[i],
lineLen[i],
offset
)
)
lastSync = syncPulses.last(); curLineSamples = lineSamples; lastOffset = offset
shift(lastSync + currentMode.firstPixelSampleIndex)
return true
}
}
@@ -0,0 +1,240 @@
/*
* 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.sstv
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.round
import kotlin.math.sin
import kotlin.math.sqrt
internal class Complex(var real: Float = 0f, var imag: Float = 0f) {
fun set(real: Float, imag: Float): Complex {
this.real = real; this.imag = imag; return this
}
fun set(real: Float): Complex = set(real, 0f)
fun abs(): Float = sqrt(real * real + imag * imag)
fun mul(other: Complex): Complex {
val tmp = real * other.real - imag * other.imag
imag = real * other.imag + imag * other.real
real = tmp
return this
}
fun div(value: Float): Complex {
real /= value; imag /= value; return this
}
}
internal object WindowFunctions {
fun sinc(cutoff: Double, rate: Double, n: Int, nN: Int): Double {
val f = 2 * cutoff / rate
val x = n - (nN - 1) / 2.0
val fx = f * x
return if (fx == 0.0) f else f * sin(PI * fx) / (PI * fx)
}
fun kaiser(a: Double, n: Int, nN: Int): Double {
fun square(v: Double) = v * v
fun i0(x: Double): Double {
val terms = DoubleArray(35)
terms[0] = 1.0
var v = 1.0
for (m in 1 until 35) {
v *= x / (2 * m); terms[m] = square(v)
}
terms.sort()
var sum = 0.0; for (m in 34 downTo 0) sum += terms[m]
return sum
}
return i0(PI * a * sqrt(1 - square((2.0 * n) / (nN - 1) - 1))) / i0(PI * a)
}
}
// O(1) ring buffer — simpler and faster than a segment tree for the short window
// lengths used here (≤512 samples). Float32 accumulated drift over such windows
// is ~6e-5, negligible for audio-frequency processing.
internal open class MovingSum(val length: Int) {
private val buf = FloatArray(length)
private var pos = 0
private var runningSum = 0f
fun add(input: Float) {
runningSum += input - buf[pos]
buf[pos] = input
if (++pos >= length) pos = 0
}
fun sum(): Float = runningSum
fun sum(input: Float): Float {
add(input); return sum()
}
}
internal class MovingAverage(length: Int) : MovingSum(length) {
fun avg(input: Float): Float = sum(input) / length
}
internal class Ema {
private var alpha: Float = 1f
private var prev: Float = 0f
fun process(input: Float): Float {
prev = prev * (1 - alpha) + alpha * input; return prev
}
fun setCutoff(freq: Double, rate: Double, order: Int = 1) {
alpha = computeAlpha(freq, rate, order)
}
fun reset() {
prev = 0f
}
companion object {
fun computeAlpha(freq: Double, rate: Double, order: Int = 1): Float {
val x = cos(2 * PI * (freq.coerceAtMost(rate * 0.499)) / rate)
val discriminant = (x * (x - 4) + 3).coerceAtLeast(0.0)
return (x - 1 + sqrt(discriminant)).coerceIn(0.0, 1.0).pow(1.0 / order).toFloat()
}
fun withCutoff(freq: Double, rate: Double, order: Int = 1): Ema {
return Ema().also { it.setCutoff(freq, rate, order) }
}
}
}
internal class Phasor(freq: Double, rate: Double) {
private val value = Complex(1f, 0f)
private val delta: Complex = run {
val omega = 2 * PI * freq / rate
Complex(cos(omega).toFloat(), sin(omega).toFloat())
}
private var count = 0
// Renormalize every 512 rotations to prevent magnitude drift accumulation,
// eliminating the per-sample sqrt without sacrificing demodulation accuracy.
fun rotate(): Complex {
value.mul(delta)
if (++count == 512) { value.div(value.abs()); count = 0 }
return value
}
}
internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private val scale = (sampleRate / (bandwidth * PI)).toFloat()
private val pi = PI.toFloat()
private val twoPi = (2 * PI).toFloat()
private var prev = 0f
fun demodulate(input: Complex): Float {
// Use fast polynomial atan2 instead of the exact trigonometric call.
// Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz,
// well within the 50 Hz sync tolerance.
val phase = fastAtan2(input.imag, input.real)
var delta = phase - prev; prev = phase
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
return scale * delta
}
// Rajan's polynomial approximation of atan2 — avoids a transcendental call
// in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate).
private fun fastAtan2(y: Float, x: Float): Float {
val absY = kotlin.math.abs(y) + 1e-10f
val r: Float
val angle: Float
if (x >= 0f) {
r = (x - absY) / (x + absY)
angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f
} else {
r = (x + absY) / (absY - x)
angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f
}
return if (y < 0f) -angle else angle
}
}
internal class ComplexFirFilter(val length: Int) {
private val real = FloatArray(length)
private val imag = FloatArray(length)
private val sum = Complex()
private var pos = 0
val taps = FloatArray(length)
fun filter(input: Complex): Complex {
real[pos] = input.real; imag[pos] = input.imag
if (++pos >= length) pos = 0
sum.real = 0f; sum.imag = 0f
for (tap in taps) {
sum.real += tap * real[pos]; sum.imag += tap * imag[pos]; if (++pos >= length) pos = 0
}
return sum
}
}
internal class Delay(val length: Int) {
private val buf = FloatArray(length)
private var pos = 0
fun push(input: Float): Float {
val tmp = buf[pos]; buf[pos] = input; if (++pos >= length) pos = 0; return tmp
}
}
internal class SchmittTrigger(private val low: Float, private val high: Float) {
private var state = false
fun process(input: Float): Boolean {
if (state) {
if (input < low) state = false
} else {
if (input > high) state = true
}
return state
}
}
internal object ColorConverter {
private fun clamp(v: Int) = v.coerceIn(0, 255)
private fun toInt(level: Float) = clamp(round(255 * level).toInt())
private fun compress(level: Float) = toInt(sqrt(level.coerceIn(0f, 1f)))
private fun yuv2rgb(yY: Int, uU: Int, vV: Int): Int {
val y = yY - 16
val u = uU - 128
val v = vV - 128
val r = clamp((298 * y + 409 * v + 128) shr 8)
val g = clamp((298 * y - 100 * u - 208 * v + 128) shr 8)
val b = clamp((298 * y + 516 * u + 128) shr 8)
return 0xff000000.toInt() or (r shl 16) or (g shl 8) or b
}
fun gray(level: Float): Int = 0xff000000.toInt() or (0x00010101 * compress(level))
fun rgb(r: Float, g: Float, b: Float): Int = 0xff000000.toInt() or (toInt(r) shl 16) or (toInt(g) shl 8) or toInt(b)
fun yuv2rgb(yY: Float, uU: Float, vV: Float): Int = yuv2rgb(toInt(yY), toInt(uU), toInt(vV))
fun yuv2rgb(packed: Int): Int = yuv2rgb((packed shr 16) and 0xff, (packed shr 8) and 0xff, packed and 0xff)
}
@@ -0,0 +1,434 @@
/*
* 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.sstv
import kotlin.math.round
internal class PixelBuffer(var width: Int, var height: Int) {
var pixels = IntArray(width * height)
var line = 0
}
internal sealed interface SstvMode {
val name: String
val visCode: Int
val width: Int
val height: Int
val firstPixelSampleIndex: Int
val firstSyncPulseIndex: Int
val scanLineSamples: Int
fun resetState() {}
fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean
}
internal class RgbMode(
override val name: String,
override val visCode: Int,
private val hPixels: Int,
private val vPixels: Int,
override val firstSyncPulseIndex: Int,
override val scanLineSamples: Int,
override val firstPixelSampleIndex: Int,
private val redBegin: Int,
private val redLen: Int,
private val greenBegin: Int,
private val greenLen: Int,
private val blueBegin: Int,
private val blueLen: Int,
private val endSamples: Int,
) : SstvMode {
override val width get() = hPixels
override val height get() = vPixels
private val ema = Ema.withCutoff(hPixels.toDouble(), (2 * greenLen).toDouble(), 2)
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
val begin = firstPixelSampleIndex
if (syncPulseIndex + begin < 0 || syncPulseIndex + endSamples > scanLine.size) return false
ema.reset()
for (i in 0 until endSamples - begin) scratch[i] = ema.process(scanLine[syncPulseIndex + begin + i])
ema.reset()
for (i in endSamples - begin - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until hPixels) {
val r = redBegin + (i * redLen) / hPixels
val g = greenBegin + (i * greenLen) / hPixels
val b = blueBegin + (i * blueLen) / hPixels
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[r], scratch[g], scratch[b])
}
pixelBuffer.width = hPixels; pixelBuffer.height = 1
return true
}
companion object {
fun martin(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
val sync = 0.004862
val sep = 0.000572
val scanLine = sync + sep + 3 * (channelSec + sep)
val gEnd = sep + channelSec
val bBegin = gEnd + sep
val bEnd = bBegin + channelSec
val rBegin = bEnd + sep
val rEnd = rBegin + channelSec
return fromSeconds(
name = "Martin $variant",
code = code,
firstSyncSec = 0.0,
scanLineSec = scanLine,
beginSec = sep,
rBeginSec = rBegin,
rEndSec = rEnd,
gBeginSec = sep,
gEndSec = gEnd,
bBeginSec = bBegin,
bEndSec = bEnd,
endSec = rEnd,
sr = sampleRate
)
}
fun scottie(variant: String, code: Int, channelSec: Double, sampleRate: Int): RgbMode {
val sync = 0.009
val sep = 0.0015
val firstSync = sync + 2 * (sep + channelSec)
val scanLine = sync + 3 * (channelSec + sep)
val bEnd = -sync
val bBegin = bEnd - channelSec
val gEnd = bBegin - sep
val gBegin = gEnd - channelSec
val rEnd = sep + channelSec
return fromSeconds(
name = "Scottie $variant",
code = code,
firstSyncSec = firstSync,
scanLineSec = scanLine,
beginSec = gBegin,
rBeginSec = sep,
rEndSec = rEnd,
gBeginSec = gBegin,
gEndSec = gEnd,
bBeginSec = bBegin,
bEndSec = bEnd,
endSec = rEnd,
sr = sampleRate
)
}
fun wraaseSc2180(sampleRate: Int): RgbMode {
val sync = 0.0055225
val porch = 0.0005
val ch = 0.235
val scanLine = sync + porch + 3 * ch
val rEnd = porch + ch
val gEnd = rEnd + ch
val bEnd = gEnd + ch
return fromSeconds(
name = "Wraase SC2-180",
code = 55,
firstSyncSec = 0.0,
scanLineSec = scanLine,
beginSec = porch,
rBeginSec = porch,
rEndSec = rEnd,
gBeginSec = rEnd,
gEndSec = gEnd,
bBeginSec = gEnd,
bEndSec = bEnd,
endSec = bEnd,
sr = sampleRate
)
}
private fun fromSeconds(
name: String, code: Int, w: Int = 320, h: Int = 256,
firstSyncSec: Double, scanLineSec: Double, beginSec: Double,
rBeginSec: Double, rEndSec: Double, gBeginSec: Double, gEndSec: Double,
bBeginSec: Double, bEndSec: Double, endSec: Double, sr: Int
): RgbMode {
val begin = round(beginSec * sr).toInt()
return RgbMode(
name = name, visCode = code, hPixels = w, vPixels = h,
firstSyncPulseIndex = round(firstSyncSec * sr).toInt(),
scanLineSamples = round(scanLineSec * sr).toInt(),
firstPixelSampleIndex = begin,
redBegin = round(rBeginSec * sr).toInt() - begin,
redLen = round((rEndSec - rBeginSec) * sr).toInt(),
greenBegin = round(gBeginSec * sr).toInt() - begin,
greenLen = round((gEndSec - gBeginSec) * sr).toInt(),
blueBegin = round(bBeginSec * sr).toInt() - begin,
blueLen = round((bEndSec - bBeginSec) * sr).toInt(),
endSamples = round(endSec * sr).toInt()
)
}
}
}
internal class Robot36Mode(sampleRate: Int) : SstvMode {
override val name = "Robot 36 Color"
override val visCode = 8
override val width = 320
override val height = 240
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val lumSamples: Int
private val sepSamples: Int
private val chromSamples: Int
private val lumBegin: Int
private val sepBegin: Int
private val chromBegin: Int
private val end: Int
private val ema: Ema
private var lastEven = false
init {
val syncPorch = 0.003
val lum = 0.088
val sep = 0.0045
val porch = 0.0015
val chrom = 0.044
scanLineSamples = round((0.009 + syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
lumSamples = round(lum * sampleRate).toInt(); sepSamples = round(sep * sampleRate).toInt()
chromSamples = round(chrom * sampleRate).toInt()
lumBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = lumBegin
sepBegin = round((syncPorch + lum) * sampleRate).toInt()
chromBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
end = round((syncPorch + lum + sep + porch + chrom) * sampleRate).toInt()
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
}
override fun resetState() {
lastEven = false
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
var sep = 0f
for (i in 0 until sepSamples) sep += scanLine[syncPulseIndex + sepBegin + i]
sep = sep / sepSamples - freqOffset
var even = sep < 0
if (sep < -1.1f || (sep > -0.9f && sep < 0.9f) || sep > 1.1f) even = !lastEven
lastEven = even
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until width) {
val lPos = lumBegin + (i * lumSamples) / width
val cPos = chromBegin + (i * chromSamples) / width
if (even) {
// Even line: store Y in the red channel slot and Cr in the blue slot,
// using ColorConverter.rgb() as a convenient 3×byte packer (not RGB).
// The odd line will read these back and combine with its own Cb to
// produce the final YUV→RGB conversion for both rows.
pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
} else {
val evenYuv = pixelBuffer.pixels[i]
// Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB
// Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot)
// Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00).
val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
pixelBuffer.pixels[i + width] =
ColorConverter.yuv2rgb((oddYuv and 0x00ffff00) or (evenYuv and 0x000000ff))
}
}
pixelBuffer.width = width; pixelBuffer.height = 2
return !even
}
}
internal class Robot72Mode(sampleRate: Int) : SstvMode {
override val name = "Robot 72 Color"
override val visCode = 12
override val width = 320
override val height = 240
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val lumSamples: Int
private val chromSamples: Int
private val yBegin: Int
private val vBegin: Int
private val uBegin: Int
private val end: Int
private val ema: Ema
init {
val syncPorch = 0.003
val lum = 0.138
val sep = 0.0045
val porch = 0.0015
val chrom = 0.069
scanLineSamples = round((0.009 + syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
lumSamples = round(lum * sampleRate).toInt(); chromSamples = round(chrom * sampleRate).toInt()
yBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yBegin
vBegin = round((syncPorch + lum + sep + porch) * sampleRate).toInt()
uBegin = round((syncPorch + lum + sep + porch + chrom + sep + porch) * sampleRate).toInt()
end = round((syncPorch + lum + 2 * (sep + porch + chrom)) * sampleRate).toInt()
ema = Ema.withCutoff(width.toDouble(), (2 * lumSamples).toDouble(), 2)
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until width) {
val yP = yBegin + (i * lumSamples) / width
val uP = uBegin + (i * chromSamples) / width
val vP = vBegin + (i * chromSamples) / width
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb(scratch[yP], scratch[uP], scratch[vP])
}
pixelBuffer.width = width; pixelBuffer.height = 1
return true
}
}
internal class PdMode(
variant: String,
override val visCode: Int,
private val hPixels: Int,
private val vPixels: Int,
channelSec: Double,
sampleRate: Int
) : SstvMode {
override val name = "PD $variant"
override val width get() = hPixels
override val height get() = vPixels
override val firstSyncPulseIndex = 0
override val scanLineSamples: Int
override val firstPixelSampleIndex: Int
private val chSamples: Int
private val yEvenBegin: Int
private val vAvgBegin: Int
private val uAvgBegin: Int
private val yOddBegin: Int
private val end: Int
private val ema: Ema
init {
val syncPorch = 0.00208
scanLineSamples = round((0.02 + syncPorch + 4 * channelSec) * sampleRate).toInt()
chSamples = round(channelSec * sampleRate).toInt()
yEvenBegin = round(syncPorch * sampleRate).toInt(); firstPixelSampleIndex = yEvenBegin
vAvgBegin = round((syncPorch + channelSec) * sampleRate).toInt()
uAvgBegin = round((syncPorch + 2 * channelSec) * sampleRate).toInt()
yOddBegin = round((syncPorch + 3 * channelSec) * sampleRate).toInt()
end = round((syncPorch + 4 * channelSec) * sampleRate).toInt()
ema = Ema.withCutoff(hPixels.toDouble(), (2 * chSamples).toDouble(), 2)
}
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex + firstPixelSampleIndex < 0 || syncPulseIndex + end > scanLine.size) return false
ema.reset()
for (i in firstPixelSampleIndex until end) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in end - 1 downTo firstPixelSampleIndex) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until hPixels) {
val pos = (i * chSamples) / hPixels
pixelBuffer.pixels[i] =
ColorConverter.yuv2rgb(scratch[pos + yEvenBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
pixelBuffer.pixels[i + hPixels] =
ColorConverter.yuv2rgb(scratch[pos + yOddBegin], scratch[pos + uAvgBegin], scratch[pos + vAvgBegin])
}
pixelBuffer.width = hPixels; pixelBuffer.height = 2
return true
}
}
internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
override val visCode = -1
override val width = -1
override val height = -1
override val firstPixelSampleIndex = 0
override val firstSyncPulseIndex = -1
override val scanLineSamples = -1
private val smallMax = round(0.125 * sampleRate).toInt()
private val medMax = round(0.175 * sampleRate).toInt()
private val ema = Ema()
override fun decodeScanLine(
pixelBuffer: PixelBuffer,
scratch: FloatArray,
scanLine: FloatArray,
scopeWidth: Int,
syncPulseIndex: Int,
lineSamples: Int,
freqOffset: Float
): Boolean {
if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false
var px = scopeWidth
if (lineSamples < smallMax) px /= 2
if (lineSamples < medMax) px /= 2
ema.setCutoff(px.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset()
for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i])
ema.reset()
for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset)
for (i in 0 until px) pixelBuffer.pixels[i] = ColorConverter.gray(scratch[(i * lineSamples) / px])
pixelBuffer.width = px; pixelBuffer.height = 1
return true
}
}
private fun freqToLevel(frequency: Float, offset: Float): Float = 0.5f * (frequency - offset + 1f)
@@ -0,0 +1,34 @@
/*
* 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.usecase
import kotlinx.coroutines.flow.Flow
/**
* Platform abstraction for microphone audio capture.
* Produces a flow of mono Float PCM buffers at the configured sample rate.
*/
interface IAudioCapture {
val sampleRate: Int
/**
* Start capturing audio. Emits FloatArray buffers continuously until the flow is canceled.
* Caller is responsible for holding RECORD_AUDIO permission before calling this.
*/
fun audioFlow(): Flow<FloatArray>
}
@@ -0,0 +1,26 @@
/*
* 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.usecase
/**
* Saves a decoded SSTV image to the device gallery.
* @return true if the image was saved successfully
*/
interface ISaveImage {
suspend operator fun invoke(pixels: IntArray, width: Int, height: Int, modeName: String): Boolean
}
@@ -21,13 +21,19 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import kotlinx.serialization.json.Json
import org.json.JSONObject import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream import java.io.InputStream
import kotlin.math.pow import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) { class DataParser(private val dispatcher: CoroutineDispatcher) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines -> stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
@@ -43,13 +49,15 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) { suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
runCatching { runCatching {
val jsonArray = JSONArray(stream.bufferedReader().readText()) val root = json.parseToJsonElement(stream.bufferedReader().readText())
(0 until jsonArray.length()).mapNotNull { parseJSON(jsonArray.getJSONObject(it)) } (root as? JsonArray)?.mapNotNull { element ->
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
.onFailure { println("JSON parsing exception: $it") }
.getOrNull()
} ?: emptyList()
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
private fun parseCSV(values: List<String>): OrbitalData? = runCatching { private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0] val name = values[0]
val timestamp = values[2] val timestamp = values[2]
@@ -74,7 +82,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
argper = values[7].toDouble(), argper = values[7].toDouble(),
meanan = values[8].toDouble(), meanan = values[8].toDouble(),
catnum = values[11].toInt(), catnum = values[11].toInt(),
bstar = values[14].toDouble() bstar = values[14].toDouble(),
ndot = values[15].toDouble()
) )
}.onFailure { println("CSV parsing exception: $it") }.getOrNull() }.onFailure { println("CSV parsing exception: $it") }.getOrNull()
@@ -91,31 +100,14 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
argper = line2.substring(34, 42).toDouble(), argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(), meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(), catnum = line1.substring(2, 7).trim().toInt(),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()) bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble()
) )
}.onFailure { println("TLE parsing exception: $it") }.getOrNull() }.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun parseJSON(json: JSONObject): SatRadio? = runCatching { fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
SatRadio(
uuid = json.getString("uuid"),
info = json.getString("description"),
isAlive = json.getBoolean("alive"),
downlinkLow = json.optLongOrNull("downlink_low"),
downlinkHigh = json.optLongOrNull("downlink_high"),
downlinkMode = json.optStringOrNull("mode"),
uplinkLow = json.optLongOrNull("uplink_low"),
uplinkHigh = json.optLongOrNull("uplink_high"),
uplinkMode = json.optStringOrNull("uplink_mode"),
isInverted = json.getBoolean("invert"),
catnum = json.optIntOrNull("norad_cat_id")
)
}.onFailure { println("JSON parsing exception: $it") }.getOrNull()
private fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else getString(key) fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
private fun JSONObject.optLongOrNull(key: String): Long? = if (isNull(key)) null else getLong(key)
private fun JSONObject.optIntOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
return daysInMonth.take(month - 1).sum() + dayOfMonth return daysInMonth.take(month - 1).sum() + dayOfMonth
} }
@@ -0,0 +1,173 @@
/*
* 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.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
*
* The full physical path:
*
* TX→RX (uplink → downlink):
* ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
*
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/
object DopplerFrequencyCalculator {
/**
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/
fun computeUplinkFromDownlink(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
// ④→③ 逆下行多普勒:卫星转发的频率
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
}
/**
* Given a downlink frequency (what the user hears), compute the
* uplink frequency the user should transmit, with an offset applied
* to the downlink (in Hz).
* Full path: ④→③→②→①
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before the inverse downlink Doppler.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
}
/**
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear.
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplink(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos
): Long? {
if (!isLinearTransponder(transponder)) return null
// ①→② 上行多普勒:卫星收到的频率
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
}
/**
* Given an uplink frequency (what the user transmits), compute the
* downlink frequency the user will hear, with an offset applied
* to the downlink (in Hz).
* Full path: ①→②→③→④
*/
fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long,
transponder: SatRadio,
orbitalPos: OrbitalPos,
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
// ①→② 上行多普勒:卫星收到的频率
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③ 加上 offset(offset 在卫星本地频率域)
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
}
/** True if this transponder supports linear passband mapping. */
fun isLinearTransponder(transponder: SatRadio): Boolean {
val upLow = transponder.uplinkLow
val upHigh = transponder.uplinkHigh
val downLow = transponder.downlinkLow
val downHigh = transponder.downlinkHigh
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for the named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
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) ||
(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()
}
}
}
@@ -17,11 +17,18 @@
*/ */
package com.rtbishop.look4sat.core.domain.utility package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import kotlin.math.acos
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.sin
private const val AVG_EARTH_RADIUS_KM = 6371.009
private const val MIN_LATITUDE = -85.05112877980658 private const val MIN_LATITUDE = -85.05112877980658
private const val MAX_LATITUDE = 85.05112877980658 private const val MAX_LATITUDE = 85.05112877980658
private const val MIN_LONGITUDE = -180.0 private const val MIN_LONGITUDE = -180.0
@@ -48,6 +55,27 @@ fun Double.toRadians(): Double = this * DEG2RAD
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this // return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
//} //}
// Great-circle distance between two positions in kilometers using the spherical law of cosines.
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val lon1R = lon1.toRadians()
val lon2R = lon2.toRadians()
return acos(
sin(lat1R) * sin(lat2R) + cos(lat1R) * cos(lat2R) * cos(lon2R - lon1R)
) * AVG_EARTH_RADIUS_KM
}
// Initial bearing (azimuth) from position 1 to position 2, in degrees (0-360).
fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1R = lat1.toRadians()
val lat2R = lat2.toRadians()
val dLon = (lon2 - lon1).toRadians()
val y = sin(dLon) * cos(lat2R)
val x = cos(lat1R) * sin(lat2R) - sin(lat1R) * cos(lat2R) * cos(dLon)
return (atan2(y, x).toDegrees() + 360) % 360
}
fun clipLat(latitude: Double): Double { fun clipLat(latitude: Double): Double {
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE) return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
} }
@@ -59,6 +87,13 @@ fun clipLon(longitude: Double): Double {
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE) return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
} }
fun OrbitalPos.toMapGeoPos(): GeoPos {
return GeoPos(
latitude = clipLat(latitude.toDegrees()),
longitude = clipLon(longitude.toDegrees())
)
}
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double { private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
return min(max(currentValue, minValue), maxValue) return min(max(currentValue, minValue), maxValue)
} }
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
* square / subsquare / extended-square centre. Longer input is truncated to
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
* null.
*/
fun qthToPosition(locator: String): GeoPos? { fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6) val trimmedQth = locator.trim().uppercase().take(8)
if (!isValidLocator(trimmedQth)) return null if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20 val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10 val latFirst = (trimmedQth[1].code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2 val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt() val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180 // Start from the 4-char square centre, refine for 6/8-char subsquares.
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90 var longitude = lonFirst + lonSecond + 1.0
val longitude = (lonFirst + lonSecond + lonThird).round(4) var latitude = latFirst + latSecond + 0.5
val latitude = (latFirst + latSecond + latThird).round(4) if (trimmedQth.length >= 6) {
return GeoPos(latitude, longitude) val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
if (trimmedQth.length >= 8) {
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
longitude += lonExt + 15.0 / 3600.0
latitude += latExt + 7.5 / 3600.0
}
}
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
} }
/**
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
* square and subsquare letters). Returns null for out-of-range positions.
*/
fun positionToQth(latitude: Double, longitude: Double): String? { fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180 val newLongitude = if (longitude > 180.0) longitude else longitude + 180
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
val latFirst = (65 + (newLatitude / 10)).toInt().toChar() val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt() val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt() val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar() val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar() val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
} }
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
} }
private fun isValidLocator(locator: String): Boolean { private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex()) // 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
return when (locator.length) {
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
else -> false
}
} }
@@ -0,0 +1,48 @@
package com.rtbishop.look4sat.core.domain.utility
/**
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
*
* The comparison is done on the numeric version segments first; when the base versions are
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
* are compared as a sequence, so multi-level suffixes work correctly:
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
*/
object VersionComparator {
/** True when [candidate] is a newer version than [current]. */
fun isNewer(candidate: String, current: String): Boolean {
val cand = parse(candidate)
val curr = parse(current)
val maxLen = maxOf(cand.first.size, curr.first.size)
for (i in 0 until maxLen) {
val c = cand.first.getOrElse(i) { 0 }
val k = curr.first.getOrElse(i) { 0 }
if (c != k) return c > k
}
val maxBuild = maxOf(cand.second.size, curr.second.size)
for (i in 0 until maxBuild) {
val c = cand.second.getOrElse(i) { 0 }
val k = curr.second.getOrElse(i) { 0 }
if (c != k) return c > k
}
return false
}
/**
* Parses a version string into (numeric base segments, numeric suffix segments).
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
*/
fun parse(version: String): Pair<List<Int>, List<Int>> {
val cleaned = version.trim().removePrefix("v")
val dashIndex = cleaned.indexOf('-')
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
return base to build
}
}
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more