Compare commits

...
Author SHA1 Message Date
atsunatsu 474762cbd3 i18n: credit BG5JSU for LoTW upload support 2026-09-26 18:54:14 +08:00
atsunatsu 8fe88fbc93 test(lotw): PBES2 reader and region-field config tests 2026-09-26 18:33:59 +08:00
atsunatsu 7728b5c38f build: release version 4.4.7-ba7opf.17 (523) 2026-09-26 18:33:47 +08:00
atsunatsu a769f7dea7 feat(logbook): Room persistence, UP/QSL markers, one-click upload, swipe-delete, calculator-frequency logging 2026-09-26 18:33:38 +08:00
atsunatsu 3be2e4b9e8 feat(lotw): PBES2 .p12 parsing, station region fields, upload error codes 2026-09-26 18:33:25 +08:00
atsunatsu 5957f30af2 build: bump version to 4.4.7-ba7opf.17.3 (513) 2026-09-25 23:50:52 +08:00
atsunatsu d8d0825f54 feat: show clear error when p12 certificate import fails
The import previously swallowed all exceptions silently. Now the upload
config dialog shows a specific message for: wrong password, expired
certificate, invalid file, and unknown failures.
2026-09-25 23:50:48 +08:00
atsunatsu bc5a8e4bba chore: restore gradle.properties to original build settings (-Xmx6g, parallel, 4 workers) 2026-09-25 23:13:57 +08:00
atsunatsu 851bc25d73 build: bump version to 4.4.7-ba7opf.17.2 (512) 2026-09-25 23:03:23 +08:00
atsunatsu 238511aa78 fix: logbook mode display + bottom-sheet dialogs
- displayMode no longer trusts a stale submode for non-MFSK modes: FM/CW/SSB
  records (including ones persisted with the old default submode=FT4) now show
  their real mode; only MODE=MFSK uses the FT4 submode. QsoRecord defaults
  changed mode=MFSK->FM, submode=FT4->''.
- LoTW report bridge sets submode=FT4 for MODE=MFSK so confirmations still
  match local FT4 records.
- Logbook and LoTW upload config dialogs now use SharedDialog (bottom-sheet),
  matching the LoTW confirmed-grids dialog style.
2026-09-25 23:03:20 +08:00
atsunatsu e606afb0f3 build: bump version to 4.4.7-ba7opf.17.1 (511) 2026-09-25 22:56:39 +08:00
atsunatsu e19f098aa5 fix: p12 import — pick file first, then password (matches normal usage)
The Choose .p12 button was gated on a non-blank password, which forced users
to type the password before selecting the file. Now the file picker is always
available; after selection the filename is shown, then the password field and
an Import button appear.
2026-09-25 22:56:36 +08:00
atsunatsu 797b1c19f7 build: bump version to 4.4.7-ba7opf.17 (510) 2026-09-25 22:37:31 +08:00
atsunatsu 8e549cd43c feat: LoTW upload + logbook (port from Iu-yang1's fork)
- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
  LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
  MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
  myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
  to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
  installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
  auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
  (CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
  one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
  LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
  grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
2026-09-25 22:37:20 +08:00
atsunatsu d33629f015 build: bump version to 4.4.7-ba7opf.16 (509) 2026-09-25 15:02:28 +08:00
atsunatsu 7081ffaa60 Merge remote-tracking branch 'fork/main' 2026-09-25 14:50:56 +08:00
atsunatsu 3b5b5fe627 Merge pull request #6 from Iu-yang1/feature/tianditu-autotle
feat(map): add TianDiTu switching and AutoTLE sources
2026-09-25 14:47:57 +08:00
Iu_yang1 ebe32ed153 feat(map): add TianDiTu and AutoTLE sources 2026-09-25 14:42:29 +08:00
atsunatsu 091d3e867b build: bump version to 4.4.7-ba7opf.15.11 (508) 2026-09-25 14:20:46 +08:00
atsunatsu 26e50ddab5 feat(map,lotw): multi-grid station locations (台址) in roamed stripes and operated-grid selector
A LoTW station location (台址) can span several grid squares: the report
emits <MY_VUCC_GRIDS> (comma-separated, MY_GRIDSQUARE often absent) on
those records, but the app parsed only <MY_GRIDSQUARE>, so every extra
grid of a multi-grid 台址 silently vanished from both the blue roamed
stripes and the operated-grid (VUCC) selector.

- GridQso gains myGrids (full own-grid set), myCallsign and stationKey
  (MY_* station snapshot); myGrid stays as the first grid for back-compat.
- parseConfirmedGridQsos parses MY_VUCC_GRIDS + the MY_* snapshot fields;
  parseRoamedGrids parses MY_VUCC_GRIDS too and uppercases (lowercase
  grids never matched the overlay labels).
- SettingsRepo persists mgs/mc/sk; old data falls back to single-grid sets.
- MapScreen groups the selector's 台址名单 by the 台址's full grid set:
  a multi-grid 台址 is one entry listing all its grids (e.g. 'OM60,PM01'),
  and selecting it fills every worked grid worked from any of them.
  Identical grid sets merge (VUCC-equivalent), so partial MY_* snapshots
  can't split one 台址 into duplicate entries. Own grid still never
  striped.

Verified against real reports: BH6RJD (807 SAT QSOs) yields 7 台址
including 4 multi-grid ones; BA7OPF's own data stays a single 'OL62'
entry. 28 LoTW parser tests pass (5 new).
2026-09-25 14:10:17 +08:00
atsunatsu 5f90eebab4 fix: correct AO-123 in hardcoded FM list to ASRTU-1 (61781)
User correction: AO-123 is ASRTU-1 (NORAD 61781, launched 2024-11-04,
in orbit), not Fox-1D (43137, re-entered 2024-02). Update the hardcoded
FM set and its tests to include ASRTU-1.
2026-09-24 12:57:25 +08:00
atsunatsu 70ada0d608 fix: revert AMSAT live data route; hardcode FM/Linear satellite lists
User decision: drop the AMSAT live-page fetch pipeline entirely (page
parsing, amateur whitelist, ISS module disambiguation, timeout fallbacks,
amSatListsVersion re-resolution) and hardcode the FM/Linear lists instead.

- Sources: remove amSatLiveUrls + amSatActiveUrls; add
  amSatFmCatnums = {SO-50 27607, ISS ZARYA 25544, AO-123 43137} and
  amSatLinearCatnums = {RS-44 44909, FO-29 24278, AO-7 7530,
  AO-73 39444, JO-97 43803}
- SelectionRepo: virtual types resolve FM/Linear from hardcoded sets;
  SSTV stays (amateur-only mode query + R/B debris exclusion)
- MutualViewModel: FM/Linear chips filter by the hardcoded sets
- DatabaseRepo/SettingsRepo/ISettingsRepo: AMSAT list fetch/persist
  removed; DataParser AMSAT parse helpers removed
- Tests: AMSAT pipeline tests dropped; selection/mutual tests re-targeted
  at the hardcoded lists
2026-09-24 12:41:52 +08:00
atsunatsu 317f6d877f build: bump version to 4.4.7-ba7opf.15.8 (505) 2026-09-24 11:38:21 +08:00
atsunatsu 865fa9c6f2 fix: AMSAT live page timeouts wiped FM/Linear lists; add fallback
Root cause of the recurring empty AMSAT Live filters: OkHttp defaults to a
10s read timeout and amsat.org's live pages take 7-10s+ from many
networks — every sync timed out, fmNames/linearNames stayed empty, and
(with no previous list, e.g. right after clearing data) the filters were
written as EMPTY. The stale pre-whitelist FM list survived only because
older syncs had occasionally succeeded and the failure path preserved it.

Fix:
1. OkHttp timeouts raised (connect 20s / read 30s / write 30s / call 60s)
   in MainContainer.provideRemoteSource so slow AMSAT pages actually load.
2. updateAmSatLiveLists gains a last-resort fallback: when the AMSAT page
   is down AND there is no previous list to clean, derive FM/Linear from
   the local transceivers (FM / SSB,CW,USB,LSB modes) intersected with
   the amateur whitelist, so a network timeout never wipes the filters.
   ISS module aliases stay excluded by the whitelist intersection.

Regression test: page fails + empty previous list -> fallback yields
{25544} (26700 excluded by whitelist).
2026-09-24 11:37:50 +08:00
atsunatsu a082abb72c build: bump version to 4.4.7-ba7opf.15.7 (504) 2026-09-24 11:11:31 +08:00
atsunatsu a2e41a0c1a fix: don't crash the AMSAT list update on unknown satellite names
resolvePerName crashed with NoSuchElementException (all.first()) when an
AMSAT live-page name had no matching local entry (TEVEL2-1..9, RS95S, any
brand-new sat not yet in the local TLE). The exception was swallowed by
updateAmSatLiveLists' runCatching, so the FM/Linear lists were NEVER
rewritten — they stayed at the stale pre-whitelist snapshot containing
all five ISS modules, which is why ISS kept showing as DESTINY despite the
15.4/15.5/15.6 disambiguation fixes.

Fix: return an empty set for names with no local match (correct skip
semantics) so the rest of the page still resolves and the ISS module
collapse runs. Regression test covers the real AMSAT page mix (ISS +
unknown names).
2026-09-24 11:11:04 +08:00
atsunatsu e1daf70f11 build: bump version to 4.4.7-ba7opf.15.6 (503) 2026-09-24 10:48:46 +08:00
atsunatsu ca9f93c1ed fix: persist All source type ids so the All filter shows satellites
Root cause of "All filter is empty": 'All' is a real TLE source type
(CelesTrak active group) in satelliteDataUrls, but setSatelliteTypeIds
had an upstream-legacy 'if (type == "All") return' that silently dropped
its ids on every sync. Selecting 'All' in the type dialog then resolved
to an empty catnum set -> empty list, while entries stayed populated
(which is why satellite counts looked normal).

Also fix getTypesList: removeAt(0) removed the alphabetically-first real
source ('ARISS') instead of the intended placeholder; now excludes only
'Other' (empty URL), so all real source types incl. 'All'/'ARISS' are
selectable.

Tests: remote update persists All ids; All type resolves to persisted
CelesTrak active ids; getTypesList excludes only Other.
2026-09-24 10:48:22 +08:00
atsunatsu 42bd55e180 build: bump version to 4.4.7-ba7opf.15.5 (502) 2026-09-24 09:40:56 +08:00
atsunatsu 7401e1451b fix: re-resolve AMSAT virtual types after sync; clean stale FM lists
Two independent defects kept ISS (DESTINY) visible in the satellite-page
AMSAT Live FM filter even after installing the fix and syncing data:

1. UI-layer staleness (SelectionRepo): resolveTypeIds ran only when the
   filter type changed and snapshotted the catnum set. A background data
   sync rewriting the FM/Linear lists never re-triggered resolution, so
   the UI kept filtering with the pre-sync (dirty) set until restart or a
   type toggle. ISettingsRepo now exposes a monotonic amSatListsVersion
   StateFlow, bumped by SettingsRepo.setAmSatCatnums; SelectionRepo
   combines it with currentTypes so every rewrite re-resolves instantly.

2. Data-layer staleness (DatabaseRepo): when the AMSAT FM page fetch
   failed, the previous list was kept verbatim — including pre-whitelist
   era lists containing all five ISS module entries. The stale list is now
   re-disambiguated: previous catnums are looked up by local name and run
   through the same whitelist / smallest-catnum collapse, so ISS modules
   always collapse to the primary ZARYA (25544).
2026-09-24 09:40:42 +08:00
atsunatsu fa51fb2b44 build: bump version to 4.4.7-ba7opf.15.4 (501) 2026-09-24 09:17:55 +08:00
atsunatsu 75a251eb98 fix: keep last good AMSAT live lists on fetch failures; whitelist mirror
- Failed whitelist fetch (raw.githubusercontent.com unreachable on some
  networks) no longer wipes the persisted whitelist or admits every ISS
  module alias: fall back to the persisted whitelist, and only persist
  when a stream was actually fetched.
- Multi-match collapse without any whitelist now keeps the primary entry
  (smallest catnum, ISS ZARYA=25544) instead of every alias (DESTINY etc.).
- Failed FM/Linear page fetch keeps the previous lists instead of
  clearing the filters.
- Amateur whitelist gets a jsDelivr CDN mirror as fallback source.
2026-09-24 09:17:17 +08:00
atsunatsu 91b5269740 build: bump version to 4.4.7-ba7opf.15.3 (500) 2026-09-24 08:49:53 +08:00
atsunatsu 522cac25e8 fix: correct AMSAT live filter satellite lists
- Resolve ISS station modules (ZARYA/UNITY/ZVEZDA/DESTINY/NAUKA) to the
  primary 25544 instead of whichever module appears first in the TLE source
- Match OSCAR designators to local names spelled 'OSCAR N'/'AMSAT-OSCAR N'
  (AO-7 -> OSCAR 7) so Linear lists keep AO-7 on SatNOGS-sourced data
- SSTV virtual type now uses the transceiver service class (Amateur only)
  plus launcher-debris name exclusion, dropping retired weather birds
  (TIROS) and rocket bodies (ARIANE 6 R/B) while keeping ISS/CSS/TEIKYOSAT
- Persist the AMSAT active-frequency whitelist (palewire mirror) on sync;
  used only to disambiguate multi-match names, never as a hard filter
2026-09-24 08:49:53 +08:00
atsunatsu 732412deb2 build: bump version to 4.4.7-ba7opf.15.2 (499) 2026-09-24 02:53:53 +08:00
atsunatsu 30000a913e fix: AMSAT name matching token-based; SSTV filters to in-orbit only
- matchesAmSatName: exact token match instead of substring contains,
  so 'ISS' no longer matches AISSAT-1 and 'AO-7' no longer matches
  AO-73 (FUNCUBE-1); resolves missing ISS in FM and missing AO-7 in
  Linear lists
- resolveTypeIds: intersect SSTV catnums with current in-orbit TLE set,
  excluding retired satellites (e.g. TIROS) whose radios are tagged SSTV
- add regression tests for token matching
2026-09-24 02:53:34 +08:00
atsunatsu e7754a4c9e build: bump version to 4.4.7-ba7opf.15.1 (498) 2026-09-24 00:51:21 +08:00
atsunatsu cf1e2b99b4 fix: transponder filters - mutual exclusion and beacon-free fallback
Two correctness fixes for the FM/Linear/SSTV filters:

1. Satellite type picker: virtual transponder types (AMSAT Live FM,
   AMSAT Live Linear, Live SSTV) are now mutually exclusive with the
   regular TLE-source types - ticking a virtual type clears Amateur/
   CubeSat/etc and vice versa, so selecting 'AMSAT Live FM' no longer
   unions in hundreds of non-transponder satellites. Virtual types
   still combine with each other. Also: an empty virtual-type resolve
   now yields an empty list instead of falling back to 'show all'.

2. Mutual-match fallback: when the AMSAT lists are empty (first run /
   sync failure) the DB mode filter used downlinkMode alone, so any
   downlink-only beacon sharing a mode label was misclassified
   (ISS/IO-86 CW beacons into Linear, FO-29's FM Digitalker into FM).
   New getIdsWithModesAndUplink() requires uplinkLow IS NOT NULL,
   matching real transponders only. SSTV filter keeps the old query
   (SSTV sats are often downlink-only).

Shared virtual-type names moved to Sources.virtualTypeNames.
2026-09-24 00:51:13 +08:00
atsunatsu b295500d14 build: bump version to 4.4.7-ba7opf.15 (497) 2026-09-23 23:26:03 +08:00
atsunatsu 0163c7b217 feat: satellite page transponder/activity filters as virtual types
The satellite selection screen's 'Select satellite type' dialog now lists
three virtual types first (AMSAT Live FM, AMSAT Live Linear, Live SSTV)
before the TLE-source types. Selecting one filters the satellite list to
that transponder/activity set; multiple selections union. 'All' is kept.

- SelectionRepo.getTypesList: virtual types prepended (All preserved)
- SelectionRepo.resolveTypeIds: FM/Linear -> AMSAT catnum sets,
  Live SSTV -> mode=SSTV radios; other types via persisted IDs
- Tests: virtual-type filtering, union, clear, list-order assertion
2026-09-23 23:25:52 +08:00
atsunatsu 85a4b9cfa3 feat: mutual pass FM/Linear transponder filters (AMSAT Live source)
Mutual match page gains FM/Linear transponder-type filters. The
authoritative source is AMSAT's human-maintained Live FM/Linear
Satellites pages, fetched during data sync and stored as catnum sets;
filters resolve to those sets (falling back to DB mode filter when
empty), and unchecking both shows a prompt instead of querying.

- DataParser.parseAmSatLivePage: table rows to satellite names,
  TEVEL2-1 thru TEVEL2-9 range expansion, bracket-alias matching
- SettingsRepo: AMSAT FM/Linear catnum sets persisted in prefs
- DatabaseRepo: fetch AMSAT pages in updateFromRemote, best-effort
  (failure keeps previous list, never blocks the update)
- getIdsWithModes: AND isAlive = 1
- MutualScreen: FilterChip row inside MatchSearchCard
- Tests: parser (4) + viewmodel (3) + repo fakes
2026-09-23 23:25:37 +08:00
atsunatsu c4c4dd682d build: bump to 4.4.7-ba7opf.14.2 2026-09-23 15:54:59 +08:00
atsunatsu 62ad995aeb feat(map): multiple marked stations per grid with pin-to-top
- MarkedStation storage upgraded to an ordered list per grid
  (Map<String, List<MarkedStation>>); JSON array shape with legacy
  v14.1 single-object migration on read
- MapAction: SetMarkedStation appends (duplicate callsign ignored),
  RemoveMarkedStation deletes by callsign, PinMarkedStation moves a
  mark to the top of the list
- MaidenheadGridOverlay: red fill for any marked grid unchanged;
  first-call label mode shows the FIRST (pinned) marked callsign
- MapScreen: one row per mark (pale-yellow callsign), pin icon
  (冖 + stroke ↑, drawn in-app) left of the Marked label — shown only
  when the grid has 2+ marks; pinned row's icon grey, others pale
  yellow; delete icon per row
- New ic_pin.xml vector drawable; strings grid_mark_pin
2026-09-23 15:47:17 +08:00
atsunatsu c8fdd2e93e build: bump to 4.4.7-ba7opf.14.1 2026-09-23 15:15:47 +08:00
atsunatsu dc013f920d feat(map,mutual): uppercase callsign input; mutual match honors historical pass window
- MapScreen: mark-a-station callsign input uppercases as you type
- MutualViewModel: read passesSettings.hoursBefore, shift the mutual
  match query start to now-hoursBefore so historical passes are
  included when the main Passes page has a lookback window set;
  endTime stays at now+hoursAhead
2026-09-23 15:08:46 +08:00
atsunatsu f36a426855 build: bump to 4.4.7-ba7opf.14 2026-09-23 13:26:00 +08:00
atsunatsu 4da59bdd17 feat(map): mark a station you want to contact in unworked grids
- New MarkedStation model + ISettingsRepo/SettingsRepo persistence
  (single JSON key markedGridStations, org.json only)
- MapState/MapViewModel: markedGrids state, Set/RemoveMarkedStation
  actions; marks auto-clear once the grid becomes worked
- MaidenheadGridOverlay: red fill (alpha 90, same as green worked
  fill) for marked grids at both zoom levels; first-call label mode
  shows the marked callsign in pale yellow on red cells
- MapScreen: unworked-grid dialog gains a 'mark a station you want
  to contact' button + callsign input dialog; marked row shows red
  callsign, 'Marked' in the count column, marking date, delete icon
- i18n: values/ + values-zh/ (grid_mark_*)
2026-09-23 13:14:38 +08:00
atsunatsu b8138c948b build: bump to 4.4.7-ba7opf.13 2026-09-22 16:41:59 +08:00
atsunatsu 0e28231c4a feat(map): run the Match query before navigating to the mutual page
Tapping the grid-QSO dialog "Match" button now starts the pass query on
the map page and only navigates to the match page once it finishes, so the
page's first frame already contains the results — the time-range card at
the top and the pass curves right below, with no scroll flicker. The dialog
button shows a spinner and is disabled while calculating. A uiState
collector in MainScreen performs the actual navigation (still a push, so
the back gesture returns to the map). Failures/exceptions still navigate so
the user is never stuck on the map page.
2026-09-22 16:41:55 +08:00
atsunatsu d6bdf5ecb6 fix(mutual): keep scroll position when re-querying from the match page
queryMutualPasses() reset listScrollIndex to 0 on every query, so tapping
"query" on the match page jumped the list back to the top. Now the default
keeps the current position; the stale results stay on screen while the new
query runs (clearing them shrank the list below a screen and clamped the
position away) and are replaced when the new results arrive. New VM test
covers position + stale-results behaviour through a settled re-query.
2026-09-22 16:41:51 +08:00
atsunatsu 6962a4bfa1 fix(mutual): retry prefill scroll until it really lands
On a device the first frame of the match page only contains the station
cards and the time-range card, which can be shorter than the viewport —
scrollToItem(1) then has no scroll range and silently does nothing, and
by the time the async query results grow the list past one screen the
one-shot flag is already consumed. Retry every 100ms (up to 2s) until
firstVisibleItemIndex reaches the time-range card. Verified with a new
Robolectric test that grows the content after a delay. Bump 12.6.
2026-09-22 15:50:31 +08:00
atsunatsu 94a1a4f7b7 diagnostics(mutual): log prefill scroll path under tag Look4SatMutual
Temporary Log.d probes at prefill, list-layout gate, scroll effect entry,
scrollToItem outcome and consume, to locate why the map Match prefill
scroll does not land on the device (Robolectric covers it). Bump 12.5.
2026-09-22 15:13:47 +08:00
atsunatsu dbf2229774 chore: bump version to 4.4.7-ba7opf.12.4 (versionCode 490) 2026-09-22 14:49:32 +08:00
atsunatsu f2fc1b7b28 test(mutual): portrait-size Robolectric + NavDisplay entry scenario
Run the scroll tests at 411x891dp portrait (device-like layout) and add
a NavDisplay entryProvider scenario that composes MutualScreen the way
the app does, so the prefill scroll is exercised end-to-end.
2026-09-22 14:49:04 +08:00
atsunatsu d600836e19 fix(mutual): gate prefill scroll on real list layout (onGloballyPositioned)
On a device the Mutual entry is composed inside the NavDisplay transition;
LaunchedEffect could run before the LazyColumn is measurable and the
scrollToItem then never lands, leaving the page at the top. Only scroll
once onGloballyPositioned confirms the list has actually been laid out.
2026-09-22 14:49:00 +08:00
atsunatsu 94bffe80b0 chore: bump version to 4.4.7-ba7opf.12.3 (versionCode 489) 2026-09-22 13:13:11 +08:00
atsunatsu 4260fbb096 fix(nav): map Match prefill pushes Mutual onto the stack
The grid-QSO dialog Match button now adds Mutual on top of the Map entry
instead of clearing the stack, so the system back gesture pops back to
the map page (the page the user came from). Bottom-nav tab switches keep
their replace semantics.
2026-09-22 13:13:08 +08:00
atsunatsu 3d5b55faa3 test(mutual): Robolectric Compose UI tests for prefill scroll; bump 4.4.7-ba7opf.12.2
MutualMatchPrefillScrollTest verifies the map grid-QSO Match prefill:
time-range card lands at the top after scrollToItem, default open stays
at station inputs, and the query auto-starts with grid+24h pre-filled.
Adds Robolectric + compose ui-test-junit4 to the mutual test classpath.
2026-09-22 12:23:55 +08:00
atsunatsu 466f2f6088 fix(mutual): map Match prefill scrolls to time-range card via scrollToItem
Compose ui 1.11.x (BOM 2026.06.01) ignores the LazyListState
firstVisibleItemIndex constructor parameter - the list always starts at
item 0. Restore the scrollToItem approach (verified effective by
Robolectric Compose UI tests) and keep the auto-started query.
2026-09-22 12:23:50 +08:00
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
101 changed files with 8004 additions and 407 deletions

No files matched your search

+2 -3
View File
@@ -6,13 +6,12 @@
## 本仓库特色功能
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
@@ -24,6 +24,14 @@ import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
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.logbook.toConfirmedRecord
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 java.text.SimpleDateFormat
import java.util.Date
@@ -41,6 +49,8 @@ class MainApplication : Application(), IContainerProvider {
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
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
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -79,4 +89,42 @@ 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)
// Feed the logbook so confirmations appear and local uploads get matched.
runCatching {
val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) }
container.qsoRepository.mergeLoTW(confirmed)
}
println("Periodic LoTW grid sync finished: $count worked grids")
} else {
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
}
}
}
@@ -81,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
@@ -104,33 +105,47 @@ fun NavRoot(deeplink: String? = null) {
// Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it }
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = rootBackStack,
onBack = navigateBack,
transitionSpec = { 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)
// 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
@@ -155,6 +170,34 @@ fun MainScreen(
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()
)
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
// grid and run the query BEFORE navigating, so the page's first frame
// already contains the results (time-range card at the top, pass curves
// right below — no scroll flicker). Navigation happens once the query
// finishes (see the collector below).
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
LaunchedEffect(mutualViewModel) {
mutualViewModel.uiState.collect { state ->
if (state.pendingNavigation && !state.isCalculating) {
mutualViewModel.consumePendingNavigation()
// Push Mutual on top of the Map entry instead of replacing the
// stack (bottom-nav style): the system back gesture then pops
// back to the map page, which is the page the user came from.
if (backStack.lastOrNull() !is Screen.Mutual) {
backStack.add(Screen.Mutual)
}
}
}
}
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
@@ -223,7 +266,18 @@ fun MainScreen(
)
}
entry<Screen.Map> {
MapDestination()
MapDestination(
mapFilterViewModel = mapFilterViewModel,
matchCalculating = matchCalculating,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill
// the match page for that grid and start the
// query. Navigation to the match page is
// handled by the uiState collector above,
// once the query finishes.
mutualViewModel.prefillMatchFromGrid(grid)
}
)
}
entry<Screen.Mutual> {
MutualScreen(
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
ksp(libs.androidx.room.compiler)
implementation(libs.kotlin.coroutines)
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)
}
}
}
Binary file not shown.
@@ -45,9 +45,22 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
suspend fun getIdsWithModes(modes: List<String>): List<Int>
/** Like [getIdsWithModes] but only matches transponder records with an uplink,
* excluding downlink-only beacons/telemetry that share the same mode label
* (e.g. CW beacons, FM voice-synthesis beacons) from FM/Linear filter fallback. */
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
/** Like [getIdsWithModes] but only matches records whose service class is
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
* launcher debris or other non-amateur transmitters that happen to carry
* an SSTV-labelled downlink. */
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
suspend fun getRadiosTotal(): Int
@@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao
}
@@ -37,3 +37,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
}
}
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
}
}
@@ -0,0 +1,50 @@
/*
* 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.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface QsoDao {
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
fun observeAll(): Flow<List<QsoEntity>>
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
suspend fun find(id: Long): QsoEntity?
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
suspend fun getAll(): List<QsoEntity>
@Query("SELECT dedupeKey FROM qso_records")
suspend fun getDedupeKeys(): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(record: QsoEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun importRecords(records: List<QsoEntity>): List<Long>
@Transaction
suspend fun saveBatch(records: List<QsoEntity>) {
records.forEach { save(it) }
}
@Query("DELETE FROM qso_records WHERE id = :id")
suspend fun delete(id: Long)
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>)
}
@@ -0,0 +1,33 @@
/*
* 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.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
/**
* Separate database for the logbook (QSO records, LoTW confirmations and
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
}
}
@@ -0,0 +1,55 @@
/*
* 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.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "qso_records",
indices = [Index(value = ["dedupeKey"])]
)
data class QsoEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val startUtcMillis: Long,
val endUtcMillis: Long?,
val theirCallsign: String,
val myCallsign: String,
val theirGrid: String,
val myGrid: String,
val sentReport: String,
val receivedReport: String,
val txFrequencyHz: Long?,
val rxFrequencyHz: Long?,
val band: String,
val rxBand: String,
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
val satelliteName: String,
val transponderName: String,
val satelliteMode: String,
val passAosUtcMillis: Long?,
val automatic: Boolean,
val status: String,
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null,
@ColumnInfo(defaultValue = "''") val region: String = "",
@ColumnInfo(defaultValue = "''") val comment: String = ""
)
@@ -33,6 +33,10 @@ data class SatRadio(
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?,
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
* Used to tell amateur satellites apart from debris / weather birds
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
val service: String? = null,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -25,13 +25,18 @@ import android.location.LocationManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
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.RadioTrackingService
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.QsoRepository
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
@@ -45,8 +50,10 @@ 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.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
@@ -73,6 +80,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
class MainContainer(private val context: Context) : IMainContainer {
@@ -88,6 +96,13 @@ class MainContainer(private val context: Context) : IMainContainer {
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -169,14 +184,23 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.fallbackToDestructiveMigration(false)
.build()
return LocalSource(database.look4SatDao())
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
// amsat.org live pages are slow (7-10s+ from some networks); the
// OkHttp defaults (10s read timeout) made every AMSAT Live update
// time out, silently keeping stale FM/Linear lists forever.
val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(60, TimeUnit.SECONDS)
.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -0,0 +1,171 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
import org.w3c.dom.Element
import org.xml.sax.InputSource
import org.xml.sax.SAXException
import java.io.InputStream
import java.io.StringReader
import java.util.Locale
import java.util.zip.GZIPInputStream
import javax.xml.parsers.DocumentBuilderFactory
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
internal class LoTWConfig(input: InputStream) {
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
// Android's DOM provider does not support Xerces-specific setFeature flags.
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
}.parse(InputSource(StringReader(xml))).documentElement
} }
private val config = root.elements("tqslconfig").single()
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
private val bands = config.elements("bands").single().elements("band")
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
fun mode(record: QsoRecord): String {
val display = record.displayMode
if (display in modes) return display
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
return mapped ?: fail(LoTWProblem.MODE, display)
}
fun band(explicit: String, frequency: Long?, required: Boolean): String {
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
val mhz = hz / unit
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
}?.textContent }
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
if (value.isBlank() && !required && frequency == null) return ""
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
return value
}
fun satellite(name: String, date: String): String {
val normalized = name.trim().uppercase(Locale.US)
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
// first, then accept any satellite whose official name is a substring of the
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
// contact is unavailable for upload.
val sat = satellites[normalized]
?: satellites.entries.firstOrNull { (official, _) ->
official.length > 1 && normalized.contains(official)
}?.value
?: fail(LoTWProblem.SATELLITE, normalized)
val first = sat.getAttribute("startDate")
val last = sat.getAttribute("endDate")
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
return sat.getAttribute("name").uppercase(Locale.US)
}
fun propagation(value: String): String {
val normalized = value.trim().uppercase(Locale.US)
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
fail(LoTWProblem.INVALID_CONTACT, normalized)
}
return normalized
}
/** Region-field metadata and the national zonemap for one DXCC entity. */
fun stationMeta(dxcc: Int): LoTWStationMeta {
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
.flatMap { it.elements("pageField") }.map { it.textContent }
val primary = pageFields.firstOrNull { it in primaryRegionFields }
val regionField = primary?.let { id ->
val field = config.elements("field").single { it.getAttribute("Id") == id }
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
val options = field.elements("enums").filter {
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
}.flatMap { it.elements("enum") }.map { enum ->
LoTWRegionOption(
code = enum.getAttribute("value").uppercase(Locale.US),
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
zones = parseZonemap(enum.getAttribute("zonemap"))
)
}
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
}
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
return LoTWStationMeta(regionField, countryZones)
}
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
val parts = pair.split(':')
if (parts.size != 2) return@mapNotNull null
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
LoTWZonePair(itu, cq)
}
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
fun normalized(value: String) = value.trim().uppercase(Locale.US)
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
fail(LoTWProblem.STATION_GRID)
}
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
// station field that TQSL stores but does not sign (same precedent as IOTA).
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
fun zone(name: String, value: String, maximum: Int) {
if (value.isBlank()) return
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
fields[name] = number.toString()
}
zone("CQZ", station.cqZone, 40)
zone("ITUZ", station.ituZone, 90)
if (station.iota.isNotBlank()) {
val iota = normalized(station.iota)
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
fields["IOTA"] = iota
}
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
.flatMap { it.elements("pageField") }.map { it.textContent }
val primary = pageFields.firstOrNull { it in primaryRegionFields }
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
fun region(name: String?, value: String) {
if (value.isBlank()) return
if (name == null) fail(LoTWProblem.STATION_REGION)
val field = config.elements("field").single { it.getAttribute("Id") == name }
val dependency = when (field.getAttribute("dependsOn")) {
"DXCC" -> dxcc.toString()
else -> fields[field.getAttribute("dependsOn")]
}
val options = field.elements("enums").filter {
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
val code = normalized(value)
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
fields[name] = code
}
region(primary, station.region)
region(secondary, station.county)
return fields
}
}
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
(0 until nodes.length).map { nodes.item(it) as Element }
}
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
@@ -0,0 +1,145 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.Date
import java.util.Locale
import java.util.TimeZone
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
companion object {
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
val key: PrivateKey
val cert: X509Certificate
val store = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) {
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
// that fails too, report the real reason (format vs password).
if (isPbes2(bytes)) {
try {
val parsed = Pkcs12Reader.read(bytes, password)
parsed.first to parsed.second
} catch (_: Exception) {
fail(if (password.isNotEmpty()) LoTWProblem.CERTIFICATE_FORMAT else LoTWProblem.CERTIFICATE_PASSWORD)
}
} else {
fail(LoTWProblem.CERTIFICATE_PASSWORD)
}
}
if (store is Pair<*, *>) {
@Suppress("UNCHECKED_CAST")
key = store.first as PrivateKey
@Suppress("UNCHECKED_CAST")
cert = store.second as X509Certificate
} else {
val ks = store as KeyStore
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
val alias = aliases.single()
key = try { ks.getKey(alias, password) as? PrivateKey }
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
?: fail(LoTWProblem.CERTIFICATE_INVALID)
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
}
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
return LoTWKeyMaterial(key, cert, info)
}
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
private fun isPbes2(bytes: ByteArray): Boolean {
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
if (bytes.size < oid.size) return false
outer@ for (i in 0..bytes.size - oid.size) {
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
return true
}
return false
}
private fun metadata(cert: X509Certificate): LoTWCertificate {
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
require(subject.tag == 0x30)
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
val pair = attribute.children()
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
}
val call = calls.single().trim().uppercase(Locale.US)
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
val first = extension(cert, "2")
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
val dxcc = extension(cert, "4").toInt()
require(dxcc > 0)
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
}
private fun extension(cert: X509Certificate, suffix: String): String {
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
val outer = DerValue.read(bytes).single()
require(outer.tag == 4)
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
} else DerValue.read(outer.data).single().text().trim()
}
}
}
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
}.getOrDefault(false)
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
internal data class DerValue(val tag: Int, val data: ByteArray) {
fun children(): List<DerValue> = read(data)
fun text(): String = when (tag) {
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
30 -> data.toString(Charsets.UTF_16BE)
else -> error("Not an ASN.1 string")
}
companion object {
fun read(bytes: ByteArray): List<DerValue> {
val result = mutableListOf<DerValue>()
var offset = 0
while (offset < bytes.size) {
require(bytes.size - offset >= 2)
val tag = bytes[offset++].toInt() and 255
var length = bytes[offset++].toInt() and 255
if (length and 128 != 0) {
val size = length and 127
require(size in 1..3 && size <= bytes.size - offset)
length = 0
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
}
require(length <= bytes.size - offset)
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
offset += length
}
return result
}
}
}
@@ -0,0 +1,84 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.io.ByteArrayOutputStream
import java.math.BigDecimal
import java.security.MessageDigest
import java.security.Signature
import java.util.Locale
import java.util.zip.GZIPOutputStream
import kotlin.io.encoding.Base64
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
internal class LoTWSigner(private val config: LoTWConfig) {
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
val call = record.theirCallsign.trim().uppercase(Locale.US)
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
fail(LoTWProblem.QSO_DATE, call)
}
val grids = buildList {
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
}.filter(String::isNotBlank)
if (record.myGrid.isNotBlank() && grids.none { grid ->
val local = record.myGrid.trim().uppercase(Locale.US)
grid.startsWith(local) || local.startsWith(grid)
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
val fields = linkedMapOf(
"BAND" to config.band(record.band, record.txFrequencyHz, true),
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
"CALL" to call,
"FREQ" to mhz(record.txFrequencyHz),
"FREQ_RX" to mhz(record.rxFrequencyHz),
"MODE" to config.mode(record),
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
"QSO_DATE" to date,
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
).filterValues { it.isNotBlank() }
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
fields.entries.joinToString("") { field(it.key, it.value) }
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return LoTWContact(record, fields, signData, hash)
}
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
val text = buildString {
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
append("<eor>\n")
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
station.forEach { (name, value) -> append(field(name, value)) }
append("<eor>\n")
contacts.forEach { contact ->
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
contact.fields.forEach { (name, value) -> append(field(name, value)) }
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
val signature = Signature.getInstance("SHA1withRSA").run {
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
}
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
}
}
return ByteArrayOutputStream().also { output ->
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
}.toByteArray()
}
}
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
@@ -0,0 +1,61 @@
package com.rtbishop.look4sat.core.data.lotw
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.AtomicFile
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal interface LoTWStorage {
fun read(name: String): ByteArray?
fun write(name: String, data: ByteArray)
fun delete(name: String)
}
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
private val directory = File(context.noBackupFilesDir, "lotw")
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
private fun file(name: String): AtomicFile {
require(name.matches(Regex("[a-z]+")))
check(directory.isDirectory || directory.mkdirs())
return AtomicFile(File(directory, name))
}
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
).run {
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256).build())
generateKey()
}
@Synchronized override fun read(name: String): ByteArray? {
val file = file(name)
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
val bytes = file.readFully()
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
return Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
updateAAD(name.toByteArray(Charsets.UTF_8))
doFinal(bytes, 13, bytes.size - 13)
}
}
@Synchronized override fun write(name: String, data: ByteArray) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, key())
updateAAD(name.toByteArray(Charsets.UTF_8))
}
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
val file = file(name)
val stream = file.startWrite()
try { stream.write(bytes); file.finishWrite(stream) }
catch (error: Exception) { file.failWrite(stream); throw error }
}
@Synchronized override fun delete(name: String) = file(name).delete()
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
}
@@ -0,0 +1,384 @@
package com.rtbishop.look4sat.core.data.lotw
import android.content.Context
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
val result = StringBuilder()
val buffer = CharArray(8192)
while (true) {
val count = reader.read(buffer)
if (count < 0) break
require(result.length <= limit - count) { "Response exceeds size limit" }
result.append(buffer, 0, count)
}
result.toString()
}
class LoTWUploadRepository internal constructor(
private val storage: LoTWStorage,
private val config: () -> LoTWConfig,
client: OkHttpClient = OkHttpClient(),
private val now: () -> Long = System::currentTimeMillis
) : ILoTWUploadRepository {
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
private val mutex = Mutex()
@Volatile private var pending: Pending? = null
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
mutex.withLock {
val bytes = storage.read("certificate") ?: return@withLock null
try {
readBundle(bytes).let { bundle ->
bundle.p12.fill(0)
bundle.password?.fill(0)
bundle.info.copy(passwordSaved = bundle.password != null)
}
} finally { bytes.fill(0) }
}
}
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
val passwordBytes = encodePassword(password)
try {
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
val key = LoTWKeyMaterial.read(data, password, now())
val bytes = encodeBundle(key.info, data, passwordBytes)
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
discardPreview()
key.info.copy(passwordSaved = true)
} finally {
passwordBytes.fill(0)
password.fill('\u0000')
data.fill(0)
}
}
}
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val passwordBytes = encodePassword(password)
try {
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
discardPreview()
key.info.copy(passwordSaved = true)
} finally {
bundle.p12.fill(0)
bundle.password?.fill(0)
passwordBytes.fill(0)
password.fill('\u0000')
}
}
}
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
mutex.withLock { readStation() }
}
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val normalized = station.normalized()
try {
config().stationFields(normalized, bundle.info.dxcc)
} catch (e: LoTWOperationException) {
// A region saved under a different certificate no longer applies to
// this DXCC entity — drop it instead of failing the whole save.
if (e.reason != LoTWProblem.STATION_REGION) throw e
val cleaned = normalized.copy(region = "", county = "")
config().stationFields(cleaned, bundle.info.dxcc)
writeStation(cleaned)
discardPreview()
return@withLock cleaned
} finally {
bundle.p12.fill(0)
bundle.password?.fill(0)
}
writeStation(normalized)
discardPreview()
normalized
}
}
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
mutex.withLock { discardPreview(); storage.delete("certificate") }
}
override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
config().stationMeta(dxcc)
}
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
mutex.withLock {
val signing = signingContext()
val ledger = ledger()
val unique = hashSetOf<String>()
var pendingCount = 0
var uploaded = 0
var unknown = 0
var unavailable = 0
records.sortedBy { it.startUtcMillis }.forEach { record ->
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> unavailable++
record.lotwReceived -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (_: LoTWOperationException) {
unavailable++
null
}
if (contact != null) when {
!unique.add(contact.fingerprint) -> unavailable++
ledger[contact.fingerprint] == "accepted" -> uploaded++
ledger[contact.fingerprint] == "unknown" -> unknown++
else -> pendingCount++
}
}
}
}
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
}
}
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
mutex.withLock {
discardPreview()
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
val signing = signingContext()
val ledger = ledger()
var skipped = 0
var unknown = 0
val unique = hashSetOf<String>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
val contact = signing.signer.contact(record, signing.key, signing.location, now())
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
else -> contact
}
}
val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped,
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
signing.signer.sign(contacts, signing.key, signing.location),
contacts.map { it.fingerprint },
now()
)
preview
}
}
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
mutex.withLock {
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
?: return@withLock LoTWUploadResult.ExpiredPreview
pending = null // A preview is a one-shot authorization, including across failures.
val ledger = ledger()
payload.hashes.forEach { ledger[it] = "unknown" }
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
try {
val result = post(payload.data, payload.preview.count)
when (result) {
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
else -> Unit
}
saveLedger(ledger)
result
} finally { payload.data.fill(0) }
}
}
override fun discardPreview() { pending?.data?.fill(0); pending = null }
private fun signingContext(): SigningContext {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val passwordBytes = bundle.password ?: run {
bundle.p12.fill(0)
fail(LoTWProblem.CERTIFICATE_PASSWORD)
}
val password = decodePassword(passwordBytes)
return try {
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
val tqslConfig = config()
val signer = LoTWSigner(tqslConfig)
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
} finally {
bundle.p12.fill(0)
passwordBytes.fill(0)
password.fill('\u0000')
}
}
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
DataInputStream(stream).use { input ->
require(input.readInt() == 1)
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
}
}
private fun writeStation(station: LoTWStation) {
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
output.writeInt(1)
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
} }.toByteArray()
try { storage.write("station", bytes) } finally { bytes.fill(0) }
}
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
}
override fun onResponse(call: Call, response: Response) {
val result = try {
response.use {
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
}
} catch (_: Exception) { LoTWUploadResult.Unknown }
if (continuation.isActive) continuation.resume(result)
}
})
}
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
DataInputStream(stream).use { input ->
require(input.readInt() == 1)
val size = input.readInt().also { require(it in 0..200_000) }
val entries = linkedMapOf<String, String>()
repeat(size) { entries[input.readUTF()] = input.readUTF() }
entries
}
} ?: linkedMapOf()
private fun saveLedger(entries: Map<String, String>) {
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
output.writeInt(1); output.writeInt(entries.size)
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
} }.toByteArray()
storage.write("receipts", bytes)
}
}
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
return when (markers.singleOrNull()) {
"accepted" -> LoTWUploadResult.Accepted(count)
"rejected" -> {
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
LoTWUploadResult.Rejected(message)
}
else -> LoTWUploadResult.Unknown
}
}
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
DataOutputStream(stream).use { output ->
output.writeInt(2)
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
output.writeInt(password.size); output.write(password)
output.writeInt(p12.size); output.write(p12)
}
}.toByteArray()
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
val version = input.readInt().also { require(it in 1..2) }
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
val password = if (version >= 2) {
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
ByteArray(size).also { input.readFully(it) }
} else null
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
}
private fun encodePassword(password: CharArray): ByteArray {
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
return ByteArray(buffer.remaining()).also(buffer::get)
}
private fun decodePassword(password: ByteArray): CharArray {
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
return CharArray(buffer.remaining()).also(buffer::get)
}
private fun LoTWStation.normalized() = LoTWStation(
grid.trim().uppercase(Locale.US),
cqZone.trim(),
ituZone.trim(),
region.trim().uppercase(Locale.US),
county.trim().uppercase(Locale.US),
iota.trim().uppercase(Locale.US)
)
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
@@ -0,0 +1,255 @@
/*
* 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.lotw
import java.io.ByteArrayInputStream
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
*
* Handles both layouts:
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
* inside a plaintext `data` ContentInfo.
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
* ContentInfo.
*/
internal object Pkcs12Reader {
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
val pfx = DerReader.read(bytes)
require(pfx.tag == 0x30) { "not a PFX" }
val pfxChildren = DerReader.children(pfx.content)
require(pfxChildren.size >= 2) { "PFX too small" }
// authSafe ContentInfo
val authSafeCi = DerReader.children(pfxChildren[1].content)
require(authSafeCi.size >= 2) { "authSafe malformed" }
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
val explicit = DerReader.children(authSafeCi[1].content).first()
val octet = DerReader.read(explicit.content)
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
var privateKey: PrivateKey? = null
val certs = mutableListOf<X509Certificate>()
for (info in DerReader.children(octet.content)) {
val parts = DerReader.children(info.content)
if (parts.isEmpty()) continue
when {
parts[0].content.contentEquals(OID_DATA) ->
parseSafeContentsContent(parts, password, certs) { privateKey = it }
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
parseEncryptedData(parts, password, certs)
}
}
val key = privateKey ?: error("no private key bag found")
require(certs.isNotEmpty()) { "no certificate bag found" }
return key to certs[0]
}
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
private fun parseSafeContentsContent(
parts: List<Der>,
password: CharArray,
certs: MutableList<X509Certificate>,
onKey: (PrivateKey) -> Unit
) {
val explicit = DerReader.children(parts[1].content).first()
parseSafeBags(explicit.content, password, certs, onKey)
}
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
private fun parseEncryptedData(
parts: List<Der>,
password: CharArray,
certs: MutableList<X509Certificate>
) {
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
val explicit = DerReader.children(parts[1].content).first()
val eciParts = DerReader.children(explicit.content)
// eciParts = [version INT, EncryptedContentInfo SEQ]
require(eciParts.size >= 2) { "EncryptedData malformed" }
val eci = DerReader.children(eciParts[1].content)
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
val alg = eci[1]
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
val safeContents = decryptPbes2(alg, ciphertext, password)
// The decrypted SafeContents holds certificate bags.
parseSafeBags(safeContents, password, certs) {}
}
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
val algParts = DerReader.children(algorithm.content)
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
val pbes2 = DerReader.children(algParts[1].content)
val kdf = DerReader.children(pbes2[0].content)
val kdfParams = DerReader.children(kdf[1].content)
val salt = kdfParams[0].content
val iterations = readInt(kdfParams[1].content)
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
// an absurd key size and a PBKDF2 that runs for hours.
var keyBits = 256
var prfName = "PBKDF2WithHmacSHA1"
for (i in 2 until kdfParams.size) {
val param = kdfParams[i]
when (param.tag) {
0x02 -> keyBits = readInt(param.content) * 8
0x30 -> {
val prf = DerReader.children(param.content)
prfName = if (prf.isNotEmpty() && prf[0].content.contentEquals(OID_HMAC_SHA1))
"PBKDF2WithHmacSHA1" else "PBKDF2WithHmacSHA256"
}
}
}
require(keyBits in 128..512) { "implausible PBKDF2 key size" }
val enc = DerReader.children(pbes2[1].content)
val encOid = enc[0].content
val iv = enc[1].content
require(encOid.contentEquals(OID_AES_256_CBC) || encOid.contentEquals(OID_AES_128_CBC)) { "unsupported cipher" }
val spec = PBEKeySpec(password, salt, iterations, keyBits)
val secretKey = SecretKeyFactory.getInstance(prfName).generateSecret(spec)
// PBKDF2 factories return a PBE key; wrap the raw bytes as an AES key.
val aesKey = SecretKeySpec(secretKey.encoded, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
return cipher.doFinal(encrypted)
}
private fun parseSafeBags(
safeContentsDer: ByteArray,
password: CharArray,
certs: MutableList<X509Certificate>,
onKey: (PrivateKey) -> Unit
) {
val safeContents = DerReader.read(safeContentsDer)
require(safeContents.tag == 0x30) { "SafeContents malformed" }
for (bag in DerReader.children(safeContents.content)) {
val bagParts = DerReader.children(bag.content)
if (bagParts.size < 2) continue
val bagOid = bagParts[0].content
val bagValue = DerReader.children(bagParts[1].content).first()
when {
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
onKey(decryptShroudedKeyBag(bagValue, password))
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
}
}
}
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
// [AlgorithmIdentifier, encryptedData OCTET STRING].
val parts = DerReader.children(bagValue.content)
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
}
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
// bagValue = the SafeBag value SEQ (CertBag): children are
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
val parts = DerReader.children(bagValue.content)
if (parts.size < 2) return
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
val explicit = DerReader.children(parts[1].content).first()
val certDer = explicit.content
val factory = CertificateFactory.getInstance("X.509")
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
}
private fun readInt(bytes: ByteArray): Int {
var value = 0
var start = 0
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
return value
}
/** Minimal DER element parser. */
private data class Der(val tag: Int, val content: ByteArray)
private object DerReader {
fun read(der: ByteArray, offset: Int = 0): Der {
require(offset < der.size) { "DER truncated" }
val tag = der[offset].toInt() and 0xff
var i = offset + 1
var len = der[i].toInt() and 0xff
i++
if (len and 0x80 != 0) {
val numBytes = len and 0x7f
len = 0
repeat(numBytes) {
len = (len shl 8) or (der[i].toInt() and 0xff)
i++
}
}
require(i + len <= der.size) { "DER length overflow" }
return Der(tag, der.copyOfRange(i, i + len))
}
fun children(content: ByteArray): List<Der> {
val out = mutableListOf<Der>()
var off = 0
while (off < content.size) {
val d = read(content, off)
out.add(d)
val step = headerSize(content, off) + d.content.size
if (step <= 0) break // defensive: never spin on malformed input
off += step
}
return out
}
private fun headerSize(der: ByteArray, offset: Int): Int {
var i = offset + 1
var len = der[i].toInt() and 0xff
i++
if (len and 0x80 != 0) i += len and 0x7f
return i - offset
}
}
}
@@ -221,7 +221,22 @@ class AmSatRepository(
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
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)
}
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
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.CoroutineDispatcher
@@ -18,9 +18,13 @@
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
@@ -46,7 +50,7 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
@@ -58,9 +62,11 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
password: String,
since: String,
onProgress: (LoTWProgress) -> Unit
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
fetchReportBody(callsign, password, since, onProgress).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
@@ -87,33 +93,46 @@ class LoTWRepository : ILoTWRepository {
return Triple(grids, qsos, roamed)
}
private fun fetchReportBody(callsign: String, password: String): Result<String> {
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"))
// 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 return an empty/incremental report.
val since = "2000-01-01"
// 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(since, "UTF-8"))
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). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
// 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()
@@ -123,9 +142,8 @@ class LoTWRepository : ILoTWRepository {
else Result.failure(IOException("HTTP $code"))
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
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.
@@ -135,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
!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) {
@@ -145,6 +166,68 @@ class LoTWRepository : ILoTWRepository {
}
}
/**
* 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)
@@ -169,17 +252,40 @@ class LoTWRepository : ILoTWRepository {
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
// full grid set of the station location; the remaining MY_* fields
// together identify the station location (one callsign can own
// several locations, and one location can span several grids).
var myGrids = mutableSetOf<String>()
var myCallsign: String? = null
var myDxcc: String? = null
var myState: String? = null
var myCq: String? = null
var myItu: String? = null
var myIota: String? = null
var myCountry: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
val stationKey = buildString {
append(myCallsign ?: "")
append('|').append(myDxcc ?: "")
append('|').append(myState ?: "")
append('|').append(myCq ?: "")
append('|').append(myItu ?: "")
append('|').append(myIota ?: "")
append('|').append(myCountry ?: "")
}.ifBlank { null }
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
myGrid = myGrids.firstOrNull(),
myGrids = myGrids,
myCallsign = myCallsign,
stationKey = stationKey
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -190,7 +296,9 @@ class LoTWRepository : ILoTWRepository {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
myGrids = mutableSetOf()
myCallsign = null; myDxcc = null; myState = null
myCq = null; myItu = null; myIota = null; myCountry = null
gridsInRecord.clear()
}
@@ -228,10 +336,40 @@ class LoTWRepository : ILoTWRepository {
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.
// can be counted per operated grid. A station location may
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
}
line.startsWith("<MY_VUCC_GRIDS:") -> {
// Own-station VUCC grids: a comma-separated list LoTW emits
// when one station location spans several grid squares
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
// this field the extra grids of a multi-grid 台址 silently
// vanished from both the blue stripes and the per-台址 VUCC
// count. Same split/truncate as VUCC_GRIDS below.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) myGrids.add(field.take(4))
}
}
// Remaining MY_* fields identify the station location (台址):
// grouped together they form the stationKey used by the map's
// operated-grid selector. One callsign can own several 台址.
line.startsWith("<STATION_CALLSIGN:") ->
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
line.startsWith("<MY_DXCC:") ->
myDxcc = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_STATE:") ->
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_CQ_ZONE:") ->
myCq = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_ITU_ZONE:") ->
myItu = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_IOTA:") ->
myIota = adifValue(line).trim().uppercase().ifBlank { null }
line.startsWith("<MY_COUNTRY:") ->
myCountry = adifValue(line).trim().ifBlank { null }
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -337,23 +475,30 @@ class LoTWRepository : ILoTWRepository {
// 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
var myGrids = mutableSetOf<String>()
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
propMode = null
myGrid = null
myGrids = mutableSetOf()
}
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)
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
}
line.startsWith("<MY_VUCC_GRIDS:") -> {
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
// station location covered; without this the extra grids
// never reached the blue stripes.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) myGrids.add(field.take(4))
}
}
}
}
@@ -362,5 +507,16 @@ class LoTWRepository : ILoTWRepository {
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
}
}
@@ -0,0 +1,206 @@
/*
* 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.data.database.QsoDao
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.Locale
class QsoRepository(
private val dao: QsoDao,
private val dispatcher: CoroutineDispatcher
) : IQsoRepository {
private val importMutex = Mutex()
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
importMutex.withLock {
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
) else record
val received = if (previous != null && (
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
)) false else saved.lotwReceived
dao.save(saved.copy(lotwReceived = received).toEntity())
}
}
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
dao.markUploaded(ids)
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll()
.asSequence()
.filter { ids == null || it.id in ids }
.map(QsoEntity::toDomain)
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
.toList()
AdifCodec.encode(records)
}
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
importMutex.withLock {
mergeRecords(AdifCodec.decode(content))
}
}
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
}
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
}
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
val changes = linkedMapOf<Int, QsoRecord>()
var imported = 0
var updated = 0
var skipped = 0
records.forEach { remote ->
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) {
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L)
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added)
working += added
imported++
} else {
val previous = working[match]
val merged = previous.withConfirmation(remote)
if (merged == previous) skipped++ else {
working[match] = merged
changes[match] = merged
updated++
}
}
}
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
return AdifImportResult(imported, skipped, updated)
}
}
private fun QsoEntity.toDomain() = QsoRecord(
id = id,
startUtcMillis = startUtcMillis,
endUtcMillis = endUtcMillis,
theirCallsign = theirCallsign,
myCallsign = myCallsign,
theirGrid = theirGrid,
myGrid = myGrid,
sentReport = sentReport,
receivedReport = receivedReport,
txFrequencyHz = txFrequencyHz,
rxFrequencyHz = rxFrequencyHz,
band = band,
rxBand = rxBand,
mode = mode,
submode = submode,
satelliteName = satelliteName,
transponderName = transponderName,
satelliteMode = satelliteMode,
passAosUtcMillis = passAosUtcMillis,
automatic = automatic,
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
private fun QsoRecord.toEntity() = QsoEntity(
id = id,
startUtcMillis = startUtcMillis,
endUtcMillis = endUtcMillis,
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
myCallsign = myCallsign.trim().uppercase(Locale.US),
theirGrid = theirGrid.trim().uppercase(Locale.US),
myGrid = myGrid.trim().uppercase(Locale.US),
sentReport = sentReport.trim(),
receivedReport = receivedReport.trim(),
txFrequencyHz = txFrequencyHz,
rxFrequencyHz = rxFrequencyHz,
band = band,
rxBand = rxBand,
mode = mode.trim(),
submode = submode.trim(),
satelliteName = satelliteName,
transponderName = transponderName,
satelliteMode = satelliteMode,
passAosUtcMillis = passAosUtcMillis,
automatic = automatic,
status = status.name,
dedupeKey = stableQsoKey(this),
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
internal fun stableQsoKey(record: QsoRecord): String = listOf(
record.startUtcMillis.toString(),
record.theirCallsign.trim().uppercase(Locale.US),
record.myCallsign.trim().uppercase(Locale.US),
record.txFrequencyHz?.toString().orEmpty(),
record.mode.trim().uppercase(Locale.US),
record.submode.trim().uppercase(Locale.US),
record.satelliteName.trim().uppercase(Locale.US)
).joinToString("|")
@@ -65,6 +65,12 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
@@ -76,9 +82,11 @@ class SatelliteRepo(
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
val now = System.currentTimeMillis()
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = now - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
@@ -43,12 +43,15 @@ class SelectionRepo(
// Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
// SSTV transponder data instead of SharedPreferences.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
if (ids.isEmpty()) null else ids.toHashSet()
val ids = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
@@ -61,8 +64,46 @@ class SelectionRepo(
override fun getCurrentTypes() = currentTypes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
/**
* Resolves a list of type names to satellite catnums. The three virtual
* transponder/activity types are resolved from hardcoded FM/Linear lists
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
* data; all other types come from the per-type ID lists persisted by
* [ISettingsRepo].
*/
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
when (type) {
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
// Live SSTV: mode=SSTV transponder records whose service class
// is "Amateur", excluding launcher debris / rocket bodies
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
// carry an amateur payload transponder but are not satellites.
Sources.virtualTypeNames[2] -> {
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
val filtered = sstvIds.filter { catnum ->
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
!name.endsWith("R/B") && !name.endsWith("DEB")
}
idsSet.addAll(filtered)
}
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
}
}
return idsSet.toList()
}
override fun getTypesList() = buildList {
// 三个转发器/活动虚拟类型排在最前.
addAll(Sources.virtualTypeNames)
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
@@ -111,12 +152,29 @@ class SelectionRepo(
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* 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> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
val tokens = query.split(' ')
.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() }
}
@@ -27,6 +27,7 @@ import android.view.Display
import android.view.Surface
import com.rtbishop.look4sat.core.domain.predict.GeoPos
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -41,7 +42,12 @@ class SensorsRepo(
) : ISensorsRepo, SensorEventListener {
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
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 tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
@@ -50,6 +56,7 @@ class SensorsRepo(
private var hasInitialReading = false
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField(
@@ -62,15 +69,41 @@ class SensorsRepo(
override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
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 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) {
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
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 {
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -58,6 +59,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterHoursBefore = "filterHoursBefore"
private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
@@ -77,12 +79,15 @@ class SettingsRepo(
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors"
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -94,6 +99,8 @@ class SettingsRepo(
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyMapSource = "mapSource"
private val keyTiandituKey = "tiandituKey"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -103,6 +110,7 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -206,7 +214,20 @@ class SettingsRepo(
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 }
myGrid = o.optString("mg").ifBlank { null },
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
// pre-multi-grid data has only "mg" and falls back to a
// single-element set so every consumer sees myGrids.
myGrids = o.optJSONArray("mgs")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i).takeIf { it.isNotBlank() }
}.toSet()
}?.takeIf { it.isNotEmpty() }
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
?: emptySet(),
myCallsign = o.optString("mc").ifBlank { null },
stationKey = o.optString("sk").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
@@ -235,6 +256,9 @@ class SettingsRepo(
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
.put("mc", q.myCallsign ?: "")
.put("sk", q.stationKey ?: "")
)
}
root.put(grid, array)
@@ -265,6 +289,59 @@ class SettingsRepo(
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// Marked stations in unworked gridsquares, persisted as a single JSON
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
// ordered list (first = pinned/top). v14.1 stored a single object per
// grid; reads transparently migrate that old shape to a one-element list.
private val keyMarkedGridStations = "markedGridStations"
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
val json = preferences.getString(keyMarkedGridStations, 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.MarkedStation>>()
for (grid in root.keys()) {
val value = root.opt(grid)
val list = when (value) {
// v14.2+ shape: JSON array of station objects.
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
val o = value.optJSONObject(i) ?: return@mapNotNull null
val call = o.optString("c").ifBlank { return@mapNotNull null }
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
}
// v14.1 legacy shape: a single station object per grid.
is org.json.JSONObject -> {
val call = value.optString("c").ifBlank { null }
if (call == null) emptyList()
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
}
else -> emptyList()
}
if (list.isNotEmpty()) result[grid] = list
}
result
} catch (_: Exception) {
emptyMap()
}
}
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
val root = org.json.JSONObject()
for ((grid, list) in stations) {
val array = org.json.JSONArray()
for (station in list) {
array.put(
org.json.JSONObject()
.put("c", station.call)
.put("t", station.epochMs)
)
}
root.put(grid, array)
}
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
@@ -289,6 +366,24 @@ class SettingsRepo(
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
@@ -298,6 +393,7 @@ class SettingsRepo(
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putInt(keyFilterHoursBefore, settings.hoursBefore)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
@@ -309,6 +405,7 @@ class SettingsRepo(
private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
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 aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
@@ -322,7 +419,8 @@ class SettingsRepo(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
selectedModes,
hoursBefore
)
}
//endregion
@@ -417,7 +515,9 @@ class SettingsRepo(
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
// "All" is a real TLE source type (CelesTrak active group) whose ids
// must be persisted like any other type — skipping it made the "All"
// filter resolve to an empty set and show nothing.
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
@@ -517,20 +617,30 @@ class SettingsRepo(
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
_otherSettings.update { current ->
val new = transform(current)
val new = transform(current).let {
it.copy(
mapSource = MapSource.normalize(it.mapSource),
tiandituKey = it.tiandituKey.trim()
)
}
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putString(keyMapSource, new.mapSource)
putString(keyTiandituKey, new.tiandituKey)
}
new
}
@@ -538,17 +648,22 @@ class SettingsRepo(
private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, 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),
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()))
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
)
//endregion
@@ -570,6 +685,7 @@ class SettingsRepo(
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
putBoolean(keyAutoTleSourceMigration, true)
}
_dataSourcesSettings.value = normalized
}
@@ -580,7 +696,7 @@ class SettingsRepo(
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
).migrateSatnogsTleSource(),
).migrateSatnogsTleSource().migrateAutoTleSources(),
transceiversUrls = getDataSourceUrls(
key = keyTransceiversUrls,
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
@@ -641,6 +757,28 @@ class SettingsRepo(
return migrated
}
private fun List<String>.migrateAutoTleSources(): List<String> {
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
val autoTleUrls = listOfNotNull(
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
).filter { it.isNotBlank() }
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
if (missingUrls.isEmpty()) {
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
return this
}
val migrated = this + missingUrls
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
preferences.edit {
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
putBoolean(keyAutoTleSourceMigration, true)
}
return migrated
}
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
private fun isSameSourceUrl(first: String, second: String): Boolean =
@@ -739,6 +877,31 @@ class SettingsRepo(
}
//endregion
//region # Per-satellite logbook mode preset settings
private val keySatelliteModes = "satelliteModes"
override fun getSatelliteMode(catnum: Int): String {
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteMode(catnum: Int, mode: String) {
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
obj.toString()
} catch (_: Exception) {
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
}
preferences.edit { putString(keySatelliteModes, updated) }
}
//endregion
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
@@ -48,6 +48,8 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
@@ -83,12 +85,14 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
this.service, this.isCustom
)
private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
this.service, this.isCustom
)
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -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.
*/
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Parses the real bundled ARRL config.tq6 to verify region-field and zonemap extraction. */
class LoTWConfigTest {
private val config = LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream())
@Test
fun `china exposes province field with all 31 options and zonemaps`() {
val meta = config.stationMeta(318)
assertEquals("CN_PROVINCE", meta.regionField?.id)
assertEquals("Province", meta.regionField?.label)
assertEquals(31, meta.regionField?.options?.size)
val gd = meta.regionField?.options?.first { it.code == "GD" }
assertEquals("Guangdong", gd?.name)
assertEquals(listOf(LoTWZonePair(44, 24)), gd?.zones)
val gx = meta.regionField?.options?.first { it.code == "GX" }
assertEquals(listOf(LoTWZonePair(43, 24), LoTWZonePair(44, 24)), gx?.zones)
// China spans 8 zone pairs — the UI must NOT prefill from the national map.
assertTrue(meta.countryZones.size > 1)
}
@Test
fun `usa exposes state field with 49 continental options`() {
val meta = config.stationMeta(291)
assertEquals("US_STATE", meta.regionField?.id)
assertEquals("State", meta.regionField?.label)
// AK and HI are separate DXCC entities (6 and 110) with their own 1-option blocks.
assertEquals(49, meta.regionField?.options?.size)
assertTrue(meta.regionField!!.options.none { it.code == "AK" || it.code == "HI" })
assertTrue(meta.countryZones.size > 1)
}
@Test
fun `alaska and hawaii each expose their own single-state block`() {
val ak = config.stationMeta(6)
assertEquals(listOf("AK"), ak.regionField?.options?.map { it.code })
val hi = config.stationMeta(110)
assertEquals(listOf("HI"), hi.regionField?.options?.map { it.code })
}
@Test
fun `germany has no region field and one national zone pair`() {
val meta = config.stationMeta(230)
assertNull(meta.regionField)
assertEquals(listOf(LoTWZonePair(28, 14)), meta.countryZones)
}
@Test
fun `india has no region field and one national zone pair`() {
val meta = config.stationMeta(324)
assertNull(meta.regionField)
assertEquals(listOf(LoTWZonePair(41, 22)), meta.countryZones)
}
@Test
fun `unknown entity yields empty meta`() {
val meta = config.stationMeta(999999)
assertNull(meta.regionField)
assertTrue(meta.countryZones.isEmpty())
}
}
@@ -0,0 +1,73 @@
/*
* 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.lotw
import java.security.Signature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Verifies the PBES2/AES-256-CBC PKCS12 reader against a fixture generated with
* `openssl pkcs12 -export -certpbe NONE` — same layout as modern TQSL exports
* (plain certificate bags + PBES2-shrouded private key), which Android's legacy
* Bouncy Castle parser cannot read.
*/
class Pkcs12ReaderTest {
private fun fixture(): ByteArray =
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
@Test
fun readsPbes2KeyAndCertificate() {
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
assertEquals("RSA", key.algorithm)
assertEquals("RSA", cert.publicKey.algorithm)
assertNotNull(cert.subjectX500Principal)
}
@Test
fun keyPairsWithCertificate() {
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
val signed = Signature.getInstance("SHA1withRSA").run {
initSign(key); update(challenge); sign()
}
val valid = Signature.getInstance("SHA1withRSA").run {
initVerify(cert); update(challenge); verify(signed)
}
assertTrue("private key must match certificate", valid)
}
@Test
fun wrongPasswordThrows() {
assertThrows(Exception::class.java) {
Pkcs12Reader.read(fixture(), "wrongpass".toCharArray())
}
}
@Test
fun garbageBytesThrows() {
assertThrows(Exception::class.java) {
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
}
}
}
@@ -12,6 +12,10 @@ 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 {
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
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) {
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
private class FakeAmSatRemoteSource(
private val responseDelayMillis: Long = 0L,
private val reportsJson: String = """{"data":[]}"""
) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return """{"data":[]}"""
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))
@@ -127,6 +127,29 @@ class DatabaseRepoTest {
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
}
@Test
fun `remote update persists All source type ids`() = runTest(dispatcher) {
// Regression: "All" (CelesTrak active) is a real TLE source type whose
// ids must be persisted on sync — the old `if (type == "All") return`
// silently dropped them, making the "All" filter resolve to nothing.
val allUrl = Sources.satelliteDataUrls.getValue("All")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[allUrl] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(allUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
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
@@ -167,7 +190,8 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesTotal(): Int = insertedEntries.size
override suspend fun getEntriesList(): List<SatItem> = emptyList()
override suspend fun getEntriesList(): List<SatItem> =
insertedEntries.map { SatItem(it.catnum, it.name) }
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
@@ -180,6 +204,8 @@ private class FakeLocalSource : ILocalSource {
}
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = insertedRadios.size
@@ -272,6 +298,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun getSatelliteMode(catnum: Int): String = ""
override fun setSatelliteMode(catnum: Int, mode: String) {}
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
@@ -296,6 +324,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
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()
@@ -308,6 +340,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -169,6 +169,53 @@ class LoTWRepositoryTest {
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
@Test
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
// A station location (台址) can span several grids: LoTW emits
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
// grids must land in myGrids, and myGrid (back-compat) = the first.
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["NL47"]!!.first()
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
assertEquals("OM60", parsed.myGrid)
// 6-char fields are truncated to 4 like every other grid path.
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
}
@Test
fun parseQsosBuildsStationKeyFromMyFields() {
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
// stable key the operated-grid selector groups by.
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
"<GRIDSQUARE:4>NL47\n<EOR>\n"
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
assertEquals("BH6RJD", parsed.myCallsign)
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
}
@Test
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
// and a Zhejiang location); different MY_STATE -> different stationKey.
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@@ -224,6 +271,30 @@ class LoTWRepositoryTest {
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseRoamedGridsCollectsMyVuccGrids() {
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
// may be absent); every field must reach the roamed set for the stripes.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
// Combined with MY_GRIDSQUARE the union is kept.
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
}
@Test
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
// labels (regression: take(4) without .uppercase() silently dropped
// stripes for lowercase grids).
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@@ -0,0 +1,365 @@
/*
* 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())
}
@Test
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
// satellite (99999) is not.
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
val repo = createRepo(items)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
}
@Test
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live Linear"))
val results = repo.getEntriesFlow().first()
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
}
@Test
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
// only catnums the DAO would have filtered to Amateur service (43803).
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
// payload); it must be dropped even though its radio is Amateur.
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
val repo = createRepo(
items = items,
sstvIds = listOf(43803, 60239)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803)
)
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
}
@Test
fun `clearing types restores full list`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
repo.setTypes(emptyList())
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
}
@Test
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
// 类型应显示空列表, 而非全部卫星.
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
val repo = createRepo(items)
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
@Test
fun `virtual type combined with regular type unions both lists`() = runTest {
val repo = createRepo(items = sampleItems)
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
val results = repo.getEntriesFlow().first()
assertEquals(
setOf(25544, 7530, 39444, 43803),
results.map { it.catnum }.toSet()
)
}
@Test
fun `types list has virtual transponder types first and keeps All`() {
val repo = createRepo(sampleItems)
val types = repo.getTypesList()
assertTrue(types.size >= 4)
assertEquals("AMSAT Live FM", types[0])
assertEquals("AMSAT Live Linear", types[1])
assertEquals("Live SSTV", types[2])
// "All" must be kept in the list (first original type shown after virtual ones).
assertTrue("All" in types)
}
@Test
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
// Regression: "All" is a real TLE source type (CelesTrak active). Its
// ids are persisted on sync like any other type; selecting it must
// show the synced satellites, NOT an empty list.
val fake = FakeSettingsRepoForSearch()
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
val repo = SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(sampleItems),
settingsRepo = fake
)
repo.setTypes(listOf("All"))
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
@Test
fun `getTypesList excludes only the Other placeholder`() = runTest {
val repo = createRepo(sampleItems)
val types = repo.getTypesList()
// All real TLE source types must be selectable, including "All"
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
assertTrue("All" in types)
assertTrue("ARISS" in types)
assertTrue("R4UAB" in types)
assertTrue("SatNOGS" in types)
assertTrue("Other" !in types)
}
@Test
fun `all types shows all items`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(emptyList()) // "All" = no type filtering
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.size, results.size)
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
private fun createRepo(
items: List<SatItem>,
sstvIds: List<Int> = emptyList()
): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
private class FakeLocalSourceForSearch(
private val items: List<SatItem>,
private val sstvIds: List<Int> = emptyList()
) : 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> = sstvIds
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
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 {
private val typeIds = mutableMapOf<String, List<Int>>()
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> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
typeIds[type] = ids
}
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 getSatelliteMode(catnum: Int): String = ""
override fun setSatelliteMode(catnum: Int, mode: 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 getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = 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
}
Binary file not shown.
@@ -0,0 +1,163 @@
/*
* 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.logbook
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC")
fun encode(records: List<QsoRecord>): String = buildString {
append(field("ADIF_VER", "3.1.7"))
append(field("PROGRAMID", "Look4Sat"))
append("<EOH>\r\n")
records.forEach { record ->
val startDate = format(record.startUtcMillis, "yyyyMMdd")
val startTime = format(record.startUtcMillis, "HHmmss")
append(field("QSO_DATE", startDate))
append(field("TIME_ON", startTime))
record.endUtcMillis?.let {
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
append(field("TIME_OFF", format(it, "HHmmss")))
}
append(field("CALL", record.theirCallsign))
append(field("STATION_CALLSIGN", record.myCallsign))
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid)
appendOptional("MY_GRIDSQUARE", record.myGrid)
appendOptional("RST_SENT", record.sentReport)
appendOptional("RST_RCVD", record.receivedReport)
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
appendOptional("BAND", record.band)
appendOptional("BAND_RX", record.rxBand)
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
appendOptional("SAT_NAME", record.satelliteName)
appendOptional("SAT_MODE", record.satelliteMode)
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
record.dxcc?.let { append(field("DXCC", it.toString())) }
appendOptional("COUNTRY", record.country)
record.cqZone?.let { append(field("CQZ", it.toString())) }
appendOptional("STATE", record.region)
appendOptional("COMMENT", record.comment)
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
append(field("APP_LOOK4SAT_STATUS", record.status.name))
append("<EOR>\r\n")
}
}
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
val date = values["QSO_DATE"].orEmpty()
val time = values["TIME_ON"].orEmpty()
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
val start = parse(date, time) ?: return@mapNotNull null
val end = values["QSO_DATE_OFF"]?.let { endDate ->
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
}
QsoRecord(
startUtcMillis = start,
endUtcMillis = end,
theirCallsign = call,
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
sentReport = values["RST_SENT"].orEmpty(),
receivedReport = values["RST_RCVD"].orEmpty(),
txFrequencyHz = mhzToHz(values["FREQ"]),
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
band = values["BAND"].orEmpty(),
rxBand = values["BAND_RX"].orEmpty(),
mode = values["MODE"].orEmpty(),
submode = values["SUBMODE"].orEmpty(),
satelliteName = values["SAT_NAME"].orEmpty(),
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
satelliteMode = values["SAT_MODE"].orEmpty(),
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
?: QsoStatus.COMPLETE,
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
dxcc = values["DXCC"]?.toIntOrNull(),
country = values["COUNTRY"].orEmpty(),
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
comment = values["COMMENT"].orEmpty()
)
}
private fun splitRecords(content: String): List<Map<String, String>> {
val records = mutableListOf<Map<String, String>>()
var values = linkedMapOf<String, String>()
var index = 0
while (index < content.length) {
val start = content.indexOf('<', index)
if (start < 0) break
val end = content.indexOf('>', start + 1)
if (end < 0) break
val descriptor = content.substring(start + 1, end)
val parts = descriptor.split(':')
val name = parts.first().trim().uppercase(Locale.US)
index = end + 1
when (name) {
"EOH" -> values.clear()
"EOR" -> {
if (values.isNotEmpty()) records += values.toMap()
values = linkedMapOf()
}
else -> {
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
if (length < 0 || index + length > content.length) continue
values[name] = content.substring(index, index + length)
index += length
}
}
}
return records
}
private fun StringBuilder.appendOptional(name: String, value: String) {
if (value.isNotBlank()) append(field(name, value))
}
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
private fun format(value: Long, pattern: String): String =
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
private fun parse(date: String, time: String): Long? = runCatching {
val normalizedTime = if (time.length == 4) "${time}00" else time
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
isLenient = false
timeZone = utc
}.parse(date + normalizedTime)?.time
}.getOrNull()
}
@@ -0,0 +1,25 @@
/*
* 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.logbook
import kotlinx.coroutines.flow.Flow
interface IQsoRepository {
val records: Flow<List<QsoRecord>>
suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long)
suspend fun markUploaded(ids: List<Long>)
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
}
@@ -0,0 +1,28 @@
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
* so the logbook can show downloaded confirmations and mark local uploads
* as confirmed (matched by [sameConfirmedContact]). */
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs,
theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(),
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(),
band = bandUp,
rxBand = bandDown,
mode = mode,
// On satellites, MODE=MFSK means FT4 (ADIF pairing); keep the sub-mode so
// confirmations match local FT4 records (mode=MFSK, submode=FT4).
submode = if (mode.equals("MFSK", true)) "FT4" else "",
satelliteName = satName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
dxcc = dxcc,
country = country.orEmpty(),
cqZone = cqz,
region = state.orEmpty()
)
@@ -0,0 +1,70 @@
package com.rtbishop.look4sat.core.domain.logbook
import java.util.Locale
import kotlin.math.abs
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
* parenthetical-free prefix so local records and downloaded confirmations match. */
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode &&
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
).joinToString("|")
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
band = band.ifBlank { confirmed.band },
rxBand = rxBand.ifBlank { confirmed.rxBand },
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
status = QsoStatus.COMPLETE,
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
dxcc = confirmed.dxcc ?: dxcc,
country = confirmed.country.ifBlank { country },
cqZone = confirmed.cqZone ?: cqZone,
region = confirmed.region.ifBlank { region }
)
val QsoRecord.displayMode: String
get() {
// Satellite FT4 is MODE=MFSK + SUBMODE=FT4; any other mode keeps its own
// label even when a stale submode default ("FT4") was persisted.
val label = if (mode.equals("MFSK", true) && submode.isNotBlank()) submode else mode
return label.trim().uppercase(Locale.US)
}
val QsoRecord.isSatellite: Boolean
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
fun frequencyBand(hz: Long?): String = when (hz) {
null -> ""
in 28_000_000L..29_700_000L -> "10M"
in 50_000_000L..54_000_000L -> "6M"
in 144_000_000L..148_000_000L -> "2M"
in 219_000_000L..225_000_000L -> "1.25M"
in 420_000_000L..450_000_000L -> "70CM"
in 902_000_000L..928_000_000L -> "33CM"
in 1_240_000_000L..1_300_000_000L -> "23CM"
in 2_300_000_000L..2_450_000_000L -> "13CM"
in 5_650_000_000L..5_925_000_000L -> "6CM"
in 10_000_000_000L..10_500_000_000L -> "3CM"
else -> ""
}
@@ -0,0 +1,49 @@
/*
* 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.logbook
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
data class QsoRecord(
val id: Long = 0L,
val startUtcMillis: Long,
val endUtcMillis: Long? = null,
val theirCallsign: String,
val myCallsign: String,
val theirGrid: String = "",
val myGrid: String = "",
val sentReport: String = "",
val receivedReport: String = "",
val txFrequencyHz: Long? = null,
val rxFrequencyHz: Long? = null,
val band: String = "",
val rxBand: String = "",
val mode: String = "FM",
val submode: String = "",
val satelliteName: String = "",
val transponderName: String = "",
val satelliteMode: String = "",
val passAosUtcMillis: Long? = null,
val automatic: Boolean = false,
val status: QsoStatus = QsoStatus.DRAFT,
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false,
val lotwUploaded: Boolean = false,
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(),
val dxcc: Int? = null,
val country: String = "",
val cqZone: Int? = null,
val region: String = "",
val comment: String = ""
)
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
@@ -49,8 +49,27 @@ data class GridQso(
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
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
* Kept as the first entry of [myGrids] for backward compatibility with
* consumers that predate multi-grid station locations. */
val myGrid: String? = null,
/** Every 4-char grid the station operated from for this QSO — ADIF
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
* list LoTW emits when one station location roams across several grid
* squares; MY_GRIDSQUARE may be absent on those records). A station
* location (台址) can legitimately span multiple grids, and the map's
* stripes and the operated-grid selector must show all of them. */
val myGrids: Set<String> = emptySet(),
/** Callsign of the station location this QSO was logged under (ADIF
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
* on its own — one callsign can have several station locations. */
val myCallsign: String? = null,
/** Stable key of the station location (台址) this QSO was logged under,
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
* operated-grid selector can group QSOs per 台址 and show each 台址 with
* its full grid set. Null for data synced before this field existed. */
val stationKey: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -0,0 +1,31 @@
/*
* 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
/**
* A station the user marked in a gridsquare they have NOT yet worked — a
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
* per grid).
*
* @param call the callsign the user wants to work (uppercase)
* @param epochMs the moment the mark was created, UTC milliseconds
*/
data class MarkedStation(
val call: String,
val epochMs: Long
)
@@ -33,6 +33,10 @@ data class SatRadio(
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?,
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
* Used by the SSTV virtual filter to tell amateur satellites apart from
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
@SerialName("service") val service: String? = null,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -20,7 +20,8 @@ data class SatSlot(
/** 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 slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
)
/** One satellite, 3 days of state */
@@ -30,7 +30,9 @@ data class PassesSettings(
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val selectedModes: List<String>,
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
val hoursBefore: Int = 0
)
data class RCSettings(
@@ -60,13 +62,36 @@ data class OtherSettings(
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 shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.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,
val mapSource: String = MapSource.OSM,
val tiandituKey: String = ""
)
object MapSource {
const val OSM = "osm"
const val TIANDITU_VECTOR = "tianditu_vector"
const val TIANDITU_IMAGE = "tianditu_image"
private const val LEGACY_TIANDITU = "tianditu"
fun normalize(source: String): String = when (source) {
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
LEGACY_TIANDITU -> TIANDITU_VECTOR
else -> OSM
}
}
data class DataSourcesSettings(
val satelliteUrls: List<String>,
val transceiversUrls: List<String>,
@@ -30,9 +30,43 @@ interface ILoTWRepository {
* 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
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 }
@@ -0,0 +1,96 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate?
suspend fun station(): LoTWStation?
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
suspend fun saveStation(station: LoTWStation): LoTWStation
suspend fun removeCertificate()
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
suspend fun upload(previewId: String): LoTWUploadResult
fun discardPreview()
}
data class LoTWCertificate(
val callsign: String,
val dxcc: Int,
val serial: String,
val expires: String,
val firstQsoDate: String,
val lastQsoDate: String,
val passwordSaved: Boolean = false
)
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
data class LoTWStation(
val grid: String = "",
val cqZone: String = "",
val ituZone: String = "",
val region: String = "",
val county: String = "",
val iota: String = ""
)
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
data class LoTWZonePair(val itu: Int, val cq: Int)
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
/**
* Everything the station-location UI needs for one DXCC entity:
* the region field to show (null when the country has none) and the national
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
*/
data class LoTWStationMeta(
val regionField: LoTWRegionField? = null,
val countryZones: List<LoTWZonePair> = emptyList()
)
data class LoTWUploadPreview(
val id: String,
val callsign: String,
val dxcc: Int,
val grid: String,
val count: Int,
val skipped: Int,
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val unknownSkipped: Int = 0
)
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
data class LoTWUploadAudit(
val total: Int,
val pending: Int,
val uploaded: Int,
val unknown: Int,
val unavailable: Int
)
sealed interface LoTWUploadResult {
data class Accepted(val count: Int) : LoTWUploadResult
data class Rejected(val message: String = "") : LoTWUploadResult
/** May have reached LoTW. Do not retry automatically. */
data object Unknown : LoTWUploadResult
data object ExpiredPreview : LoTWUploadResult
}
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
LOCATION_MISMATCH, TOO_MANY_CONTACTS
}
@@ -16,6 +16,7 @@
* 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.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
@@ -34,6 +35,8 @@ interface IMainContainer {
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val qsoRepository: IQsoRepository
val lotwUploadRepository: ILoTWUploadRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -65,4 +65,19 @@ interface ISatelliteRepo {
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** Satellite catnums whose radios carry any of the given downlink modes.
* Empty modes -> empty result; used to filter the mutual-match satellite
* set to transponder satellites (FM voice / linear). */
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
/** Like [getSatelliteIdsWithModes] but only matches records with an uplink
* (real transponders), excluding downlink-only beacons/telemetry that share
* the same mode label. Used by the FM/Linear fallback filter. */
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
/** Like [getSatelliteIdsWithModes] but only matches records whose service
* class is "Amateur", so SSTV never matches weather birds (TIROS),
* launcher debris or other non-amateur transmitters. */
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
}
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
enum class CompassAccuracy {
UNAVAILABLE,
UNRELIABLE,
LOW,
MEDIUM,
HIGH
}
interface ISensorsRepo {
val sensorData: StateFlow<Pair<Float, Float>>
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
val compassAccuracy: StateFlow<CompassAccuracy>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor()
fun disableSensor()
@@ -89,6 +89,12 @@ interface ISettingsRepo {
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # Per-satellite logbook mode preset settings
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
fun getSatelliteMode(catnum: Int): String
fun setSatelliteMode(catnum: Int, mode: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
@@ -105,10 +111,21 @@ interface ISettingsRepo {
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
/** Stations the user marked as "want to contact" in unworked gridsquares
* (grid -> ordered list of marks; the first is the pinned/top one). The
* whole grid clears automatically once it becomes worked. */
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
//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,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
}
@@ -46,3 +46,6 @@ sealed class 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 }
@@ -29,6 +29,8 @@ interface ILocalSource {
suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
@@ -21,6 +21,8 @@ object Sources {
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",
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
"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",
@@ -55,4 +57,25 @@ object Sources {
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json"
)
/**
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
* Replaces the AMSAT live-page fetch: stable on any network, no
* sync-time dependency, no stale-list or timeout failure modes.
*/
val amSatFmCatnums = setOf(27607, 25544, 61781)
/**
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
*/
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
/** Virtual satellite-selection types: transponder/activity filters shown
* at the top of the type picker. They resolve to live lists (hardcoded
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
* per-type IDs, and are mutually exclusive with the regular TLE-source
* types in the picker. */
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
}
@@ -0,0 +1,86 @@
/*
* 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.logbook
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AdifCodecTest {
@Test
fun encodeDecode_roundTrip() {
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
endUtcMillis = 1_700_000_000_100L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = "OM60",
myGrid = "OL62TI",
sentReport = "59",
receivedReport = "59",
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_805_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
satelliteMode = "FM",
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
lotwQslDate = "2026-09-20",
vuccGrids = listOf("OM60", "OM50")
)
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
assertEquals(record.theirCallsign, decoded.theirCallsign)
assertEquals(record.myCallsign, decoded.myCallsign)
assertEquals(record.satelliteName, decoded.satelliteName)
assertEquals("2M", decoded.band)
assertEquals("70CM", decoded.rxBand)
assertTrue(decoded.lotwConfirmed)
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
}
@Test
fun decode_lotwStyleReportFields() {
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
val adi = """
<EOH>
<CALL:6>BH6RJD
<QSO_DATE:8>20260916
<TIME_ON:6>051300
<MODE:2>FM
<SAT_NAME:5>SO-50
<PROP_MODE:3>SAT
<BAND:3>2M
<BAND_RX:4>70CM
<GRIDSQUARE:4>OM60
<MY_GRIDSQUARE:8>OM60IL70
<VUCC_GRIDS:9>OM60,OM50
<STATION_CALLSIGN:6>BH6RJD
<DXCC:3>318
<COUNTRY:5>China
<LOTW_QSL_RCVD:1>Y
<eor>
""".trimIndent()
val records = AdifCodec.decode(adi)
assertEquals(1, records.size)
val record = records.single()
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("SO-50", record.satelliteName)
assertEquals("FM", record.displayMode)
assertEquals("OM60IL70", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
}
}
@@ -0,0 +1,136 @@
/*
* 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.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LogbookMergeTest {
private val local = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "bg5jvm",
myCallsign = "ba7opf",
txFrequencyHz = 145_850_000L,
mode = "FM",
submode = "",
satelliteName = "SO-50 (SaudiOSCAR 50)",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
private val confirmed = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
mode = "FM",
submode = "",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50")
)
@Test
fun satMatchKey_stripsParenthetical() {
assertEquals("SO-50", satMatchKey("SO-50 (SaudiOSCAR 50)"))
assertEquals("SO-50", satMatchKey("SO-50"))
assertEquals("AO-91", satMatchKey("AO-91 (FUNcube-1)"))
assertEquals("IO-86", satMatchKey("IO-86"))
}
@Test
fun sameConfirmedContact_matchesAcrossSatNOGSAndOfficialName() {
assertTrue(sameConfirmedContact(local, confirmed))
}
@Test
fun sameConfirmedContact_rejectsDifferentSatellite() {
val other = confirmed.copy(satelliteName = "AO-91")
assertFalse(sameConfirmedContact(local, other))
}
@Test
fun sameConfirmedContact_rejectsTimeSkew() {
val skewed = confirmed.copy(startUtcMillis = confirmed.startUtcMillis + 120_000L)
assertFalse(sameConfirmedContact(local, skewed))
}
@Test
fun confirmationLookupKey_usesNormalizedSatellite() {
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
}
@Test
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived)
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
// Local myCallsign is kept when present; only blanks are backfilled.
assertEquals("ba7opf", merged.myCallsign)
}
@Test
fun toConfirmedRecord_mapsGridQso() {
val qso = GridQso(
call = "BH6RJD",
epochMs = 1_700_000_000_000L,
satName = "SO-50",
mode = "FM",
bandUp = "70CM",
bandDown = "2M",
dxcc = 318,
country = "China",
cqz = 24,
state = "GD",
myGrid = "OM60",
myGrids = setOf("OM60", "OM50")
)
val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
assertEquals("70CM", record.band)
assertEquals("2M", record.rxBand)
}
@Test
fun frequencyBand_edges() {
assertEquals("2M", frequencyBand(144_000_000L))
assertEquals("70CM", frequencyBand(450_000_000L))
assertEquals("10M", frequencyBand(29_000_000L))
assertEquals("", frequencyBand(null))
}
@Test
fun displayMode_prefersSubmode() {
val ft4 = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "MFSK", submode = "FT4")
assertEquals("FT4", ft4.displayMode)
assertEquals("FM", local.displayMode)
}
@Test
fun displayMode_ignoresStaleSubmodeForOtherModes() {
// Regression: records persisted with the old default submode="FT4" must not
// show FT4 when the actual mode is FM/CW/SSB.
val staleFm = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "FM", submode = "FT4")
val staleCw = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "CW", submode = "FT4")
assertEquals("FM", staleFm.displayMode)
assertEquals("CW", staleCw.displayMode)
}
}
@@ -0,0 +1,296 @@
/*
* 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.DatabaseState
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
import com.rtbishop.look4sat.core.domain.model.MarkedStation
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.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWGridSyncTest {
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
private val now = 1_789_516_800_000L
// region resolveLoTWSyncMode
@Test
fun resolveModeHonorsExplicitFull() {
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
}
@Test
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
assertEquals(
LoTWSyncMode.Incremental,
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
)
}
@Test
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
}
// endregion
// region shouldAutoSyncLoTW
@Test
fun autoSyncGateRequiresEverything() {
val today = "20260916"
// Not configured.
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
// LoTW auto-sync toggle off.
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
// Never synced manually — first sync stays manual/full.
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
// All gates open — yesterday in either format.
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
}
// endregion
// region cursor helpers
@Test
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
assertEquals("2026-09-16", lotwCursorApi("20260916"))
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
assertEquals("", lotwCursorApi(""))
}
@Test
fun cursorDateToleratesBothFormats() {
assertEquals("20260916", lotwCursorDate("20260916"))
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
assertEquals("", lotwCursorDate(""))
}
@Test
fun cursorEpochMsParsesBothFormatsUtc() {
// 2026-09-16T00:00:00Z in both spellings.
assertEquals(now, lotwCursorEpochMs("20260916"))
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
assertEquals(0L, lotwCursorEpochMs(""))
assertEquals(0L, lotwCursorEpochMs("garbage"))
}
// endregion
// region mergeGridQsos
@Test
fun mergeGridQsosDedupsByCallAndTime() {
val existing = mapOf(
"OL62" to listOf(qso("BA7ABC", 1000L)),
"OL63" to listOf(qso("BA7DEF", 2000L))
)
val fresh = mapOf(
// Same grid, same QSO re-delivered by an overlapping since-date.
"OL62" to listOf(qso("BA7ABC", 1000L)),
// Same grid, brand-new QSO.
"OL62" to listOf(qso("BA7GHI", 3000L)),
// New grid entirely.
"PM95" to listOf(qso("BA7JKL", 4000L))
)
val merged = mergeGridQsos(existing, fresh)
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
assertEquals(1, merged["OL63"]?.size)
assertEquals(1, merged["PM95"]?.size)
}
@Test
fun mergeGridQsosHandlesEmptySides() {
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
// An empty incremental pull must leave the stored side untouched.
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
}
// endregion
// region applyLoTWGridResult
@Test
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62"))
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
setRoamedGrids(setOf("OL62"))
setLastLotwSyncDate("20260915")
setLastLotwSyncCallsign("BA7OPF")
}
val result = LoTWResult.Success(
grids = setOf("OL62", "PM95"),
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
assertEquals(2, count)
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
@Test
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62", "STALE"))
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
setRoamedGrids(setOf("STALE"))
setLastLotwSyncDate("20260910")
setLastLotwSyncCallsign("BG7OLD")
}
val result = LoTWResult.Success(
grids = setOf("PM95"),
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
assertEquals(1, count)
assertEquals(setOf("PM95"), repo.getWorkedGrids())
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
assertEquals(setOf("PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
// endregion
private fun qso(call: String, epochMs: Long): GridQso =
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
/**
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
*/
private class FakeSettingsRepo : ISettingsRepo {
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
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 = 10.0, selectedModes = emptyList())
)
override val databaseState: StateFlow<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(
stateOfAutoUpdate = true,
stateOfSensors = false,
stateOfSweep = false,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: StateFlow<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<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var lastSyncDate: String = ""
private var lastSyncCallsign: String = ""
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
override fun getLastLotwSyncDate(): String = lastSyncDate
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun getSatelliteMode(catnum: Int): String = ""
override fun setSatelliteMode(catnum: Int, mode: String) {}
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
}
}
@@ -17,9 +17,15 @@
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat
import java.util.Date
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Locale
import java.util.TimeZone
@@ -375,7 +397,10 @@ fun SharedDialog(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
.padding(
start = if (onCancel != null) padding else 0.dp,
end = if (onAccept != null) padding else 0.dp
)
)
if (onAccept != null) {
CardButton(
@@ -444,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
fun Modifier.infiniteMarquee(): Modifier =
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
class SwipeController {
var openKey by mutableStateOf<String?>(null)
private set
fun open(key: String) {
openKey = key
}
fun close() {
openKey = null
}
}
@Composable
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
@Composable
fun SwipeRevealRow(
key: String,
controller: SwipeController,
modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp,
revealAction: () -> Unit,
revealButton: @Composable () -> Unit = {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
},
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val revealPx = with(density) { revealWidth.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val isOpen = controller.openKey == key
fun settle(target: Float) {
settleJob?.cancel()
settleJob = scope.launch {
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
offsetX = value
}
}
}
// 另一行展开(或全部收回)时,本行随之收回。
LaunchedEffect(controller.openKey) {
if (!isOpen && offsetX < 0f) settle(0f)
}
Box(modifier.fillMaxWidth().clipToBounds()) {
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(revealWidth)
.offset(x = revealWidth)
.graphicsLayer { translationX = offsetX }
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
controller.close()
settle(0f)
revealAction()
}
) { revealButton() }
// 前景内容:右划平移露出按钮
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { translationX = offsetX }
.pointerInput(revealPx) {
detectHorizontalDragGestures(
onDragStart = { settleJob?.cancel() },
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
},
onDragEnd = {
if (offsetX < -revealPx / 2) {
controller.open(key)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
settle(-revealPx)
} else {
controller.close()
settle(0f)
}
},
onDragCancel = {
if (offsetX < -revealPx / 2) {
controller.open(key)
settle(-revealPx)
} else {
controller.close()
settle(0f)
}
}
)
}
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (isOpen) {
controller.close()
settle(0f)
}
}
) { content() }
}
}
@Composable
fun Modifier.layoutPadding(): Modifier {
val spacing = LocalSpacing.current.extraSmall
@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 冖 (秃宝盖): 顶横 + 两侧短竖, 笔画型 -->
<path
android:pathData="M5,4 L19,4 M5,4 L5,8.5 M19,4 L19,8.5"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- 笔画型向上箭头 ↑: 竖杆 + 两斜线箭头头 -->
<path
android:pathData="M12,9 L12,20 M12,9 L7.5,14 M12,9 L16.5,14"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
</vector>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filtrar pases</string>
<string name="pass_filter_elev">Elevación mínima</string>
<string name="pass_filter_hours">Tiempo por delante</string>
<string name="pass_filter_history_hours">Tiempo anterior</string>
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
@@ -31,6 +31,7 @@
<string name="pass_filter_title">Фильтровать пролеты</string>
<string name="pass_filter_elev">Минимальная элевация</string>
<string name="pass_filter_hours">Период расчёта</string>
<string name="pass_filter_history_hours">Прошедший период</string>
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
@@ -146,6 +147,7 @@
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
<string name="pass_filter_hours">ඉදිරි කාලය</string>
<string name="pass_filter_history_hours">පෙර කාලය</string>
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
@@ -70,7 +71,7 @@
<string name="map_next">ඊළග</string>
<string name="map_azimuth">දිගංශය: %.1f°</string>
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
<string name="map_altitude">උන්නතාශය: %.0f km</string>
<string name="map_distance">පරතරය: %.0f km</string>
<string name="map_latitude">අක්‍ෂාංශ: %.1f°</string>
<string name="map_longitude">දේශාංශ: %.1f°</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Geçişleri filtrele</string>
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
<string name="pass_filter_hours">Gösterilecek zaman</string>
<string name="pass_filter_history_hours">Geçmiş süre</string>
<string name="pass_filter_aos_time">AOS aralığı</string>
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
@@ -87,7 +88,7 @@
<!-- Map screen -->
<string name="map_prev">Önceki</string>
<string name="map_next">Sonraki</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_azimuth">Azimut: %.1f°</string>
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
<string name="map_altitude">İrtifa: %.0f km</string>
@@ -189,6 +190,7 @@
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
@@ -207,7 +209,8 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
\n• BG7NTA BG5JSU (CW decoding feature)
\n• BH2VSQ BG5JSU (TianDiTu rendering support)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Фільтр прольотів</string>
<string name="pass_filter_elev">Мінімальне піднесень</string>
<string name="pass_filter_hours">Час вперед</string>
<string name="pass_filter_history_hours">Час назад</string>
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
@@ -28,6 +28,7 @@
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_history_hours">历史过境时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
@@ -40,7 +41,9 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
@@ -109,6 +112,7 @@
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -121,13 +125,19 @@
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="grid_qso_match">匹配</string>
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
<string name="grid_mark_station">标记你想通联的电台</string>
<string name="grid_mark_callsign">呼号</string>
<string name="grid_mark_confirm">标记</string>
<string name="grid_mark_label">标记</string>
<string name="grid_mark_delete">删除标记</string>
<string name="grid_mark_pin">置顶</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
@@ -135,7 +145,7 @@
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
@@ -162,13 +172,54 @@
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
<string name="lotw_sync_full">全量同步</string>
<string name="lotw_sync_incremental">增量合并</string>
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="btn_delete">删除</string>
<string name="prefs_logbook_title">日志本</string>
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
<string name="prefs_logbook_close">关闭</string>
<string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
<string name="prefs_lotw_upload_busy">处理中…</string>
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
<string name="prefs_lotw_upload_password">证书密码</string>
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
<string name="prefs_lotw_upload_import_confirm">导入</string>
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
<string name="prefs_lotw_upload_error_format">该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入</string>
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
<string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_region_hint">未选择</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
@@ -225,10 +276,33 @@
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_map_title">地图设置</string>
<string name="prefs_map_configure">配置地图</string>
<string name="prefs_map_source_title">地图来源</string>
<string name="prefs_map_source_osm">离线 OSM 地图</string>
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_compass_calibration_button">校准指南针</string>
<string name="prefs_compass_calibration_title">指南针校准</string>
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
<string name="prefs_compass_accuracy">精度:%s</string>
<string name="prefs_compass_accuracy_high">高——校准完成</string>
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
<string name="prefs_compass_offset">手动航向偏置(°)</string>
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
<string name="prefs_compass_offset_reset">偏置归零</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
@@ -246,7 +320,10 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
\n• BG7NTA BG5JSU(CW解码功能)
\n• BH2VSQ BG5JSU(天地图渲染支持)
\n• BG5JSU(LoTW上传支持)
</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
@@ -272,6 +349,9 @@
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_filter_transponder">转发器</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">线性</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
@@ -33,6 +33,7 @@
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Time ahead</string>
<string name="pass_filter_history_hours">Time before</string>
<string name="pass_filter_aos_time">AOS window</string>
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
@@ -51,10 +52,7 @@
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
</string>
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
@@ -130,9 +128,11 @@
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_first_call_mode">First call</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -146,13 +146,19 @@
<string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="grid_qso_match">Match</string>
<!-- Mark-a-station reminder in unworked grids -->
<string name="grid_mark_station">Mark a station you want to contact</string>
<string name="grid_mark_callsign">Callsign</string>
<string name="grid_mark_confirm">Mark</string>
<string name="grid_mark_label">Marked</string>
<string name="grid_mark_delete">Remove mark</string>
<string name="grid_mark_pin">Pin to top</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
@@ -197,13 +203,54 @@
<string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
<string name="prefs_lotw_sync_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</string>
<string name="prefs_lotw_sync">Sync</string>
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
<string name="lotw_sync_error_not_configured">LoTW callsign/password not configured</string>
<string name="lotw_sync_error_credentials">LoTW sync failed — callsign or password incorrect</string>
<string name="lotw_sync_error_rate_limited">LoTW sync failed — rate limited by server, wait a few minutes and retry</string>
<string name="lotw_sync_error_timeout">LoTW sync failed — connection timed out, try another network</string>
<string name="lotw_sync_error_network">LoTW sync failed — network error (%1$s)</string>
<string name="lotw_sync_full">Full sync</string>
<string name="lotw_sync_incremental">Incremental merge</string>
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
<string name="lotw_sync_progress_downloading">Downloading report…</string>
<string name="lotw_sync_progress_saving">Processing downloaded data…</string>
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="btn_delete">Delete</string>
<string name="prefs_logbook_title">Logbook</string>
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
<string name="prefs_logbook_close">Close</string>
<string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
<string name="prefs_lotw_upload_busy">Working…</string>
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
<string name="prefs_lotw_upload_password">Certificate password</string>
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
<string name="prefs_lotw_upload_import_confirm">Import</string>
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
<string name="prefs_lotw_upload_error_format">This .p12 uses the new PBES2/AES-256 format (OpenSSL 3 / recent TQSL), which Android cannot read directly. Re-export it as a legacy format (TQSL "export with legacy encryption" or an OpenSSL -legacy conversion), then import again.</string>
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
<string name="prefs_lotw_upload_remove">Remove certificate</string>
<string name="prefs_lotw_upload_station_title">Station location</string>
<string name="prefs_lotw_upload_region_hint">Not selected</string>
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
<string name="prefs_lotw_upload_save">Save</string>
<string name="prefs_lotw_upload_close">Close</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
@@ -259,10 +306,33 @@
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_map_title">Map settings</string>
<string name="prefs_map_configure">Configure map</string>
<string name="prefs_map_source_title">Map source</string>
<string name="prefs_map_source_osm">Offline OSM</string>
<string name="prefs_map_source_tianditu_vector">TianDiTu vector</string>
<string name="prefs_map_source_tianditu_image">TianDiTu imagery</string>
<string name="prefs_map_tianditu_key">TianDiTu access key</string>
<string name="prefs_map_tianditu_key_help">Request a server-side application key from the TianDiTu developer console before using TianDiTu maps.</string>
<string name="prefs_map_tianditu_key_required">A TianDiTu key is required. Offline OSM remains active until it is entered.</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_compass_calibration_button">Calibrate compass</string>
<string name="prefs_compass_calibration_title">Compass calibration</string>
<string name="prefs_compass_calibration_instruction">Move the phone slowly in a figure-eight until accuracy improves. Keep it away from magnets and metal.</string>
<string name="prefs_compass_accuracy">Accuracy: %s</string>
<string name="prefs_compass_accuracy_high">High — calibration complete</string>
<string name="prefs_compass_accuracy_medium">Medium — keep moving</string>
<string name="prefs_compass_accuracy_low">Low — keep moving</string>
<string name="prefs_compass_accuracy_unreliable">Unreliable — start the figure-eight motion</string>
<string name="prefs_compass_accuracy_unavailable">Magnetic sensor unavailable</string>
<string name="prefs_compass_heading">Corrected heading: %.1f°</string>
<string name="prefs_compass_offset">Manual heading offset (°)</string>
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
<string name="prefs_compass_offset_reset">Reset offset</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
@@ -280,7 +350,9 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
\n* BG7NTA BG5JSU (CW decoding feature)
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
\n* BG5JSU (LoTW upload support)
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -307,6 +379,9 @@
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
<string name="mutual_min_elevation">Min elevation: %d°</string>
<string name="mutual_time_range">Time range</string>
<string name="mutual_filter_transponder">Transponders</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">Linear</string>
<string name="mutual_calculating">Calculating…</string>
<string name="mutual_query">Query Match</string>
<string name="mutual_elevation_you">You: %d°</string>
+1 -1
View File
@@ -1 +1 @@
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[-69.6203,60.2212]Line truncated
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
+1 -1
View File
@@ -1 +1 @@
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.177,44.5509],[-111.1808,44.569Line truncated
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0],[-109.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0],[-109.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.17Line truncated
@@ -46,16 +46,29 @@ class MaidenheadGridOverlay : Overlay() {
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
// 网格标签与首通呼号标签同字号 (用户要求 2026-09-21: 字体一样大)
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 26f
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
// 首通呼号标签: 与网格标签同字号.
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(230, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
// 标记(想通联)网格填充: 红色, 透明度与绿色 workedPaint 一致(alpha 90).
private val markedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 255, 59, 48)
}
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 14f
@@ -79,6 +92,21 @@ class MaidenheadGridOverlay : Overlay() {
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** Marked gridsquares (4-char, uppercase) — unworked grids the user has
* marked stations they want to contact in; drawn red, same alpha as the
* green worked fill. Value is the ordered mark list (first = pinned);
* label mode shows the FIRST marked callsign. */
var markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
/** Grid-mode first-call labels: 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. Only meaningful at sub-square zoom. */
var showFirstCallLabels: Boolean = false
/** Worked grid -> first callsign worked in it (earliest QSO by time).
* Only read when [showFirstCallLabels] is on. */
var firstCallsByGrid: Map<String, String> = emptyMap()
/**
* Gridsquares the station operated from (4-char, uppercase) — drawn with
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
@@ -172,6 +200,9 @@ class MaidenheadGridOverlay : Overlay() {
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
if (cellLabel(lat, lon, zoom) in markedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
} else {
@@ -198,6 +229,22 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
for (grid in markedGrids.keys) {
val cell = gridCellBounds(grid) ?: continue
for (turn in -colRepeats..colRepeats) {
val dLon = turn * 360.0
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
val yTop = projectionToY(projection, cell.latTop) ?: continue
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
}
@@ -226,7 +273,7 @@ class MaidenheadGridOverlay : Overlay() {
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
if (label in roamedGrids && label != ownGrid) {
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
}
}
}
@@ -246,7 +293,7 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
}
}
}
@@ -346,9 +393,15 @@ class MaidenheadGridOverlay : Overlay() {
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom).
// Field (2-char) labels show at every zoom, subject only to the pixel-
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
// (one level above the grid lines, so zoom 6 shows lines but no names).
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
// size check below; sub-square (4-char) labels appear together with the
// grid LINES at GRID_ZOOM_SUB (user req 2026-09-21: 与首通呼号同一显示缩放).
// First-call labels replace the 4-char grid codes and only exist at
// sub-square zoom; at field zoom NO labels are drawn at all (the user
// requirement is that non-worked cells carry no grid characters).
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
// 网格标签与首通呼号同一显示缩放 (用户要求 2026-09-21): 子方块(4字符)
// 标签与首通呼号都在网格线出现的 GRID_ZOOM_SUB 同时显示, 不再晚一档.
val showLabels = zoom >= GRID_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
@@ -356,10 +409,14 @@ class MaidenheadGridOverlay : Overlay() {
val y2 = projectionToY(projection, 1.0)
if (y1 == null || y2 == null) return
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
// 两种模式同一像素门限: GRID_ZOOM_SUB 处 1° 格约 45px, 低于 48px
// 常规阈值, 统一用放宽的 0.6x 门限, 保证网格标签与首通呼号同时出现.
val minCellPx = MIN_LABEL_CELL_PX * 0.6f
if (pixelsPerDegree * cellLat < minCellPx) return
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
activePaint.textAlign = Paint.Align.CENTER
val fontMetrics = activePaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) {
val lat = row * cellLat
@@ -386,7 +443,20 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
if (showFirstCallLabels) {
// 首通呼号模式: 绿格(worked)标注该格第一个通联的呼号, 红格(marked)
// 标注用户标记想通联的呼号, 均淡黄文字; 其它格空着不写网格字符.
when {
label in workedGrids -> firstCallsByGrid[label]?.let { call ->
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station ->
canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
}
} else {
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
}
@@ -396,17 +466,21 @@ class MaidenheadGridOverlay : Overlay() {
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
* stripes stay blue even over a green worked fill underneath.
*/
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) {
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float, zoom: Double) {
if (xRight <= xLeft || yBottom <= yTop) return
canvas.save()
canvas.clipRect(xLeft, yTop, xRight, yBottom)
val height = yBottom - yTop
// Geographic density is kept constant: spacing is the reference pixel
// spacing (STRIPE_SPACING_PX at the map's max zoom) scaled by 2^(zoom-max),
// so stripes shrink/grow with the map instead of staying fixed on screen.
val spacing = STRIPE_SPACING_PX * Math.pow(2.0, zoom - MAX_GRID_ZOOM).toFloat()
// Start one stripe-width left of the cell so the top-left corner is
// always covered; each stripe runs from (x, top) to (x+height, bottom).
var x = xLeft - height
while (x < xRight) {
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
x += STRIPE_SPACING_PX
x += spacing
}
canvas.restore()
}
@@ -428,7 +502,15 @@ class MaidenheadGridOverlay : Overlay() {
/** Y pixel for a latitude, or null when outside the viewport. */
private fun projectionToY(projection: Projection, lat: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
// Web Mercator is undefined beyond ±85.0511° (osmdroid clamps the world
// to TileSystemWebMercator limits). Projecting the polar field rows
// (lat=±90: Maidenhead row R = 80..90°N, row A = -90..-80°S) yields
// ±9.2e18 pixel coordinates that the canvas cannot rasterize, so the
// polar grid lines silently vanish. Clamp to the Mercator limit: the
// polar rows then project onto the screen edge and their lines are
// drawn (the off-limit sliver beyond 85.05° is invisible anyway).
val clamped = lat.coerceIn(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT)
val geo = org.osmdroid.util.GeoPoint(clamped, 0.0)
val p = projection.toPixels(geo, null)
return p.y.toFloat()
}
@@ -484,19 +566,28 @@ class MaidenheadGridOverlay : Overlay() {
const val FIELD_LON = 20.0
const val SUB_SQUARE_LAT = 1.0
const val SUB_SQUARE_LON = 2.0
// Web Mercator latitude limit (osmdroid TileSystemWebMercator): projecting
// beyond it produces ±9.2e18 pixel coordinates the canvas cannot draw.
const val MAX_MERCATOR_LAT = 85.05112877980658
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
// the sub-square grid is too dense to read; 6 roughly doubles the
// on-screen size of each square.
const val GRID_ZOOM_SUB = 6.0
// Zoom at which the 4-char sub-square names appear (one level above the
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
const val LABEL_ZOOM_SUB = 6.5
// 网格标签与首通呼号标签统一字号 (用户要求 2026-09-21: 字体一样大).
const val LABEL_TEXT_SIZE = 20f
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
// sparse GridMaster-style zebra.
const val STRIPE_SPACING_PX = 44f
/**
* Reference zoom for stripe spacing: the map's max zoom (MapScreen sets
* maxZoomLevel = 7.0). At this zoom stripes are STRIPE_SPACING_PX apart;
* at lower zooms spacing scales by 2^(zoom-max) so the geographic
* density matches the max-zoom look at every level.
*/
const val MAX_GRID_ZOOM = 7.0
}
}
@@ -0,0 +1,39 @@
package com.rtbishop.look4sat.feature.map
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AwardType
/**
* Session-scoped holder for the map's award filter selection.
*
* Scoped to the Activity (not the navigation entry) so the selection survives
* switching to another page and back; it resets to VUCC only when the process
* starts fresh (cold start), which is what the user expects as the default.
*/
class MapFilterViewModel : ViewModel() {
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
var mapCenterLat: Double? = null
var mapCenterLon: Double? = null
var mapZoom: Double? = null
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
mapCenterLat = centerLat
mapCenterLon = centerLon
mapZoom = zoom
}
companion object {
fun factory() = viewModelFactory {
initializer { MapFilterViewModel() }
}
}
}
File diff suppressed because it is too large. Load diff
@@ -21,16 +21,25 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.model.MapSource
data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val mapSource: String = MapSource.OSM,
val tiandituKey: String = "",
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
val showFirstCallLabels: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
val roamedGrids: Set<String> = emptySet(),
/** Stations the user marked in unworked gridsquares (grid -> ordered list;
* first = pinned/top) — red fill. */
val markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -48,6 +57,10 @@ sealed interface MapAction {
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetMarkedStation(val grid: String, val call: String) : MapAction
data class RemoveMarkedStation(val grid: String, val call: String) : MapAction
data class PinMarkedStation(val grid: String, val call: String) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
@@ -63,6 +63,8 @@ class MapViewModel(
MapState(
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
mapSource = settingsRepo.otherSettings.value.mapSource,
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
orbitalPass = defaultPass
)
)
@@ -79,16 +81,32 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
mapSource = settings.mapSource,
tiandituKey = settings.tiandituKey,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
}
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = settingsRepo.getWorkedGrids(),
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids()
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
}
@@ -105,6 +123,39 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
is MapAction.SetMarkedStation -> {
val call = action.call.trim().uppercase()
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
// 重复呼号忽略: 同一网格已标记过的呼号不再重复添加.
val updated = if (current.any { it.call == call }) existing
else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation(
call = call,
epochMs = System.currentTimeMillis()
))
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.RemoveMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val remaining = current.filterNot { it.call == action.call }
val updated = if (remaining.isEmpty()) existing - action.grid
else existing + (action.grid to remaining)
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.PinMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val target = current.firstOrNull { it.call == action.call }
if (target != null) {
val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call })
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
}
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
+17 -1
View File
@@ -4,4 +4,20 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
testOptions {
unitTests {
// Robolectric needs real Android resources for Compose UI tests.
isIncludeAndroidResources = true
}
}
}
dependencies {
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.navigation3)
debugImplementation(libs.compose.debug.manifest)
}
@@ -45,6 +45,7 @@ import com.rtbishop.look4sat.core.presentation.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.math.roundToInt
/**
@@ -58,6 +59,7 @@ fun ElevationCurveChart(
startTime: Long,
endTime: Long,
maxElev: Double,
isUtc: Boolean = false,
progress: Float = 0.5f,
onProgressChange: (Float) -> Unit = {},
modifier: Modifier = Modifier
@@ -74,7 +76,11 @@ fun ElevationCurveChart(
val textColorArgb = textColor.toArgb()
val onSurfaceArgb = onSurfaceColor.toArgb()
val gridColorArgb = gridColor.toArgb()
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val timeFormat = remember(isUtc) {
SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
if (isUtc) timeZone = TimeZone.getTimeZone("UTC")
}
}
Column(modifier = modifier.fillMaxWidth()) {
Canvas(
@@ -17,6 +17,7 @@
*/
package com.rtbishop.look4sat.feature.mutual
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -29,8 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@@ -40,6 +43,7 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -47,13 +51,17 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -71,6 +79,9 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun MutualScreen(
@@ -147,9 +158,10 @@ fun MutualScreen(
}
) { isVertical ->
MutualContent(
viewModel = viewModel,
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
onQuery = { viewModel.queryMutualPasses() },
onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat,
@@ -162,6 +174,8 @@ fun MutualScreen(
onStationBMinElev = viewModel::onStationBMinElev,
onUseCurrentPosition = viewModel::onUseCurrentPosition,
onHoursAhead = viewModel::onHoursAhead,
onFilterFM = viewModel::onFilterFM,
onFilterLinear = viewModel::onFilterLinear,
onClearError = viewModel::clearError
)
}
@@ -169,6 +183,7 @@ fun MutualScreen(
@Composable
private fun MutualContent(
viewModel: MutualViewModel,
state: MutualUiState,
isVertical: Boolean,
onQuery: () -> Unit,
@@ -184,12 +199,54 @@ private fun MutualContent(
onStationBMinElev: (Double) -> Unit,
onUseCurrentPosition: () -> Unit,
onHoursAhead: (Int) -> Unit,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onClearError: () -> Unit
) {
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
val timeFormat = remember(state.isUtc) {
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()).apply {
if (state.isUtc) timeZone = TimeZone.getTimeZone("UTC")
}
}
// List state survives page switches via the Activity-scoped ViewModel: the
// initial position is restored from the VM, and scrolling writes back to it,
// so returning to this page keeps exactly where the user left off. A new
// query (queryGeneration bump) rebuilds the state at the top.
val listState = remember(viewModel.queryGeneration) {
LazyListState(
firstVisibleItemIndex = viewModel.listScrollIndex,
firstVisibleItemScrollOffset = viewModel.listScrollOffset
)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
viewModel.listScrollIndex = index
viewModel.listScrollOffset = offset
}
}
// Set once the list has actually been laid out. The prefill scroll only
// fires after this, because on a real device the Mutual entry appears
// through the NavDisplay transition and LaunchedEffect alone can run
// before the list is measurable (the scroll then never lands and the
// page stays at the top). onGloballyPositioned fires on the first real
// layout, so the subsequent scrollToItem always has a measured list.
var listReady by remember { mutableStateOf(false) }
Log.d(TAG, "composed: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=${if (state.errorMessage != null) 2 else 1} listReady=$listReady")
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
if (!listReady) {
Log.d(TAG, "lazy list laid out, opening prefill scroll gate")
listReady = true
}
},
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Error message
@@ -213,7 +270,7 @@ private fun MutualContent(
}
// Input form
item {
item(key = "stationInputs") {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
@@ -290,16 +347,25 @@ private fun MutualContent(
)
}
}
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
}
// Time range + query card. Its own LazyColumn item so a prefill from
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
// to the time-range selector.
item(key = "matchSearch") {
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
filterFM = state.filterFM,
filterLinear = state.filterLinear,
onFilterFM = onFilterFM,
onFilterLinear = onFilterLinear,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
// Results
itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData(
@@ -323,6 +389,7 @@ private fun MutualContent(
pass = pass,
isExpanded = state.selectedPassIndex == index,
timeFormat = timeFormat,
isUtc = state.isUtc,
minElevA = state.stationAMinElev,
minElevB = state.stationBMinElev,
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
@@ -330,8 +397,44 @@ private fun MutualContent(
)
}
}
// Prefill from the map's grid-QSO dialog "Match" button: once the page is
// laid out, jump straight to the time-range card. Index accounts for the
// optional error card at the top (error = 0, station inputs = 1, time
// range = 2; else 1). scrollToItem is used instead of initializing the
// LazyListState at index 1: the constructor parameter is ignored by this
// Compose version (verified in tests), while scrollToItem lands correctly.
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
if (state.scrollToTimeRange && listReady) {
// Keep trying until the scroll really lands: on a device the first
// frame only contains the station cards + time-range card, which
// can be shorter than the viewport (no scroll range), so a single
// scrollToItem does nothing. When the async query results arrive
// the list grows past one screen and the scroll becomes possible.
for (attempt in 0 until 20) {
Log.d(TAG, "attempting scrollToItem($matchSearchIndex) #$attempt")
try {
listState.scrollToItem(matchSearchIndex)
} catch (t: Throwable) {
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
break
}
if (listState.firstVisibleItemIndex == matchSearchIndex) {
Log.d(TAG, "scroll landed at $matchSearchIndex on attempt #$attempt")
break
}
delay(100)
}
viewModel.consumeScrollToTimeRange()
Log.d(TAG, "scrollToTimeRange consumed")
}
}
}
private const val TAG = "Look4SatMutual"
@Composable
private fun MutualStatusChip(state: MutualUiState) {
val colorScheme = MaterialTheme.colorScheme
@@ -475,6 +578,10 @@ private fun StationInputCard(
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
filterFM: Boolean,
filterLinear: Boolean,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
@@ -501,6 +608,28 @@ private fun MatchSearchCard(
)
}
}
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
// 防止选入大量无转发器卫星占用匹配页.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mutual_filter_transponder),
style = MaterialTheme.typography.labelLarge
)
FilterChip(
selected = filterFM,
onClick = { onFilterFM(!filterFM) },
label = { Text(stringResource(R.string.mutual_filter_fm)) }
)
FilterChip(
selected = filterLinear,
onClick = { onFilterLinear(!filterLinear) },
label = { Text(stringResource(R.string.mutual_filter_linear)) }
)
}
Button(
onClick = onQuery,
enabled = !isCalculating,
@@ -527,6 +656,7 @@ private fun MutualPassCard(
pass: MutualPass,
isExpanded: Boolean,
timeFormat: SimpleDateFormat,
isUtc: Boolean,
minElevA: Double,
minElevB: Double,
onClick: () -> Unit,
@@ -558,39 +688,62 @@ private fun MutualPassCard(
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
// Header: pass info (name/time + elevations) on the left, and a
// larger radar shortcut on the right that spans both rows vertically.
// The pass info column narrows, so the "Opposite" elevation shifts
// left to make room for the taller button.
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
// Radar shortcut, visible even on the collapsed card: a taller
// button spanning both header rows. The card's own click
// (expand) is not triggered by the IconButton's tap.
IconButton(
onClick = onNavigateToRadar,
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_radar),
contentDescription = stringResource(R.string.mutual_open_radar),
modifier = Modifier.size(24.dp)
)
}
}
AnimatedVisibility(
@@ -622,6 +775,7 @@ private fun MutualPassCard(
startTime = visibleStart,
endTime = visibleEnd,
maxElev = adjustedMaxElev,
isUtc = isUtc,
progress = dragProgress,
onProgressChange = { dragProgress = it }
)
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import kotlinx.coroutines.CoroutineDispatcher
@@ -33,12 +34,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.roundToInt
private const val TAG = "Look4SatMutual"
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
@@ -49,11 +53,28 @@ data class MutualUiState(
val stationBGrid: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
val hoursBefore: Int = 0,
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
val filterFM: Boolean = true,
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
val filterLinear: Boolean = true,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1,
val errorMessage: String? = null
val isUtc: Boolean = false,
val errorMessage: String? = null,
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
// the page scrolls to the time-range card after the first layout, then
// consumeScrollToTimeRange() clears it.
val scrollToTimeRange: Boolean = false,
// One-shot flag set by prefillMatchFromGrid(): the map page waits for the
// pre-filled query to finish, then navigates to the match page so the very
// first frame already contains the results (time-range card + pass curves,
// no scroll flicker). consumePendingNavigation() clears it.
val pendingNavigation: Boolean = false
)
class MutualViewModel(
@@ -67,6 +88,15 @@ class MutualViewModel(
private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
// survives switching to another page and back). Reset to the top whenever a
// new query replaces the results.
var listScrollIndex: Int = 0
var listScrollOffset: Int = 0
/** Bumped on every new query so the list scrolls back to the top. */
var queryGeneration: Int = 0
init {
// Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes
@@ -77,8 +107,21 @@ class MutualViewModel(
stationALat = "%.4f".format(pos.latitude),
stationALon = "%.4f".format(pos.longitude),
stationAMinElev = 0.0,
stationBMinElev = 0.0
stationBMinElev = 0.0,
isUtc = settingsRepo.otherSettings.value.stateOfUtc
) }
// Keep the time display in sync with the UTC toggle in Settings
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
// 历史过境回看窗口跟随主页面 Passes 设置 (设置里改小时数, 匹配页同步生效)
viewModelScope.launch {
settingsRepo.passesSettings.collectLatest { settings ->
_uiState.update { it.copy(hoursBefore = settings.hoursBefore) }
}
}
}
fun onStationALat(value: String) {
@@ -163,9 +206,54 @@ class MutualViewModel(
) }
}
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
fun queryMutualPasses() {
/**
* Pre-fill the match page for a target grid picked from the map's grid-QSO
* dialog ("Match" button): set the opposite-station grid (with its
* coordinates), reset the time range to 24h, start the query immediately
* so results are ready when the page opens, and flag the page to scroll
* straight to the time-range card once it is laid out.
*/
fun prefillMatchFromGrid(grid: String) {
val g = grid.trim().uppercase()
val pos = qthToPosition(g)
android.util.Log.d(TAG, "prefillMatchFromGrid('$g')")
_uiState.update {
it.copy(
stationBGrid = g,
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
hoursAhead = 24,
scrollToTimeRange = true,
pendingNavigation = true
)
}
queryMutualPasses()
}
fun consumePendingNavigation() {
android.util.Log.d(TAG, "consumePendingNavigation")
_uiState.update { it.copy(pendingNavigation = false) }
}
fun consumeScrollToTimeRange() {
android.util.Log.d(TAG, "consumeScrollToTimeRange")
_uiState.update { it.copy(scrollToTimeRange = false) }
}
/**
* Run a mutual-pass query. `initialScrollIndex` non-null forces the list
* to rebuild at that position (legacy callers); null keeps the current
* scroll position, so re-running a query from the match page does not
* jump back to the top. The previous results stay on screen while the
* query runs (the list keeps its height, so the scroll position is not
* clamped away); they are replaced — possibly with empty — when the new
* results arrive.
*/
fun queryMutualPasses(initialScrollIndex: Int? = null) {
val state = _uiState.value
// Resolve positions from lat/lon or grid
@@ -183,24 +271,64 @@ class MutualViewModel(
return
}
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
// 防止用户选入大量无转发器卫星占用匹配页.
if (!state.filterFM && !state.filterLinear) {
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
return
}
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
_uiState.update {
it.copy(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
// Deliberately keep mutualPasses: clearing it would shrink the
// list below one screen and clamp the scroll position away.
// The stale results are replaced when the new ones arrive.
selectedPassIndex = -1
)
}
// Scroll position: an explicit initialScrollIndex overrides (legacy
// callers start at a fixed spot); null keeps the current position so
// re-querying does not jump back to the top.
if (initialScrollIndex != null) {
listScrollIndex = initialScrollIndex
listScrollOffset = 0
}
queryGeneration += 1
viewModelScope.launch {
val time = System.currentTimeMillis()
val now = System.currentTimeMillis()
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
val allowed = buildSet {
if (state.filterFM) addAll(Sources.amSatFmCatnums)
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
}
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
// 终点 = now + hoursAhead 不变.
val time = now - state.hoursBefore * 60L * 60L * 1000L
val minElevA = state.stationAMinElev
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
val results = try {
withContext(computeDispatcher) {
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
}
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
// block the pending map→match navigation forever).
android.util.Log.e(TAG, "findMutualPasses threw", t)
emptyList()
}
val errorMsg = if (results.isEmpty()) {
@@ -239,15 +367,20 @@ class MutualViewModel(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, hours: Int
time: Long, hours: Int, hoursBefore: Int
): List<MutualPass> {
val endTime = time + hours * 60L * 60L * 1000L
// 终点始终是 now + hoursAhead; 起点已由调用方下移 hoursBefore 小时,
// 因此这里的 endTime 要在起点之上补回 hoursBefore, 保证窗口 = [now-hb, now+h].
val endTime = time + (hoursBefore + hours) * 60L * 60L * 1000L
val sampleInterval = 5_000L
// Use the main page's pass list for AOS/LOS times, then sample the curves
// using the actual positions. The passes list is already computed by getLeoPass
// and its AOS/LOS times match the Passes page exactly.
val existingPasses = satelliteRepo.passes.value
// The satellites argument may be a subset (transponder-type filter), so
// only passes belonging to the filtered satellites are reused.
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
if (results.isNotEmpty()) return results
@@ -2,11 +2,14 @@ package com.rtbishop.look4sat.feature.mutual
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.LoTWSettings
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.SatRadio
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
@@ -41,13 +44,18 @@ class FakeSatelliteRepo(
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
}
/**
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
* state; everything else the ViewModel never touches fails loudly.
*/
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
class FakeSettingsRepo(
initialPosition: GeoPos = GeoPos(23.13, 113.26)
) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
@@ -62,7 +70,17 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
OtherSettings(
stateOfAutoUpdate = false,
stateOfSensors = false,
stateOfSweep = false,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
@@ -70,6 +88,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
@@ -93,4 +113,18 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun getWorkedGrids(): Set<String> = TODO()
override fun setWorkedGrids(grids: Set<String>) = TODO()
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
override fun getRoamedGrids(): Set<String> = TODO()
override fun setRoamedGrids(grids: Set<String>) = TODO()
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
}
@@ -0,0 +1,291 @@
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.presentation.Screen
import kotlinx.coroutines.delay
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Verifies the map grid-QSO dialog "Match" prefill: after
* [MutualViewModel.prefillMatchFromGrid] the page opens with the time-range
* card at the top and the 24h query auto-started.
*
* Note: `LazyListState(firstVisibleItemIndex = 1)` is deliberately NOT used to
* position the page. In this Compose version (BOM 2026.06.01 / ui 1.11.x) the
* constructor parameter is ignored — the list still starts at item 0 — while
* `listState.scrollToItem(1)` from a LaunchedEffect lands correctly. See
* [scrollToItemLandsAtTop] below, which guards the scrollToItem mechanism.
*
* Robolectric quirk: `assertIsNotDisplayed()` is unreliable here (a node
* scrolled fully out of the viewport can still report "displayed"), so the
* prefill test asserts the time-range card IS displayed (positive assertion)
* rather than that the station card is NOT.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w411dp-h891dp-port")
class MutualMatchPrefillScrollTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun sanity_textRenders() {
composeRule.setContent {
MaterialTheme {
Text("sanity-check-text")
}
}
composeRule.onNodeWithText("sanity-check-text").assertIsDisplayed()
}
@Test
fun scrollToItemLandsAtTop() {
// Guards the mechanism the prefill relies on: scrollToItem(1) from a
// LaunchedEffect moves item 1 to the top of the viewport.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val state = remember { LazyListState(firstVisibleItemIndex = 0) }
LaunchedEffect(Unit) { state.scrollToItem(1) }
LazyColumn(state = state) {
item { Text("min-item-0") }
item { Text("min-item-1") }
item { Text("min-item-2") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("min-item-1").assertIsDisplayed()
}
@Test
fun defaultOpen_showsStationInputsAtTop() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
// Without a prefill the list starts at item 0 (station inputs).
composeRule.onNodeWithText("Your Station").assertIsDisplayed()
}
@Test
fun prefillFromMap_opensAtTimeRangeCard() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
// Same call the map grid-QSO "Match" button makes.
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
// The time-range card must be visible at the top of the page after the
// prefill scroll.
composeRule.onNodeWithText("Time range").assertIsDisplayed()
// The snapshotFlow write-back stores the scrolled position in the VM.
// It equals 1 only if scrollToItem(1) REALLY scrolled the station card
// out. If the list content is shorter than the viewport there is no
// scroll range, scrollToItem cannot move, and this stays 0 — that is
// exactly the device symptom ("page stays at the top").
org.junit.Assert.assertEquals(
"list must have actually scrolled to item 1 (content shorter than viewport?)",
1, vm.listScrollIndex
)
}
@Test
fun prefillAutoStartsQueryAndFillsState() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val s = vm.uiState.value
// Query auto-started: with the empty-satellite fake the query reaches
// the "no satellite data" guard (rather than never being triggered),
// proving prefillMatchFromGrid kicks off queryMutualPasses.
assertTrue(
"auto-query should reach the satellite guard: gridA=${s.stationAGrid} " +
"gridB=${s.stationBGrid} err=${s.errorMessage}",
s.errorMessage?.contains("satellite", ignoreCase = true) == true
)
// ...and the target grid + 24h range pre-filled.
org.junit.Assert.assertEquals("OL62", s.stationBGrid)
org.junit.Assert.assertEquals(24, s.hoursAhead)
// ...and navigation to the match page is pending until the query done.
assertTrue("navigation should be pending", s.pendingNavigation)
// The early-return guard means the query finished immediately (no
// satellites): the map page must now be free to navigate.
org.junit.Assert.assertFalse("query finished -> not calculating", s.isCalculating)
vm.consumePendingNavigation()
org.junit.Assert.assertFalse("consume clears pending navigation", vm.uiState.value.pendingNavigation)
}
@Test
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
// The Mutual screen composed inside a NavDisplay entry (back stack
// [Mutual]) right after prefillMatchFromGrid.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Mutual)
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun navDisplay_switchToMutualEntry_scrollsToTimeRange() {
// Closest device path: start on the Map entry, then push the Mutual
// entry (what the map "Match" button does). NavDisplay runs a real
// fade transition while the Mutual screen composes.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val backStackRef = mutableStateOf<NavBackStack<NavKey>?>(null)
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Map)
backStackRef.value = backStack
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Map> { Text("MAP PAGE") }
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
// Push Mutual like onMatchGrid does, so the transition composes
// MutualScreen with scrollToTimeRange already set.
composeRule.runOnIdle { backStackRef.value?.add(Screen.Mutual) }
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun scrollRetriesUntilContentGrows() {
// Reproduces the real-device mechanism: the first frame's content
// (item 0 short) is shorter than the viewport, so scrollToItem(1) has
// no range and cannot move. Then the content grows (async results)
// past one screen; the retry loop must land on item 1.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val tall = mutableStateOf(false)
LaunchedEffect(Unit) { delay(300); tall.value = true }
val state = remember { LazyListState() }
LaunchedEffect(state) {
// Same retry loop MutualScreen uses for the prefill.
for (attempt in 0 until 20) {
state.scrollToItem(1)
if (state.firstVisibleItemIndex == 1) return@LaunchedEffect
delay(100)
}
}
LazyColumn(state = state) {
item {
if (tall.value) Spacer(modifier = Modifier.height(900.dp))
else Spacer(modifier = Modifier.height(40.dp))
}
item { Text("target-item") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("target-item").assertIsDisplayed()
// Confirm the list really scrolled (station card equivalent is gone).
// We can't read listState from here, so use the VM-style probe: none —
// the target visible + viewport tall enough implies item 1 on top.
}
@Test
fun keepPositionAcrossTabs_thenMapMatchPrefill_scrollsToTimeRange() {
// User hypothesis: the "keep scroll position across tab switches"
// machinery (listState remember(queryGeneration) + snapshotFlow
// write-back) interferes with the prefill auto-scroll. Reproduce the
// full journey: first visit -> scroll a bit -> leave -> return
// (position restored) -> leave -> enter via map Match button.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
val showMutual = mutableStateOf(true)
// First visit (e.g. bottom nav), user scrolls a little; the
// snapshotFlow write-back stored index/offset in the VM.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
if (showMutual.value) MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
vm.listScrollIndex = 0
vm.listScrollOffset = 40
// Leave the page (tab switch destroys the composition).
showMutual.value = false
composeRule.waitForIdle()
// Re-enter: keep-position restores the scroll offset.
showMutual.value = true
composeRule.waitForIdle()
// Leave again, then enter via the map grid-QSO Match button.
showMutual.value = false
composeRule.waitForIdle()
vm.prefillMatchFromGrid("OL62")
showMutual.value = true
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
}
@@ -285,4 +285,101 @@ class MutualViewModelTest {
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `requery keeps scroll position and stale results while running`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
assertTrue("fixture must produce ISS windows", windows.isNotEmpty())
val vm = createVm(
satellites = listOf(TestOrbits.ISS),
passes = windows
)
vm.onStationBGrid("OL62")
queryAndSettle(vm)
assertTrue("fixture must yield passes", vm.uiState.value.mutualPasses.isNotEmpty())
// The user scrolled into the results; the snapshotFlow write-back has
// stored this position in the VM.
vm.listScrollIndex = 4
vm.listScrollOffset = 137
// Re-run the query from the match page ("query" button).
vm.queryMutualPasses()
// While the query runs, the old results stay on screen (the list keeps
// its height, so the scroll position is not clamped away)...
assertTrue("stale results kept while calculating", vm.uiState.value.mutualPasses.isNotEmpty())
// ...and the scroll position must not be reset to the top.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCalculating)
assertTrue("fresh results replace the stale ones", vm.uiState.value.mutualPasses.isNotEmpty())
// Position intact after the query settled.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
}
// --------------------------------------------- transponder filters (AMSAT)
@Test
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
vm.onStationBGrid("OL62")
vm.onFilterFM(false)
vm.onFilterLinear(false)
queryAndSettle(vm)
assertEquals(
"Select at least one transponder type (FM / Linear) to run the match.",
vm.uiState.value.errorMessage
)
assertFalse(vm.uiState.value.hasSearched)
}
@Test
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
assertTrue("fixture must produce windows", windows.isNotEmpty())
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
// ISS_VARIANT (25545) is not.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue(state.mutualPasses.isNotEmpty())
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
}
@Test
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
// Linear list; with FM off and Linear on, no satellite passes.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
vm.onFilterFM(false)
queryAndSettle(vm)
val state = vm.uiState.value
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
assertTrue(state.mutualPasses.isEmpty())
}
}
@@ -73,6 +73,7 @@ private val allModes = listOf(
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private val historyHourSteps = listOf(0) + hourSteps
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
private const val endOfDayMinute = dayMinutes - 1
@@ -80,6 +81,7 @@ private const val quarterHourSlots = dayMinutes / minuteStep
data class PassFilterParams(
val hours: Int,
val hoursBefore: Int,
val elevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -95,6 +97,7 @@ private fun PassesDialogPreview() {
MainTheme {
PassesFilterDialog(
hours = 24,
hoursBefore = 0,
elevation = 16.0,
lowElevation = 16.0,
highElevation = 65.0,
@@ -111,6 +114,7 @@ private fun PassesDialogPreview() {
@Composable
internal fun PassesFilterDialog(
hours: Int,
hoursBefore: Int,
elevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -122,6 +126,9 @@ internal fun PassesFilterDialog(
accept: (PassFilterParams) -> Unit
) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val hoursBeforeIndex = remember {
mutableIntStateOf(historyHourSteps.indexOfFirst { it >= hoursBefore }.coerceAtLeast(0))
}
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val highlightBounds = 0f..90f
var highlightRange by remember(lowElevation, highElevation) {
@@ -136,6 +143,7 @@ internal fun PassesFilterDialog(
accept(
PassFilterParams(
hours = hourSteps[hoursIndex.intValue],
hoursBefore = historyHourSteps[hoursBeforeIndex.intValue],
elevation = elevationValueNew.doubleValue,
lowElevation = highlightRange.start.roundToInt().toDouble(),
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
@@ -164,6 +172,14 @@ internal fun PassesFilterDialog(
valueRange = 0f..(hourSteps.size - 1).toFloat(),
steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
SliderRow(
title = stringResource(R.string.pass_filter_history_hours),
value = hoursBeforeIndex.intValue.toDouble(),
displayValue = formatHoursLabel(historyHourSteps[hoursBeforeIndex.intValue]),
valueResId = R.drawable.ic_clock,
valueRange = 0f..(historyHourSteps.size - 1).toFloat(),
steps = historyHourSteps.size - 2
) { hoursBeforeIndex.intValue = it.toInt().coerceIn(0, historyHourSteps.size - 1) }
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
@@ -30,12 +30,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -103,6 +106,7 @@ private fun PassesScreen(
if (uiState.isPassesDialogShown) {
PassesFilterDialog(
hours = uiState.hours,
hoursBefore = uiState.hoursBefore,
elevation = uiState.elevation,
lowElevation = uiState.lowElevation,
highElevation = uiState.highElevation,
@@ -115,6 +119,7 @@ private fun PassesScreen(
onAction(
PassesAction.FilterPasses(
hoursAhead = params.hours,
hoursBefore = params.hoursBefore,
minElevation = params.elevation,
lowElevation = params.lowElevation,
highElevation = params.highElevation,
@@ -143,11 +148,16 @@ private fun PassesScreen(
onAccept = dismiss,
titleFontSize = 18
) { padding ->
// The changelog is long; keep the dialog frame fixed and let the
// text scroll inside it (matches the update-checker page pattern).
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
modifier = Modifier
.padding(horizontal = padding)
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
)
}
}
@@ -28,6 +28,8 @@ data class PassesState(
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
/** 历史过境回看窗口(小时), 0 = 只看未来. */
val hoursBefore: Int = 0,
val elevation: Double = 16.0,
val lowElevation: Double = 16.0,
val highElevation: Double = 65.0,
@@ -47,6 +49,7 @@ sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(
val hoursAhead: Int,
val hoursBefore: Int,
val minElevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -55,6 +55,7 @@ class PassesViewModel(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
hoursBefore = settingsRepo.passesSettings.value.hoursBefore,
elevation = settingsRepo.passesSettings.value.minElevation,
lowElevation = settingsRepo.otherSettings.value.lowElevation,
highElevation = settingsRepo.otherSettings.value.highElevation,
@@ -95,8 +96,12 @@ class PassesViewModel(
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
// 历史过境窗口: 已结束的过境保留到超过 hoursBefore 小时后消失;
// DeepSpace 保持原行为(始终显示). hoursBefore=0 时过境一结束即消失.
val historyCutoff = timeNow - _uiState.value.hoursBefore * 3600_000L
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
.filter { pass -> pass.isDeepSpace || pass.losTime >= historyCutoff }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
@@ -123,6 +128,7 @@ class PassesViewModel(
is PassesAction.FilterPasses ->
applyFilter(
hoursAhead = action.hoursAhead,
hoursBefore = action.hoursBefore,
minElevation = action.minElevation,
lowElevation = action.lowElevation,
highElevation = action.highElevation,
@@ -135,6 +141,7 @@ class PassesViewModel(
is PassesAction.FilterRadios ->
applyFilter(
hoursAhead = _uiState.value.hours,
hoursBefore = _uiState.value.hoursBefore,
minElevation = _uiState.value.elevation,
lowElevation = _uiState.value.lowElevation,
highElevation = _uiState.value.highElevation,
@@ -246,7 +253,7 @@ class PassesViewModel(
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */
/** Computes live progress while retaining completed passes in the requested history window. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
@@ -254,9 +261,9 @@ class PassesViewModel(
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
val boundedProgress = newProgress.coerceIn(0f, 1f)
if (boundedProgress != pass.progress) {
result.add(pass.copy(progress = boundedProgress))
} else {
result.add(pass)
}
@@ -285,6 +292,7 @@ class PassesViewModel(
private fun applyFilter(
hoursAhead: Int,
hoursBefore: Int,
minElevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -302,13 +310,15 @@ class PassesViewModel(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
modes,
hoursBefore
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
_uiState.update {
it.copy(
hours = hoursAhead,
hoursBefore = hoursBefore,
elevation = minElevation,
lowElevation = lowElevation,
highElevation = highElevation,
@@ -319,9 +329,10 @@ class PassesViewModel(
modes = modes
)
}
val now = System.currentTimeMillis()
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
time = now - hoursBefore * 60L * 60L * 1000L,
hoursAhead = hoursBefore + hoursAhead,
minElevation = minElevation,
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
@@ -332,9 +343,10 @@ class PassesViewModel(
private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value
// 下拉刷新也保留历史过境窗口, 否则刷新即丢 history (与筛选/启动计算口径一致)
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = System.currentTimeMillis() - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
@@ -0,0 +1,307 @@
/*
* 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.feature.radar
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.presentation.SwipeController
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.presentation.EmptyListCard
private const val LOG_WINDOW_MS = 24 * 3_600_000L
@Composable
fun LogPage(
uiState: RadarState,
logViewModel: LogViewModel,
modifier: Modifier = Modifier
) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
val swipeController = rememberSwipeController()
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
}
// Linear if the satellite carries ANY named linear transponder — same test
// that gates the Calculator tab (RadarScreen), so the mode selector and the
// calculator always appear together regardless of which entry is selected.
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
val satName = uiState.currentPass?.name?.trim().orEmpty()
LaunchedEffect(catnum) {
if (catnum != 0) logViewModel.selectSatellite(catnum)
}
val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
selectedRadio?.uplinkLow?.let { low ->
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
}
}
val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
selectedRadio?.downlinkLow?.let { low ->
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
}
}
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
val now = System.currentTimeMillis()
val recent = records
.filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
.sortedByDescending { it.startUtcMillis }
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
Column(modifier = modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = logUiState.callsignInput,
onValueChange = logViewModel::updateCallsign,
modifier = Modifier.weight(1f),
placeholder = { Text("Callsign", fontSize = 14.sp) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
})
)
Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") }
}
if (isLinear) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("CW", "SSB", "FT4").forEach { candidate ->
FilterChip(
selected = mode == candidate,
onClick = { logViewModel.selectMode(catnum, candidate) },
label = { Text(candidate, fontSize = 13.sp) }
)
}
}
} else {
Text("FM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (recent.isEmpty()) {
EmptyListCard(message = "No QSOs yet — type a callsign and tap Log")
} else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(recent, key = { it.id }) { record ->
LogRecordRow(record, swipeController, onDelete = { logViewModel.delete(record.id) })
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { logViewModel.generatePost(satName, maxElev) },
enabled = recent.isNotEmpty(),
modifier = Modifier.weight(1f)
) { Text("生成通联记录", fontSize = 13.sp) }
OutlinedButton(
onClick = logViewModel::prepareUpload,
enabled = !logUiState.busy,
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
}
if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
Text("Working…", fontSize = 13.sp)
}
}
}
logUiState.postText?.let { text ->
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
PostDialog(
text = text,
onDismiss = logViewModel::dismissPost,
onCopy = {
clipboard.setText(AnnotatedString(text))
logViewModel.dismissPost()
},
onShare = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(send, "Share QSO log"))
logViewModel.dismissPost()
}
)
}
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
title = { Text("LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
}
)
}
}
@Composable
private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis))
}
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
controller = swipeController,
revealAction = onDelete,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$time ", fontSize = 14.sp, maxLines = 1)
Text(
text = record.theirCallsign,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
maxLines = 1
)
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
}
when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
else -> Text("", fontSize = 12.sp)
}
}
}
}
@Composable
private fun PostDialog(
text: String,
onDismiss: () -> Unit,
onCopy: () -> Unit,
onShare: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("通联记录") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(onClick = onCopy) { Text("Copy") }
TextButton(onClick = onShare) { Text("Share") }
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }
)
}
@Composable
private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传到 LoTW") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text("${preview.count} QSO(s) · ${
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.firstUtc.let {
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
} – ${
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.lastUtc.let {
runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
}Z", fontSize = 13.sp)
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -0,0 +1,208 @@
/*
* 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.feature.radar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class LogUiState(
val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
val selectedMode: String = "",
/** Station callsign from the imported LoTW certificate ("" when none). */
val certificateCallsign: String = "",
/** Primary grid of the LoTW upload station location ("" when none). */
val stationGrid: String = "",
val preview: LoTWUploadPreview? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
val postText: String? = null,
val busy: Boolean = false
)
class LogViewModel(
private val qsoRepository: IQsoRepository,
private val lotwUploadRepository: ILoTWUploadRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
val records: Flow<List<QsoRecord>> = qsoRepository.records
private val _uiState = MutableStateFlow(LogUiState())
val uiState: StateFlow<LogUiState> = _uiState
// Record ids submitted in the most recent prepare → upload cycle, so a
// successful POST can mark them "uploaded" (distinct from "confirmed").
private var lastUploadedIds: List<Long> = emptyList()
init {
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
_uiState.update {
it.copy(
certificateCallsign = cert?.callsign.orEmpty(),
stationGrid = station?.grid.orEmpty()
)
}
}
}
fun updateCallsign(value: String) =
_uiState.update { it.copy(callsignInput = value.uppercase(Locale.US)) }
/** Load the per-satellite mode preset when the selected satellite changes. */
fun selectSatellite(catnum: Int) {
val preset = settingsRepo.getSatelliteMode(catnum)
_uiState.update { it.copy(selectedMode = preset) }
}
fun selectMode(catnum: Int, mode: String) {
_uiState.update { it.copy(selectedMode = mode) }
settingsRepo.setSatelliteMode(catnum, mode)
}
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
val now = System.currentTimeMillis()
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
txFrequencyHz = txHz,
rxFrequencyHz = rxHz,
band = frequencyBand(txHz),
rxBand = frequencyBand(rxHz),
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
satelliteName = satName,
satelliteMode = normalizedMode,
status = QsoStatus.COMPLETE,
propagationMode = "SAT"
)
viewModelScope.launch { qsoRepository.save(record) }
_uiState.update { it.copy(callsignInput = "") }
}
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
/** English social post from the recorded QSOs of the current satellite (last 24h). */
fun generatePost(satName: String, maxElev: Double) {
viewModelScope.launch {
val now = System.currentTimeMillis()
val list = qsoRepository.records.first()
.filter { it.satelliteName.trim().equals(satName.trim(), true) }
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
.sortedBy { it.startUtcMillis }
if (list.isEmpty()) return@launch
val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val firstLine = "🛰️ $shortName | ${utc.format(Date(list.first().startUtcMillis))}"
val elLine = "🔺 EL ${String.format(Locale.US, "%.1f", maxElev)}° | ${list.size} QSOs"
val modeLines = list.groupBy { it.displayMode }
.map { (mode, qsos) -> "📡 $mode: ${qsos.joinToString(" ") { it.theirCallsign }}" }
.joinToString("\n")
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
_uiState.update { it.copy(postText = text) }
}
}
fun dismissPost() = _uiState.update { it.copy(postText = null) }
fun prepareUpload() {
if (_uiState.value.busy) return
viewModelScope.launch {
_uiState.update { it.copy(busy = true, message = "") }
try {
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastUploadedIds = pending.map { it.id }
_uiState.update { it.copy(busy = false, preview = preview) }
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(busy = false, message = "Upload failed") }
}
}
}
fun confirmUpload() {
val preview = _uiState.value.preview ?: return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id)
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed).
qsoRepository.markUploaded(lastUploadedIds)
lastUploadedIds = emptyList()
}
val msg = when (result) {
is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
is LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun clearMessage() = _uiState.update { it.copy(message = "") }
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
}
}
}
}
@@ -83,6 +83,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Calculator("Calculator"),
Log("Log"),
Sstv("SSTV")
}
@@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
@@ -101,14 +103,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
val remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) {
navigateUpAndClearMutual()
return@LaunchedEffect
}
// Auto-return only while the mutual pass is actually in progress. An
// already-finished pass must NOT bounce the radar page back instantly
// (that made the pass-card radar shortcut look broken: tapping it while
// the computed pass had ended returned to Mutual immediately).
var remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) return@LaunchedEffect
while (remainingMs > 0L) {
delay(remainingMs.coerceAtMost(1000L))
remainingMs = mutualData.endTime - System.currentTimeMillis()
}
navigateUpAndClearMutual()
}
// Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
@@ -124,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -135,6 +140,7 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
@@ -191,11 +197,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
}
}
@@ -205,6 +211,7 @@ private fun RadarScreen(
private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -215,6 +222,7 @@ private fun PagerCard(
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
@@ -258,6 +266,10 @@ private fun PagerCard(
onAction = onAction,
requestMicPermission = requestMicPermission
)
RadarPage.Log -> LogPage(
uiState = uiState,
logViewModel = logViewModel
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
@@ -70,7 +70,11 @@ data class RadarState(
val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你",
val mutualLabelB: String = "友台",
val calculatorOffsetKHz: String = ""
val calculatorOffsetKHz: String = "",
/** Current calculator TX/RX frequencies (linear transponder), synced from the
* Calculator tab so the Log tab records at the same frequencies. */
val calculatorTxHz: Long? = null,
val calculatorRxHz: Long? = null
)
enum class SstvStatus { Idle, Recording }
@@ -128,4 +132,5 @@ sealed interface RadarAction {
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
}
@@ -82,7 +82,7 @@ class RadarViewModel(
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.sensorData.value,
orientationValues = correctedOrientation(sensorsRepo.sensorData.value),
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
@@ -102,12 +102,19 @@ class RadarViewModel(
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
val orientationValues = correctedOrientation(data)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
/** 指南针方位 = 传感器方位 + 磁偏角 + 手动偏置(compassOffsetDegrees, 校准用). */
private fun correctedOrientation(data: Pair<Float, Float>): Pair<Float, Float> {
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
val azimuth = ((data.first + magDeclination + offset) % 360f + 360f) % 360f
return azimuth to data.second
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
@@ -320,6 +327,12 @@ class RadarViewModel(
}
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
}
is RadarAction.UpdateCalculatorFrequency -> {
_uiState.update {
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
else it.copy(calculatorTxHz = action.txHz, calculatorRxHz = action.rxHz)
}
}
}
}
@@ -66,6 +66,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -215,6 +216,7 @@ fun CalculatorPage(
orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
onFrequencyChange = { tx, rx -> onAction(RadarAction.UpdateCalculatorFrequency(tx, rx)) },
modifier = Modifier.fillMaxWidth()
)
@@ -569,6 +571,7 @@ private fun DopplerFrequencyCalculator(
orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
onFrequencyChange: (Long, Long) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
@@ -579,6 +582,12 @@ private fun DopplerFrequencyCalculator(
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
// Sync the calculator's current frequencies upward after every recomposition,
// so the Log tab records at the same TX/RX the operator is listening on.
SideEffect {
if (txFrequencyHz != 0L && rxFrequencyHz != 0L) onFrequencyChange(txFrequencyHz, rxFrequencyHz)
}
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
val txLow = transponder.uplinkLow ?: return
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.domain.source.Sources
@Preview(showBackground = true)
@Composable
@@ -54,9 +55,20 @@ private fun MultiTypesDialogPreview() {
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
// 虚拟类型(转发器/活动筛选)与普通 TLE 源类型互斥: 勾虚拟类型时取消普通类型,
// 勾普通类型时取消虚拟类型. 虚拟类型之间可多选组合.
val virtualTypes = Sources.virtualTypeNames
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type
selected.value = if (type in selected.value) {
selected.value - type
} else if (type in virtualTypes) {
// 勾选虚拟类型: 移除所有普通类型, 保留已勾的虚拟类型.
(selected.value.filterTo(mutableSetOf()) { it in virtualTypes }) + type
} else {
// 勾选普通类型: 移除所有虚拟类型, 保留已勾的普通类型.
(selected.value.filterTo(mutableSetOf()) { it !in virtualTypes }) + type
}
}
val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
@@ -177,6 +177,7 @@ private fun SatellitesScreen(
)
}
}
// 转发器/活动筛选已在"卫星类型"对话框前三个选项中体现(AMSAT Live FM/Linear, Live SSTV).
ElevatedCard(modifier = Modifier.fillMaxSize()) {
val emptyMessage = stringResource(R.string.sat_empty_list_message)
when {
@@ -0,0 +1,320 @@
/*
* 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.feature.settings
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Composable
fun LoTWUploadCard(
hasCertificate: Boolean,
stationGrid: String,
showUploadConfigDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(R.string.prefs_lotw_upload_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (hasCertificate) {
stringResource(R.string.prefs_lotw_upload_configured, stationGrid)
} else {
stringResource(R.string.prefs_lotw_upload_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
@Composable
fun LoTWUploadConfigDialog(
certificate: LoTWCertificate?,
station: LoTWStation?,
stationMeta: LoTWStationMeta?,
busy: Boolean,
error: LoTWUploadError?,
onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit,
onSaveStation: (LoTWStation) -> Unit
) {
var password by remember { mutableStateOf("") }
var selectedFile by remember { mutableStateOf<Uri?>(null) }
var grid by remember { mutableStateOf(station?.grid.orEmpty()) }
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
var region by remember { mutableStateOf(station?.region.orEmpty()) }
// Countries whose national zonemap has exactly one pair (e.g. Germany, India) get
// their CQZ/ITUZ prefilled; multi-zone countries are left blank to avoid a wrong
// default that would mislead (e.g. Guangdong would show the Heilongjiang zone).
LaunchedEffect(stationMeta) {
val meta = stationMeta ?: return@LaunchedEffect
val zones = meta.countryZones
if (zones.size == 1 && cqZone.isBlank() && ituZone.isBlank()) {
cqZone = zones[0].cq.toString()
ituZone = zones[0].itu.toString()
}
// A region saved under a previous certificate (different DXCC) no longer
// applies — clear it so saving doesn't fail validation.
val metaRegion = meta.regionField
if (metaRegion != null && region.isNotBlank() && metaRegion.options.none { it.code == region }) {
region = ""
}
}
val regionField = stationMeta?.regionField
val selectedRegionName = regionField?.options?.firstOrNull { it.code == region }?.name.orEmpty()
val context = LocalContext.current
// Pick the file first, then ask for the password — matches normal usage.
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) selectedFile = uri
}
fun displayName(uri: Uri): String = runCatching {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
SharedDialog(
title = stringResource(R.string.prefs_lotw_upload_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = null
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.prefs_lotw_upload_busy), fontSize = 13.sp)
}
}
if (certificate == null) {
Text(stringResource(R.string.prefs_lotw_upload_cert_hint), fontSize = 13.sp)
CardButton(
onClick = { filePicker.launch(arrayOf("*/*")) },
text = stringResource(R.string.prefs_lotw_upload_import),
isEnabled = !busy,
modifier = Modifier.fillMaxWidth()
)
error?.let {
Text(
text = stringResource(
when (it) {
LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password
LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired
LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid
LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format
LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown
}
),
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp
)
}
selectedFile?.let { uri ->
Text(
text = displayName(uri),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.prefs_lotw_upload_password), fontSize = 13.sp) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
CardButton(
onClick = {
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes != null && bytes.isNotEmpty()) {
onImport(bytes, password.toCharArray())
password = ""
selectedFile = null
}
},
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
isEnabled = password.isNotBlank() && !busy,
modifier = Modifier.fillMaxWidth()
)
}
} else {
Text(stringResource(R.string.prefs_lotw_upload_cert_info, certificate.callsign, certificate.dxcc, certificate.expires), fontSize = 13.sp)
OutlinedButton(onClick = onRemove, enabled = !busy) {
Text(stringResource(R.string.prefs_lotw_upload_remove), fontSize = 13.sp)
}
}
Text(stringResource(R.string.prefs_lotw_upload_station_title), style = MaterialTheme.typography.titleSmall)
// Grid field accepts a comma-separated multi-grid set (e.g. "OL62,OL72").
OutlinedTextField(
value = grid,
onValueChange = { grid = it.uppercase() },
label = { Text(stringResource(R.string.prefs_lotw_upload_grid), fontSize = 13.sp) },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
modifier = Modifier.fillMaxWidth()
)
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ.
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's
// internal gesture handler consumes the tap, so clickable never fires.
// Options expand inline inside the sheet (no Popup/Dialog window stacking).
if (regionField != null) {
var regionExpanded by remember { mutableStateOf(false) }
val fieldShape = MaterialTheme.shapes.extraSmall
Box(
modifier = Modifier
.fillMaxWidth()
.clip(fieldShape)
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
.clickable { regionExpanded = !regionExpanded }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(3.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (region.isBlank()) {
stringResource(R.string.prefs_lotw_upload_region_hint)
} else {
"$region — $selectedRegionName"
},
fontSize = 16.sp,
color = if (region.isBlank()) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = if (regionExpanded) "▴" else "▾",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
AnimatedVisibility(visible = regionExpanded) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
items(regionField.options, key = { it.code }) { option ->
DropdownMenuItem(
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
onClick = {
region = option.code
regionExpanded = false
option.zones.firstOrNull()?.let { zone ->
cqZone = zone.cq.toString()
ituZone = zone.itu.toString()
}
}
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = cqZone,
onValueChange = { cqZone = it },
label = { Text("CQZ", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = ituZone,
onValueChange = { ituZone = it },
label = { Text("ITUZ", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = iota,
onValueChange = { iota = it.uppercase() },
label = { Text("IOTA", fontSize = 12.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, region, "", iota)) },
text = stringResource(R.string.prefs_lotw_upload_save),
isEnabled = grid.isNotBlank() && !busy,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@@ -0,0 +1,211 @@
/*
* 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.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@Composable
fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showLogbookDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(R.string.prefs_logbook_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_logbook_count, recordCount),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
@Composable
fun LogbookDialog(
records: List<QsoRecord>,
uploadBusy: Boolean,
uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit,
onUpload: () -> Unit,
onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit
) {
val swipeController = rememberSwipeController()
SharedDialog(
title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload),
acceptEnabled = !uploadBusy
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(records, key = { it.id }) { record ->
LogbookRow(record, swipeController, onDelete = { onDelete(record.id) })
}
}
}
}
}
if (preview != null) {
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
}
if (uploadMessage.isNotBlank()) {
AlertDialog(
onDismissRequest = onDismissMessage,
title = { Text("LoTW Upload") },
text = { Text(uploadMessage) },
confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") }
}
)
}
}
@Composable
private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.prefs_logbook_upload_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text(
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
fontSize = 13.sp
)
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
@Composable
private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(record.startUtcMillis))
}
val satShort = record.satelliteName.substringBefore('(').trim()
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
controller = swipeController,
revealAction = onDelete,
modifier = Modifier.fillMaxWidth()
) {
// Row style mirrors the worked-grid QSO details on the map: callsign in
// monospace titleMedium, summary line in bodySmall with " · " separators.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (record.lotwConfirmed) {
Text(text = "QSL", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
} else if (record.lotwUploaded) {
Text(text = "UP", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
}
}
Text(
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 3.dp)
)
}
}
}
@@ -0,0 +1,119 @@
/*
* 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.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Composable
fun MapSettingsDialog(
settings: OtherSettings,
onDismiss: () -> Unit,
onSave: (mapSource: String, tiandituKey: String) -> Unit
) {
var selectedSource by rememberSaveable {
mutableStateOf(MapSource.normalize(settings.mapSource))
}
var tiandituKey by rememberSaveable { mutableStateOf(settings.tiandituKey) }
SharedDialog(
title = stringResource(R.string.prefs_map_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = { onSave(selectedSource, tiandituKey) }
) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = stringResource(R.string.prefs_map_source_title),
color = MaterialTheme.colorScheme.primary
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MapSourceChip(
selected = selectedSource == MapSource.OSM,
label = stringResource(R.string.prefs_map_source_osm),
onClick = { selectedSource = MapSource.OSM }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_VECTOR,
label = stringResource(R.string.prefs_map_source_tianditu_vector),
onClick = { selectedSource = MapSource.TIANDITU_VECTOR }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_IMAGE,
label = stringResource(R.string.prefs_map_source_tianditu_image),
onClick = { selectedSource = MapSource.TIANDITU_IMAGE }
)
}
Text(
text = stringResource(R.string.prefs_map_tianditu_key_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = tiandituKey,
onValueChange = { tiandituKey = it },
label = { Text(stringResource(R.string.prefs_map_tianditu_key)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (selectedSource != MapSource.OSM && tiandituKey.isBlank()) {
Text(
text = stringResource(R.string.prefs_map_tianditu_key_required),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun MapSourceChip(selected: Boolean, label: String, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
}
@@ -50,6 +50,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
@@ -86,6 +87,7 @@ import androidx.compose.ui.zIndex
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -228,23 +230,27 @@ fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
isSyncing: Boolean,
syncMode: LoTWSyncMode?,
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
message: String?,
dismiss: () -> Unit,
/** Called on cancel/back while a sync is running: aborts the job. */
onCancelSync: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
onSyncFull: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSyncIncremental: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
) {
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onCancel = dismiss,
// The confirm button IS the sync action: saving credentials and
// fetching confirmed grids happen in one step.
onAccept = {
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
},
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
acceptEnabled = !isSyncing
// While syncing, cancel/back aborts the download instead of merely
// hiding the dialog — a full sync started by mistake must be stoppable.
onDismissRequest = { if (isSyncing) onCancelSync() else dismiss() },
onCancel = { if (isSyncing) onCancelSync() else dismiss() },
// The two sync buttons below are the primary actions: saving
// credentials and fetching confirmed grids happen in one step.
onAccept = null
) {
OutlinedTextField(
value = call.value,
@@ -268,12 +274,50 @@ fun LoTWDialog(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Text(
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (isSyncing) {
// Live progress: once the report header reveals the record count,
// switch to "N QSOs, ~X s remaining" with a determinate bar.
val progressText = when {
progress == null ||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
stringResource(R.string.lotw_sync_progress_connecting)
// Download finished (or the size estimate reached its cap):
// parsing and saving may still run — say so instead of the bar
// sitting at 100% with no feedback, which reads as "stuck".
progress.fraction >= 1f ->
stringResource(R.string.lotw_sync_progress_saving)
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
progress.qsoCount > 0 ->
stringResource(R.string.lotw_sync_progress_count, progress.qsoCount)
else -> stringResource(R.string.lotw_sync_progress_downloading)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (progress != null && progress.expectedBytes > 0) {
LinearProgressIndicator(
progress = { progress.fraction },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
)
}
} else {
// Pre-sync estimate: rate per 100 QSOs (measured ~8 s at ARRL's
// ~10 KB/s stream rate), plus the fixed server-side generation time.
Text(
text = stringResource(R.string.lotw_sync_rate_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) {
Text(
text = message,
@@ -282,6 +326,22 @@ fun LoTWDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
// Full sync bottom-left, incremental merge bottom-right.
CardButton(
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
text = stringResource(R.string.lotw_sync_full),
isEnabled = !isSyncing
)
CardButton(
onClick = { onSyncIncremental(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
text = stringResource(R.string.lotw_sync_incremental),
isEnabled = !isSyncing
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -38,8 +38,10 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -56,6 +58,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -66,8 +70,11 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
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.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
@@ -92,6 +99,7 @@ fun SettingsDestination() {
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) }
if (showUpdateChecker) {
UpdateCheckerScreen(
currentVersion = uiState.appVersionName,
@@ -122,6 +130,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
)
if (showMapSettings) {
MapSettingsDialog(
settings = uiState.otherSettings,
onDismiss = { showMapSettings = false },
onSave = { mapSource, tiandituKey ->
onAction(SettingsAction.UpdateMapSettings(mapSource, tiandituKey))
showMapSettings = false
}
)
}
// Dialogs
if (dialogs.position) {
PositionDialog(
@@ -203,15 +222,70 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.compassCalibration) {
LaunchedEffect(Unit) { onAction(SettingsAction.StartCompassCalibration) }
CompassCalibrationDialog(
accuracy = uiState.compassAccuracy,
headingDegrees = uiState.compassHeadingDegrees,
initialOffsetDegrees = uiState.otherSettings.compassOffsetDegrees,
onDismiss = {
onAction(SettingsAction.StopCompassCalibration)
dialogs.compassCalibration = false
},
onSave = { onAction(SettingsAction.SetCompassOffset(it)) }
)
}
if (dialogs.lotw) {
// LoTW sync failures are typed codes from the ViewModel; render them
// through string resources so the dialog follows the system language.
val lotwErrorMessage = uiState.lotwError?.let { error ->
when (error) {
LoTWError.NotConfigured -> stringResource(R.string.lotw_sync_error_not_configured)
LoTWError.BadCredentials -> stringResource(R.string.lotw_sync_error_credentials)
LoTWError.RateLimited -> stringResource(R.string.lotw_sync_error_rate_limited)
LoTWError.Timeout -> stringResource(R.string.lotw_sync_error_timeout)
is LoTWError.Network -> stringResource(R.string.lotw_sync_error_network, error.detail)
}
}
LoTWDialog(
initialSettings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.lotwSyncing,
message = uiState.lotwMessage,
syncMode = uiState.lotwSyncMode,
progress = uiState.lotwProgress,
message = lotwErrorMessage,
dismiss = { dialogs.lotw = false },
onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
)
}
if (dialogs.logbook) {
LogbookDialog(
records = uiState.logbookRecords,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false },
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
)
}
if (dialogs.lotwUpload) {
LoTWUploadConfigDialog(
certificate = uiState.lotwCertificate,
station = uiState.lotwStation,
stationMeta = uiState.lotwStationMeta,
busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError,
onDismiss = { dialogs.lotwUpload = false },
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
)
}
@@ -318,14 +392,35 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
LogbookCard(
recordCount = uiState.logbookRecords.size,
showLogbookDialog = { dialogs.logbook = true }
)
}
item {
LoTWUploadCard(
hasCertificate = uiState.lotwCertificate != null,
stationGrid = uiState.lotwStation?.grid.orEmpty(),
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
)
}
item {
LoTWCard(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
showLoTWDialog = { dialogs.lotw = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item {
OtherCard(
settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true },
onAction = onAction
)
}
item { MapSettingsCard(onClick = { showMapSettings = true }) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
@@ -513,15 +608,37 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
private fun MapSettingsCard(onClick: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(
text = stringResource(R.string.prefs_map_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(6.dp))
CardButton(
onClick = onClick,
text = stringResource(R.string.prefs_map_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun OtherCard(
settings: OtherSettings,
onCompassCalibration: () -> Unit,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(312.dp)
.height(370.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -534,19 +651,112 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
CardButton(
onClick = onCompassCalibration,
text = stringResource(R.string.prefs_compass_calibration_button),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
onAction(SettingsAction.ToggleMapGrid(it))
}
}
}
/**
* 指南针校准对话框: 8 字动作提升精度, 实时显示校正后航向, 可手动输入偏置(±180°).
*/
@Composable
private fun CompassCalibrationDialog(
accuracy: CompassAccuracy,
headingDegrees: Float,
initialOffsetDegrees: Float,
onDismiss: () -> Unit,
onSave: (Float) -> Unit
) {
var offsetText by rememberSaveable(initialOffsetDegrees) {
mutableStateOf(initialOffsetDegrees.toString())
}
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
val statusRes = when (accuracy) {
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
}
val progress = when (accuracy) {
CompassAccuracy.HIGH -> 1f
CompassAccuracy.MEDIUM -> 0.66f
CompassAccuracy.LOW -> 0.33f
CompassAccuracy.UNRELIABLE, CompassAccuracy.UNAVAILABLE -> 0f
}
SharedDialog(
title = stringResource(R.string.prefs_compass_calibration_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = {
parsedOffset?.let {
onSave(it)
onDismiss()
}
}
) { padding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = "∞",
fontSize = 92.sp,
color = MaterialTheme.colorScheme.primary,
lineHeight = 92.sp
)
Text(
text = stringResource(R.string.prefs_compass_calibration_instruction),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.prefs_compass_accuracy, stringResource(statusRes)),
fontWeight = FontWeight.Medium,
color = if (accuracy == CompassAccuracy.HIGH) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
text = stringResource(R.string.prefs_compass_heading, headingDegrees),
fontSize = 20.sp,
fontWeight = FontWeight.Medium
)
OutlinedTextField(
value = offsetText,
onValueChange = { offsetText = it },
label = { Text(stringResource(R.string.prefs_compass_offset)) },
supportingText = { Text(stringResource(R.string.prefs_compass_offset_support)) },
isError = parsedOffset == null,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
CardButton(
onClick = { offsetText = "0" },
text = stringResource(R.string.prefs_compass_offset_reset),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@@ -566,6 +776,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
lastSyncEpochMs: Long,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -584,6 +795,14 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
// Last successful sync, shown exactly like the ephemeris update time.
if (lastSyncEpochMs != 0L) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatUpdateTime(updateTime = lastSyncEpochMs),
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showLoTWDialog,
@@ -715,6 +934,9 @@ private class DialogVisibility {
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
var compassCalibration by mutableStateOf(false)
var logbook by mutableStateOf(false)
var lotwUpload by mutableStateOf(false)
}
@Composable
@@ -722,13 +944,16 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
compassCalibration = it.getOrElse(8) { false }
logbook = it.getOrElse(9) { false }
lotwUpload = it.getOrElse(10) { false }
}
}
)
@@ -24,6 +24,8 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
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.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import java.io.File
data class PositionSettings(
@@ -61,10 +63,45 @@ data class SettingsState(
val wavelogMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwMessage: String? = null,
val lotwSyncMode: LoTWSyncMode? = null,
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
val lotwError: LoTWError? = null,
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
val lotwLastSyncEpochMs: Long = 0L,
/** Logbook (QSO records + LoTW confirmations), newest first. */
val logbookRecords: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord> = emptyList(),
/** Imported LoTW upload certificate (null when none). */
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
/** LoTW upload station location (null when unset). */
val lotwStation: com.rtbishop.look4sat.core.domain.repository.LoTWStation? = null,
/** Region-field options and national zonemap for the certificate's DXCC entity. */
val lotwStationMeta: com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta? = null,
val lotwUploadBusy: Boolean = false,
/** Last certificate import outcome; shown inside the upload config dialog. */
val lotwUploadError: LoTWUploadError? = null,
/** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
val logbookUploadBusy: Boolean = false,
/** User-facing upload message shown inside the logbook dialog ("" = none). */
val logbookUploadMessage: String = "",
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
val compassHeadingDegrees: Float = 0f,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
sealed interface LoTWError {
data object NotConfigured : LoTWError
data object BadCredentials : LoTWError
data object RateLimited : LoTWError
data object Timeout : LoTWError
data class Network(val detail: String) : LoTWError
}
enum class LoTWUploadError { PASSWORD, INVALID_FILE, EXPIRED, FORMAT, UNKNOWN }
sealed interface SettingsAction {
// Position
data object SetGpsPosition : SettingsAction
@@ -81,11 +118,15 @@ sealed interface SettingsAction {
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data object StartCompassCalibration : SettingsAction
data object StopCompassCalibration : SettingsAction
data class SetCompassOffset(val degrees: Float) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class ToggleMapGrid(val value: Boolean) : SettingsAction
data class UpdateMapSettings(val mapSource: String, val tiandituKey: String) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -100,7 +141,26 @@ sealed interface SettingsAction {
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
val mode: LoTWSyncMode
) : SettingsAction
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
data object CancelLoTWSync : SettingsAction
// Logbook (QSO records + LoTW confirmations)
data object RefreshLogbook : SettingsAction
data class DeleteLogbookRecord(val id: Long) : SettingsAction
data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction
data object ClearLogbookMessage : SettingsAction
// LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
data object RemoveLoTWCertificate : SettingsAction
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
@@ -25,14 +25,23 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
@@ -43,12 +52,19 @@ class SettingsViewModel(
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
private val sensorsRepo: ISensorsRepo,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
private var lotwSyncJob: kotlinx.coroutines.Job? = null
/** 指南针校准: 传感器原始方位(未加磁偏角/偏置), 校准对话框实时显示校正后航向. */
private var rawCompassHeading = sensorsRepo.sensorData.value.first
private val _uiState = MutableStateFlow(
SettingsState(
appVersionName = settingsRepo.appVersionName,
@@ -60,7 +76,10 @@ class SettingsViewModel(
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
)
@@ -70,7 +89,10 @@ class SettingsViewModel(
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
it.copy(
positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos),
compassHeadingDegrees = correctedCompassHeading()
)
}
}
}
@@ -95,7 +117,20 @@ class SettingsViewModel(
}
viewModelScope.launch {
settingsRepo.otherSettings.collect { settings ->
_uiState.update { it.copy(otherSettings = settings) }
_uiState.update {
it.copy(otherSettings = settings, compassHeadingDegrees = correctedCompassHeading())
}
}
}
viewModelScope.launch {
sensorsRepo.compassAccuracy.collect { accuracy ->
_uiState.update { it.copy(compassAccuracy = accuracy) }
}
}
viewModelScope.launch {
sensorsRepo.sensorData.collect { orientation ->
rawCompassHeading = orientation.first
_uiState.update { it.copy(compassHeadingDegrees = correctedCompassHeading()) }
}
}
viewModelScope.launch {
@@ -123,6 +158,11 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwSettings = settings) }
}
}
viewModelScope.launch {
qsoRepository.records.collect { records ->
_uiState.update { it.copy(logbookRecords = records) }
}
}
}
@@ -145,11 +185,19 @@ class SettingsViewModel(
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
SettingsAction.StartCompassCalibration -> sensorsRepo.enableSensor()
SettingsAction.StopCompassCalibration -> sensorsRepo.disableSensor()
is SettingsAction.SetCompassOffset -> settingsRepo.updateOtherSettings {
it.copy(compassOffsetDegrees = action.degrees.coerceIn(-180f, 180f))
}
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
}
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
@@ -159,7 +207,20 @@ class SettingsViewModel(
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
// Logbook
SettingsAction.RefreshLogbook -> refreshLogbook()
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -192,61 +253,202 @@ class SettingsViewModel(
}
}
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
private fun syncLoTWGrids(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
mode: LoTWSyncMode
) {
if (!settings.isConfigured) {
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
_uiState.update { it.copy(lotwError = LoTWError.NotConfigured) }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch {
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
// Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids.
settingsRepo.setWorkedGrids(result.grids)
settingsRepo.setWorkedGridQsos(result.qsos)
settingsRepo.setRoamedGrids(result.roamedGrids)
// Incremental only makes sense for the same callsign as the last sync;
// a first sync, an unknown/empty record or a callsign change must fall
// back to a full report so no stale grids from another account linger.
val callsign = settings.callsign.trim().uppercase()
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
lotwCursorApi(settingsRepo.getLastLotwSyncDate())
} else ""
_uiState.update {
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
}
lotwSyncJob = viewModelScope.launch {
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
_uiState.update { it.copy(lotwProgress = progress) }
}
when (result) {
is LoTWResult.Success -> {
// Incremental: merge new grids/QSOs into the stored set (dedup
// by call + QSO time); full: the fresh report replaces it all.
// Shared with the automatic sync on app start (MainApplication).
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
// Feed the logbook: imported confirmations show up in 日志本 and
// match local uploads (sameConfirmedContact) to mark them confirmed.
viewModelScope.launch {
val confirmed = result.qsos.values.flatten()
.map { it.toConfirmedRecord(callsign) }
qsoRepository.mergeLoTW(confirmed)
}
_uiState.update { state ->
state.copy(
lotwSyncing = false, workedGridsCount = result.grids.size,
lotwMessage = null
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
workedGridsCount = mergedGridsCount, lotwError = null,
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
is LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — callsign or password incorrect"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.BadCredentials
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
is LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.RateLimited
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
is LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — connection timed out, try another network"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Timeout
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
is LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — network error (${result.detail})"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Network(result.detail)
)
}
}
}
}
/** Abort the in-flight LoTW sync job and reset its progress state. */
private fun cancelLoTWSync() {
lotwSyncJob?.cancel()
lotwSyncJob = null
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
}
// endregion
// region Logbook + LoTW upload configuration
private fun refreshLogbook() {
viewModelScope.launch {
_uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) }
}
}
// Record ids submitted in the most recent logbook prepare → upload cycle, so a
// successful POST can mark them "uploaded" (distinct from "confirmed").
private var lastLogbookUploadIds: List<Long> = emptyList()
private fun prepareLogbookUpload() {
if (_uiState.value.logbookUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastLogbookUploadIds = pending.map { it.id }
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
}
}
}
private fun confirmLogbookUpload() {
val preview = _uiState.value.logbookPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val result = lotwUploadRepository.upload(preview.id)
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds)
lastLogbookUploadIds = emptyList()
}
val msg = when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
private fun loadLoTWUploadStatus() {
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
// Config parsing failure must never crash the dialog — degrade to no meta.
val meta = cert?.let { runCatching { lotwUploadRepository.stationMeta(it.dxcc) }.getOrNull() }
_uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwStationMeta = meta, lotwUploadBusy = false) }
}
}
private fun importLoTWCertificate(bytes: ByteArray, password: CharArray) {
viewModelScope.launch {
_uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) }
try {
val cert = lotwUploadRepository.importCertificate(bytes, password)
_uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false, lotwUploadError = null) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
val error = when (e.reason) {
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_EXPIRED -> LoTWUploadError.EXPIRED
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT
else -> LoTWUploadError.INVALID_FILE
}
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) }
} catch (_: Exception) {
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN) }
}
}
}
private fun removeLoTWCertificate() {
viewModelScope.launch {
lotwUploadRepository.removeCertificate()
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null, lotwStationMeta = null) }
}
}
private fun saveLoTWStation(station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) {
viewModelScope.launch {
_uiState.update { it.copy(lotwUploadBusy = true) }
try {
val saved = lotwUploadRepository.saveStation(station)
_uiState.update { it.copy(lotwStation = saved, lotwUploadBusy = false) }
} catch (_: Exception) {
_uiState.update { it.copy(lotwUploadBusy = false) }
}
}
}
// endregion
// region Update checker
@@ -325,6 +527,18 @@ class SettingsViewModel(
}
}
/** 校正后航向 = 传感器原始方位 + 磁偏角 + 手动偏置. */
private fun correctedCompassHeading(): Float {
val declination = sensorsRepo.getMagDeclination(settingsRepo.stationPosition.value)
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
return ((rawCompassHeading + declination + offset) % 360f + 360f) % 360f
}
override fun onCleared() {
sensorsRepo.disableSensor()
super.onCleared()
}
// endregion
// region Data update helpers — consolidated from 3 near-identical functions
@@ -372,6 +586,9 @@ class SettingsViewModel(
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository,
lotwUploadRepository = container.lotwUploadRepository,
sensorsRepo = container.provideSensorsRepo(),
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
@@ -58,7 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -316,7 +315,6 @@ private fun HeaderRow(statuses: List<SatStatus>) {
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
@@ -329,9 +327,8 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
modifier = Modifier.weight(1f).padding(end = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell(
slot = slot,
day = day,
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) }
)
@@ -339,10 +336,18 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none. */
/**
* Day block: colored by the newest reported status among the day's 12 slots
* (gray when none); the number is the count of the most recent consecutive
* reports sharing that same status (day.streakCount), not the total report
* count of the whole day.
*/
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
val noReportGray = 0xFFC0C0C0L
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
val color = Color(slot.statusColor)
val streakCount = day.streakCount
Box(
modifier = modifier
.height(28.dp)
@@ -351,8 +356,8 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (streakCount > 0) {
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more