Compare commits

...
81 Commits
Author SHA1 Message Date
atsunatsu d13af978af build: release version 4.4.8-ba7opf.2.4 (549) 2026-09-29 17:58:10 +08:00
atsunatsu b48e23804f feat(radar): 生成通联记录帖子只含当前过境窗口
generatePost 的筛选从最近 24 小时改为当前过境窗口(aos..los),
与日志列表 recent 同一套过滤逻辑,不再混入其他过境的 QSO
2026-09-29 17:58:07 +08:00
atsunatsu a2b3ce5d69 docs: 补充第三方声明(MIT 合规)
- GridGeometry.kt 头部注明移植自 OrbitDeckiOS(Paul Stoetzer, N8HM, MIT)
- 新增 THIRD_PARTY_NOTICES.md:OrbitDeckiOS (MIT) + Material Design Icons (Apache-2.0)
2026-09-29 15:45:55 +08:00
atsunatsu 6ef994e4cd build: release version 4.4.8-ba7opf.2.3 (548) 2026-09-29 12:12:50 +08:00
atsunatsu 4c3fdeaa9c feat(gridfinder): VUCC 网格寻线实地导航 + LoTW 上传台址预填联动 + 移除地图入口
- GridGeometry 网格线/点几何 + QthConverter grid4/grid8;golden vectors 单测
- 独立定位流 ILocationRepo/LocationRepo(stop 时 removeUpdates),不自动写台址
- feature/gridfinder 模块:罗盘表盘、极坐标近距图、线上/点上精度门控(连续 3 次 ≤6.1m)
- 「设为 LoTW 上传台址」:跳证书配置页并预填当前网格(1/2/4 格),只改台站 grid 字段
- 地图页入口移除,设置页单入口;顶部间距改 ScreenColumn 结构(与设置页同构)
- 术语统一「最近网格点」(中英)
2026-09-29 12:12:47 +08:00
atsunatsu 33a7f23d7a fix(settings): Project Wiki 入口整行可点击; build: release version 4.4.8-ba7opf.1.1 (544) 2026-09-29 01:35:55 +08:00
atsunatsu fb4f14f75d feat(wiki): 更新后的 What's New 卡片改为展示 Project Wiki(与设置页入口共用内容) 2026-09-29 01:25:50 +08:00
atsunatsu 473c3bf228 build: release version 4.4.8-ba7opf.1 (543) 2026-09-29 01:11:20 +08:00
atsunatsu 2110bf0cd6 feat(settings): 设置页新增 Project Wiki 入口(fork 增量功能与用法说明) 2026-09-29 01:11:16 +08:00
atsunatsu 95d2d792cc build: release version 4.4.8-ba7opf.0 (542) 2026-09-28 23:49:10 +08:00
atsunatsu dd29c6681c feat(v448): IC-705 读频、昼夜分界极性、AMSAT/更新检查适配、许可证头批量(v4.4.8 移植) 2026-09-28 23:49:07 +08:00
atsunatsu 45cd72faf7 feat(settings): 致谢与声明卡、版本卡打开检查更新、浅色主题、品牌与 GPL 头 2026-09-28 23:46:52 +08:00
atsunatsu 5acf71b2a6 feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化 2026-09-28 23:46:47 +08:00
atsunatsu 1871c05002 build: release version 4.4.7-ba7opf.17.8 (531) 2026-09-27 21:26:16 +08:00
atsunatsu e9f58f76f6 feat(radar): keep the full-screen circle width share in small windows
The radar circle is a square, so it is limited by the shorter side of
the card it is drawn in: the small-window layout gave the radar card a
height of ~156dp in a 411x450 window, which shrank the circle to ~36% of
the page width while the full-screen layout shows ~84%.

- measure the real window with BoxWithConstraints instead of trusting the
  window size class, which can still report the full screen while the app
  runs in a split/floating window
- when the classic 1:1 split would give the radar less than 75% of the
  page width, use a fill layout: the radar takes the largest square that
  fits, a usable pager block is reserved under it while that keeps the
  circle at its full-screen share, otherwise the pager collapses into a
  48dp translucent tab strip with a tappable panel
- keep the normal two-row top area in the vertical layout: TimerRow and
  NextPassRow both carry weight(1f) internally, so putting them in a
  single row splits that row into two 50% blocks
- extract the geometry into pure Dp functions (useFillRadarLayout,
  radarFillSizes) covered by unit tests
2026-09-27 21:26:12 +08:00
atsunatsu 800494b978 feat(radar): fill radar in split-screen via measured window size
The previous compact-mode trigger used currentWindowAdaptiveInfo(),
which reports the full-screen size class in split-screen / multi-window,
so the radar never enlarged there. Measure the actual window bounds with
BoxWithConstraints: compact (radar weight 1f fills all space, pager fixed
132dp strip) when maxWidth < 600dp && maxHeight < 480dp.

build: release version 4.4.7-ba7opf.17.6 (529)
2026-09-27 20:13:13 +08:00
atsunatsu f1091acbbd build: release version 4.4.7-ba7opf.17.4 (527) 2026-09-27 18:32:28 +08:00
atsunatsu 99bf3d34a9 feat(radar): enlarge radar in split-screen/small windows
When the window is vertical (narrow) and too short for split-screen or a
small floating window, give the radar card 1.6/0.6 weight instead of the
usual 1:1 so the square radar plot stays large. The pager card shrinks
but all its pages are scrollable, so nothing is clipped. Full-screen
vertical layout (enough height) is unchanged.
2026-09-27 18:32:24 +08:00
atsunatsu 1dd185971f fix(lotw): precise uploaded marking; surface skipped/unavailable counts
P2 — markUploaded now only marks the records that actually made it
into the accepted TQ8 batch (preview.submittedIds), never the whole
candidate list. Previously, un-signable records skipped by prepare()
were still marked UP after any accepted upload, falsely showing them
as uploaded.

P4 — surface why records did not upload:
- When audit finds nothing pending, distinguish unavailable records
  (invalid call/date — check logbook) from unknown-result records
  (not retried automatically) instead of a blanket 'no pending'.
- Upload preview dialogs (Log tab + logbook) show a breakdown line:
  N already uploaded/duplicate · N unknown result · N un-uploadable.
2026-09-27 17:46:21 +08:00
atsunatsu fe85703e56 fix(lotw): decouple QSO records from certificate station; skip un-signable records
- Remove LOCATION_MISMATCH check: the recorded myGrid never enters the
  TQ8 (the tSTATION grid is signed from the current certificate station),
  so a portable/ADIF-imported record with a different grid must not block
  upload. Logging and uploading are now fully decoupled from the
  certificate location.
- Fingerprint now covers the CONTACT only (call, date, band, mode,
  satellite, freq), excluding station fields: changing grid/zones/county/
  IOTA no longer invalidates ledger entries, so already-uploaded contacts
  are not re-queued and rejected as duplicates.
- prepare(): catch un-signable contacts per-record instead of aborting
  the whole batch; count them as skipped and upload the rest.
- audit(): count lotwUploaded records as uploaded (ledger-loss fallback).
2026-09-27 17:35:34 +08:00
atsunatsu caddba5b81 fix(settings): load LoTW certificate status at startup
The LoTW upload card read uiState.lotwCertificate which was only
populated when the config dialog was opened (LoadLoTWUploadStatus).
On first entry to the settings screen it stayed null, showing
'certificate not imported' even though a certificate was stored.
Load certificate + station once in init so the card is correct
from the first frame.
2026-09-27 17:07:02 +08:00
atsunatsu 52b733a450 fix(lotw): support more PBES2 variants, classify import errors for users
- Pkcs12Reader: AES-192/AES-128/DES-EDE3-CBC ciphers, PBKDF2
  SHA1/SHA256/SHA384/SHA512 PRFs, keyLength defaulting per cipher,
  error messages reduced to algorithm names
- LoTWKeyMaterial: map BadPadding -> wrong password, IllegalState
  (unsupported algorithm) -> format error with algorithm name,
  IllegalArgumentException -> invalid file
- Import dialog: user-facing messages with algorithm name embedded,
  no more raw OpenSSL/BoringSSL error strings
- Tests: AES-192, DES-EDE3 fixtures, wrong-password BadPadding check
2026-09-27 01:20:07 +08:00
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
146 changed files with 11734 additions and 340 deletions

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
## 本仓库特色功能
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
+46
View File
@@ -0,0 +1,46 @@
# Third-Party Notices
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
components:
## OrbitDeckiOS
- Source: https://github.com/prstoetzer/OrbitDeckiOS
- Copyright (c) 2025 Paul Stoetzer, N8HM
- License: MIT
- Used for: the Grid Finder feature — the VUCC grid line / grid point
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
OrbitDeckiOS' GridGeometry.
### MIT License
```
MIT License
Copyright (c) 2025 Paul Stoetzer, N8HM
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Material Design Icons
- Source: https://materialdesignicons.com (Pictogrammers)
- License: Apache-2.0
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
(crosshairs-gps icon). Attribution is also noted in the icon file header.
+1 -1
View File
@@ -44,7 +44,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" />
android:value="cn.ba7opf.look4sat" />
<provider
android:name="androidx.core.content.FileProvider"
@@ -27,7 +27,9 @@ import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { NavRoot() }
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
// Light theme is opt-in: the app stays dark until the user turns it on.
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
}
}
@@ -26,6 +26,7 @@ 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
@@ -50,6 +51,9 @@ class MainApplication : Application(), IContainerProvider {
container.appScope.launch { checkAutoUpdate() }
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
container.appScope.launch { checkLoTWAutoSync() }
// repair logbooks split by the old name/band identity mismatch, independent of
// whether a sync is due (cheap read + in-memory match on every start)
container.appScope.launch { runCatching { container.qsoRepository.consolidateConfirmations() } }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -116,6 +120,11 @@ class MainApplication : Application(), IContainerProvider {
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})")
@@ -75,11 +75,14 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
import com.rtbishop.look4sat.core.presentation.RadarDestination
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.gridfinder.GridFinderDestination
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.mutual.MutualScreen
@@ -88,6 +91,7 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
@@ -99,6 +103,8 @@ fun NavRoot(deeplink: String? = null) {
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToGridFinder: () -> Unit = { rootBackStack.add(GridFinderDestination) }
val navigateToLoTWStation: () -> Unit = { rootBackStack.add(LoTWUploadDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
@@ -132,7 +138,8 @@ fun NavRoot(deeplink: String? = null) {
entryProvider = entryProvider {
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar
navigateToRadar = navigateToRadar,
navigateToGridFinder = navigateToGridFinder
)
}
entry<RadarDestination> {
@@ -143,6 +150,25 @@ fun NavRoot(deeplink: String? = null) {
RadarDestination(navigateUp = navigateBack)
}
}
entry<GridFinderDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GridFinderDestination(
navigateUp = navigateBack,
onOpenLoTWStation = navigateToLoTWStation
)
}
}
entry<LoTWUploadDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
LoTWUploadDestination(navigateUp = navigateBack)
}
}
}
)
}
@@ -150,7 +176,8 @@ fun NavRoot(deeplink: String? = null) {
@Composable
fun MainScreen(
navigateToRadar: () -> Unit = {}
navigateToRadar: () -> Unit = {},
navigateToGridFinder: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
@@ -177,6 +204,28 @@ fun MainScreen(
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(
low = otherSettings.lowElevation,
@@ -246,12 +295,14 @@ fun MainScreen(
entry<Screen.Map> {
MapDestination(
mapFilterViewModel = mapFilterViewModel,
matchCalculating = matchCalculating,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill the
// match page for that grid and switch to its tab.
// 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)
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Mutual)
}
)
}
@@ -267,7 +318,7 @@ fun MainScreen(
}
entry<Screen.AMSAT> { SatStatusDestination() }
entry<Screen.Settings> {
SettingsDestination()
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
}
}
)
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:map"))
implementation(project(":feature:gridfinder"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
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
)
@@ -330,12 +330,13 @@ object IcomCivProtocol {
/**
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
* Payload layout after stripping command byte: [5 freq bytes]
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
*/
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null
if (payload.size < 5) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
return freqHz to mode
}
@@ -17,27 +17,36 @@
*/
package com.rtbishop.look4sat.core.data.injection
import android.Manifest
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
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
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.LocationRepo
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
@@ -45,13 +54,16 @@ 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
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -73,6 +85,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,17 +101,36 @@ 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)
}
override val locationRepo: ILocationRepo by lazy {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
LocationRepo(manager) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
}
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
private val _pendingLoTWStationGrid = MutableStateFlow<String?>(null)
override val pendingLoTWStationGrid: StateFlow<String?> = _pendingLoTWStationGrid.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun setPendingLoTWStationGrid(grid: String?) {
_pendingLoTWStationGrid.value = grid
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
@@ -169,14 +201,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,195 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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 com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
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
}
/** Every satellite name ARRL's config.tq6 knows, sorted: the only names LoTW accepts. */
fun satelliteNames(): List<String> = satellites.keys.sorted()
/**
* Resolve a tracker name to the ARRL name, or null when it is not an ARRL satellite.
* Exact official name first, then the alias table ("SAUDISAT 1C" -> "SO-50",
* "ISS (ZARYA)" -> "ARISS"), then an official name contained in the tracker name.
*/
fun resolveSatellite(name: String): String? = LoTWSatelliteAliases.resolve(name, satellites.keys)
fun satellite(name: String, date: String): String {
val normalized = name.trim().uppercase(Locale.US)
// Tracker names differ from ARRL's: SatNOGS stores "SAUDISAT 1C" for "SO-50" and
// "ISS (ZARYA)" for "ARISS". resolveSatellite() checks the exact name, the alias
// table and finally substring containment; anything else is not uploadable.
val official = resolveSatellite(name) ?: fail(LoTWProblem.SATELLITE, normalized)
val sat = satellites.getValue(official)
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,175 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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 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 (e: Exception) {
// Classify by exception type so the user sees the right message:
// - BadPadding (BAD_DECRYPT) -> wrong password
// - IllegalStateException (error()) -> unsupported algorithm,
// message is the algorithm name
// - IllegalArgumentException (require()) -> structurally invalid file
fail(
when {
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
else -> LoTWProblem.CERTIFICATE_INVALID
},
if (e is IllegalStateException) (e.message ?: "") else ""
)
}
} 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,98 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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 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)
}
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("")
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
// deliberately excluded: changing the station location must not change
// the fingerprint, otherwise previously-uploaded contacts would lose
// their ledger entry and be re-uploaded (and rejected as duplicates).
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
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,79 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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 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,459 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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 android.content.Context
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
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)
}
/** The ARRL satellite catalogue is static for the lifetime of the bundled config.tq6. */
private val satelliteNames: List<String> by lazy { config().satelliteNames() }
override suspend fun satelliteCatalog(): List<String> = withContext(Dispatchers.IO) { satelliteNames }
/**
* What makes two records the same contact for batch de-duplication: the opposite station,
* the satellite (resolved to its ARRL identity, so the tracker spelling and the official
* name are one) and the minute the QSO started. Frequency and mode are deliberately not
* part of the key — the same contact logged twice with slightly different tuning (or with
* the frequency fields empty) must still be caught before LoTW rejects the duplicate.
*/
private fun duplicateKey(record: QsoRecord): String = listOf(
record.theirCallsign.trim().uppercase(Locale.US),
satelliteIdentity(record.satelliteName),
(record.startUtcMillis / 60_000L).toString()
).joinToString("|")
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
var duplicates = 0
var incomplete = 0
val reasons = mutableMapOf<LoTWProblem, Int>()
val details = mutableMapOf<LoTWProblem, String>()
records.sortedBy { it.startUtcMillis }.forEach { record ->
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> {
unavailable++
incomplete++
}
record.lotwReceived || record.lotwUploaded -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (e: LoTWOperationException) {
unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
if (e.detail.isNotBlank()) details.putIfAbsent(e.reason, e.detail)
null
}
if (contact != null) when {
!unique.add(duplicateKey(contact.record)) -> {
unavailable++
duplicates++
}
ledger[contact.fingerprint] == "accepted" -> uploaded++
ledger[contact.fingerprint] == "unknown" -> unknown++
else -> pendingCount++
}
}
}
}
LoTWUploadAudit(
records.size, pendingCount, uploaded, unknown, unavailable,
reasons, details, duplicates, incomplete
)
}
}
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
var unavailable = 0
var duplicates = 0
val unique = hashSetOf<String>()
val reasons = mutableMapOf<LoTWProblem, Int>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (e: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
// skip it, count the real reason, and let the rest upload.
unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) -> { skipped++; null }
!unique.add(duplicateKey(contact.record)) -> {
skipped++
duplicates++
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,
unavailable,
reasons.toMap(),
duplicates,
contacts.map { it.record.id }
)
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,301 @@
/*
* 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_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c)
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09)
private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a)
private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b)
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16)
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07)
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)
// KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the
// platform JCE, so report it by name instead of a generic failure).
if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) {
error(oidName(kdf.firstOrNull()?.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 = -1 // -1 = not written; defaulted below from the cipher
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 = when {
prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1"
prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256"
prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384"
prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512"
else -> error(oidName(prf[0].content))
}
}
}
}
val enc = DerReader.children(pbes2[1].content)
val encOid = enc[0].content
val iv = enc[1].content
val (cipherName, aesKeyBits) = when {
encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256
encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192
encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128
encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192
else -> error(oidName(encOid))
}
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
val secretKey = try {
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
} catch (e: java.security.NoSuchAlgorithmException) {
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
}
val cipher = Cipher.getInstance(cipherName)
cipher.init(
Cipher.DECRYPT_MODE,
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
else SecretKeySpec(secretKey.encoded, "DESede"),
IvParameterSpec(iv)
)
return cipher.doFinal(encrypted)
}
/** Human-readable name for a known algorithm OID. */
private fun oidName(oid: ByteArray?): String = when {
oid == null -> "unknown algorithm"
oid.contentEquals(OID_PBKDF2) -> "PBKDF2"
oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1"
oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256"
oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384"
oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512"
oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC"
oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC"
oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC"
oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC"
else -> "unknown algorithm"
}
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
}
}
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
@@ -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
@@ -252,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)
@@ -273,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()
}
@@ -297,9 +322,9 @@ class LoTWRepository : ILoTWRepository {
line.startsWith("<MODE:") ->
mode = adifValue(line)
line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<DXCC:") ->
dxcc = adifValue(line).toIntOrNull()
line.startsWith("<COUNTRY:") ->
@@ -311,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
@@ -420,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))
}
}
}
}
@@ -0,0 +1,134 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
import com.rtbishop.look4sat.core.domain.repository.LocationFix
import com.rtbishop.look4sat.core.domain.repository.LocationState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val UPDATE_INTERVAL_MS = 1_000L
private const val UPDATE_MIN_DISTANCE_M = 0f
/**
* Live-location adapter for the field tools. Deliberately independent of
* [SettingsRepo]'s station position: a walk around a grid corner must not
* rewrite the station that every pass prediction is built on.
*
* Unlike [SettingsRepo.setStationPosition], this class always calls
* [LocationManager.removeUpdates] in [stopUpdates], so the listener is only
* alive while a screen is actually collecting fixes.
*/
class LocationRepo(
private val locationManager: LocationManager,
private val permissionGranted: () -> Boolean
) : ILocationRepo, LocationListener {
private val _state = MutableStateFlow(initialState())
override val state: StateFlow<LocationState> = _state.asStateFlow()
private var isRegistered = false
override fun hasPermission(): Boolean = permissionGranted()
override fun startUpdates() {
if (isRegistered) return
val request = describeProvider() ?: return
if (!request.permissionGranted || !request.providerEnabled) return
try {
// A last-known fix is better than an empty screen while GPS warms up.
locationManager.getLastKnownLocation(request.provider)?.let(::publish)
locationManager.requestLocationUpdates(
request.provider, UPDATE_INTERVAL_MS, UPDATE_MIN_DISTANCE_M, this
)
isRegistered = true
} catch (exception: SecurityException) {
println("LocationRepo: no permission - $exception")
} catch (exception: IllegalArgumentException) {
println("LocationRepo: provider unavailable - $exception")
}
}
override fun stopUpdates() {
if (!isRegistered) return
isRegistered = false
try {
locationManager.removeUpdates(this)
} catch (exception: SecurityException) {
println("LocationRepo: removing updates without permission - $exception")
}
}
override fun onLocationChanged(location: Location) = publish(location)
override fun onProviderDisabled(provider: String) = refreshWaitingState()
override fun onProviderEnabled(provider: String) = refreshWaitingState()
private fun publish(location: Location) {
_state.value = LocationState.Fix(
LocationFix(
latitude = location.latitude,
longitude = location.longitude,
altitudeMeters = location.altitude.takeIf { location.hasAltitude() } ?: 0.0,
// Providers that cannot report accuracy use -1; treat that as
// useless rather than perfect.
accuracyMeters = location.accuracy.takeIf { location.hasAccuracy() } ?: Float.MAX_VALUE,
epochMs = location.time.takeIf { it > 0L } ?: System.currentTimeMillis()
)
)
}
private fun initialState(): LocationState {
val request = describeProvider()
return LocationState.Waiting(
permissionGranted = request?.permissionGranted ?: false,
providerEnabled = request?.providerEnabled ?: false
)
}
private fun refreshWaitingState() {
if (_state.value is LocationState.Fix) return
_state.value = initialState()
}
private fun describeProvider(): ProviderRequest? {
if (!permissionGranted()) {
return ProviderRequest(LocationManager.GPS_PROVIDER, false, false)
}
val enabled = LocationManagerCompat.isLocationEnabled(locationManager)
val provider = when {
locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
else -> null
} ?: return null
return ProviderRequest(provider, true, enabled)
}
private data class ProviderRequest(
val provider: String,
val permissionGranted: Boolean,
val providerEnabled: Boolean
)
}
@@ -0,0 +1,346 @@
/*
* 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.displayMode
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
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
// Editing a record that was already uploaded or confirmed must not rewrite it in
// place: that contact already exists on LoTW under the old values. The original row
// keeps its upload/confirmation state, and the edited content is saved as a NEW
// record (without any LoTW state, so it can be uploaded again).
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
!record.sameEditableContentAs(previous)
) {
dao.save(record.copy(
id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
lotwQslDate = "", vuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
).toEntity())
} else {
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) }
}
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
importMutex.withLock {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
val consolidation = consolidateSplitRows(working)
val renames = rewriteOfficialNames(working)
if (consolidation.merged.isEmpty() && consolidation.redundant.isEmpty() && renames.isEmpty()) {
return@withLock 0
}
val toSave = LinkedHashMap(consolidation.merged).apply { putAll(renames) }
dao.saveBatch(toSave.values.map(QsoRecord::toEntity))
consolidation.redundant.forEach { dao.delete(it.id) }
consolidation.redundant.size
}
}
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
// Consolidate first: confirmations synced before the identity fix sit as their own
// rows (the tracker name and a mirrored band direction both failed the comparison),
// and left in place they would make the lookup below ambiguous between the operator's
// own row and the stale duplicate.
val consolidation = consolidateSplitRows(working)
// Rows imported under a tracking-source name join the ARRL name here, so what is stored
// is the identity both sides of a match resolve to (the lookup below is built on it).
val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = keyIndex.keys.toMutableSet()
val changes = linkedMapOf<Int, QsoRecord>().apply {
putAll(consolidation.merged)
putAll(renames)
}
var imported = 0
var updated = consolidation.merged.size
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) {
// A confirmation downloaded by the pre-fix parser sits as its own row with the
// band direction read mirrored (the downlink was stored as the uplink). It has no
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
// so it would otherwise be skipped forever, keeping the wrong band on display.
// When the bands are exactly mirrored, adopt the report's direction instead.
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
?.takeIf { index -> working[index].lotwConfirmed &&
working[index].band.equals(remote.rxBand, true) &&
working[index].rxBand.equals(remote.band, true) } else null
if (stale != null) {
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
working[stale] = fixed
changes[stale] = fixed
updated++
return@forEach
}
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
keyIndex.getOrPut(stableQsoKey(added)) { 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))
consolidation.redundant.forEach { dao.delete(it.id) }
return AdifImportResult(imported, skipped, updated)
}
/** Result of folding already-split rows back together. */
private data class Consolidation(
/** index in the stored list -> the record to save (local row + its confirmation). */
val merged: Map<Int, QsoRecord>,
/** imported confirmation rows that are now part of a local record. */
val redundant: List<QsoRecord>
)
/**
* Rewrites the names records were imported under into their ARRL names ("SAUDISAT 1C" ->
* "SO-50"), so the logbook, the ADIF export and the signed record all carry one name.
* Names the alias table does not know (recycled placeholders, satellites ARRL does not
* list) are kept exactly as they are. [working] is updated in place; the rewritten records
* are returned for the caller to save.
*/
private fun rewriteOfficialNames(working: MutableList<QsoRecord>): Map<Int, QsoRecord> {
val renamed = mutableMapOf<Int, QsoRecord>()
working.indices.forEach { index ->
val record = working[index]
val official = officialSatelliteName(record.satelliteName)
if (official != record.satelliteName) {
val updated = record.copy(satelliteName = official)
working[index] = updated
renamed[index] = updated
}
}
return renamed
}
/**
* Folds confirmation rows that were imported as separate QSOs back into the local
* record they belong to (see [splitConfirmationPairs]). [working] is updated in
* place; the redundant rows are returned for the caller to delete.
*/
private fun consolidateSplitRows(working: MutableList<QsoRecord>): Consolidation {
val merged = mutableMapOf<Int, QsoRecord>()
val redundant = mutableListOf<QsoRecord>()
// Snapshot the pairs first: the list is rewritten as pairs are applied.
splitConfirmationPairs(working.toList()).forEach { pair ->
val local = working[pair.localIndex]
val confirmation = working[pair.confirmationIndex]
val folded = local.withConfirmation(confirmation)
if (folded != local) {
working[pair.localIndex] = folded
merged[pair.localIndex] = folded
}
redundant += confirmation
}
return Consolidation(merged, redundant)
}
}
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),
// The identity, not the spelling: a row stored as "SO-50" is the same QSO as one imported
// as "SAUDISAT 1C", so re-importing an older export does not duplicate it.
satelliteIdentity(record.satelliteName)
).joinToString("|")
/**
* Whether two rows carry the same operator-editable content.
*
* Minutes are the granularity for time: the edit dialog edits whole minutes (and rounds the
* stored seconds away when saving), so a seconds-only difference must not count as an edit.
* The satellite is compared by its ARRL identity, the mode by its display label — both sides
* of the same contact written differently (tracker vs official name, MFSK vs FT4) are the same
* content. Used to decide whether saving an already-uploaded/confirmed row should create a new
* record or leave the row alone.
*/
private fun QsoRecord.sameEditableContentAs(other: QsoRecord): Boolean =
theirCallsign.trim().equals(other.theirCallsign.trim(), true) &&
startUtcMillis / 60_000L == other.startUtcMillis / 60_000L &&
txFrequencyHz == other.txFrequencyHz &&
rxFrequencyHz == other.rxFrequencyHz &&
displayMode == other.displayMode &&
satelliteIdentity(satelliteName) == satelliteIdentity(other.satelliteName) &&
sentReport.trim() == other.sentReport.trim() &&
receivedReport.trim() == other.receivedReport.trim() &&
theirGrid.trim().uppercase(Locale.US) == other.theirGrid.trim().uppercase(Locale.US) &&
comment == other.comment
@@ -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,
@@ -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) {
@@ -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
@@ -98,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"
@@ -107,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 =
@@ -210,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
@@ -239,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)
@@ -269,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"
@@ -442,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) }
}
@@ -542,7 +617,12 @@ 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)
@@ -559,6 +639,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putString(keyMapSource, new.mapSource)
putString(keyTiandituKey, new.tiandituKey)
}
new
}
@@ -579,7 +661,9 @@ class SettingsRepo(
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
@@ -601,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
}
@@ -611,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() },
@@ -672,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 =
@@ -770,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"
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.LatestRelease
@@ -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() }
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setData(CalendarContract.Events.CONTENT_URI)
putExtra(CalendarContract.Events.TITLE, name)
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import org.junit.Assert.assertArrayEquals
@@ -0,0 +1,51 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class IcomCivProtocolTest {
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
@Test
fun parseFreqModePayload_ic705ReadFreqReply() {
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
assertNotNull(response)
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
}
@Test
fun parseFreqModePayload_withModeByte() {
assertEquals(
435611000L to "USB",
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
)
}
@Test
fun parseFreqModePayload_tooShort() {
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
}
}
@@ -0,0 +1,126 @@
/*
* 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.logbook.LoTWSatelliteAliases
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
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())
}
@Test
fun `tracker catalogue names resolve to the ARRL satellite`() {
// The names the TLE sources store vs the single name ARRL knows for that satellite.
assertEquals("SO-50", config.resolveSatellite("SAUDISAT 1C"))
assertEquals("ARISS", config.resolveSatellite("ISS (ZARYA)"))
assertEquals("PO-101", config.resolveSatellite("DIWATA 2B"))
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
assertEquals("SO-50", config.resolveSatellite("SO-50"))
}
@Test
fun `satellite signing accepts tracker names and keeps ARRL spelling`() {
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2026-09-27"))
assertEquals("ARISS", config.satellite("ISS (ZARYA)", "2026-09-27"))
assertEquals("SO-50", config.satellite("SO-50 (SaudiOSCAR 50)", "2026-09-27"))
}
@Test
fun `satellite signing rejects names ARRL does not know`() {
// Placeholder designations are deliberately unmapped: signing them would name the
// wrong object, so the record must stay un-uploadable.
listOf("OBJECT AY", "MARINA", "NOT A SATELLITE").forEach { name ->
val error = runCatching { config.satellite(name, "2026-09-27") }.exceptionOrNull()
assertTrue("$name should be rejected", error is LoTWOperationException)
}
}
@Test
fun `satellite signing enforces the ARRL service dates`() {
// SO-50 is listed from 2002-12-20: a QSO before that cannot be signed.
val error = runCatching { config.satellite("SAUDISAT 1C", "2002-01-01") }.exceptionOrNull()
assertTrue(error is LoTWOperationException)
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2003-01-01"))
}
@Test
fun `every alias target is a real ARRL satellite name`() {
val catalogue = config.satelliteNames().toSet()
assertTrue("ARRL catalogue is unexpectedly small", catalogue.size > 100)
val unknown = LoTWSatelliteAliases.table.filterValues { it !in catalogue }
assertEquals("alias targets missing from config.tq6: $unknown", emptyMap<String, String>(), unknown)
}
}
@@ -0,0 +1,134 @@
/*
* 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() }
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
@Test
fun readsAes192Variant() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray())
assertEquals("RSA", key.algorithm)
assertEquals("RSA", cert.publicKey.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun readsDesEde3CbcVariant() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray())
assertEquals("RSA", key.algorithm)
assertEquals("RSA", cert.publicKey.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun unsupportedCipherNamesTheAlgorithm() {
// A valid PBES2 file re-encrypted with an unsupported cipher must report the
// algorithm name (so the user can see exactly what to re-export with).
val fixtureBytes = fixture("test_pbes2.p12")
val e = assertThrows(IllegalStateException::class.java) {
// Simulate: patch the AES-256-CBC OID inside the file to an unknown OID.
val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] }
// find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a
val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
var idx = -1
outer@ for (i in 0..bogus.size - oid.size) {
for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer
idx = i; break
}
require(idx >= 0) { "AES-256-CBC OID not found in fixture" }
bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher
Pkcs12Reader.read(bogus, "testpass123".toCharArray())
}
assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm"))
}
private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean {
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
val signed = Signature.getInstance("SHA1withRSA").run {
initSign(key); update(challenge); sign()
}
return Signature.getInstance("SHA1withRSA").run {
initVerify(cert); update(challenge); verify(signed)
}
}
@Test
fun wrongPasswordFailsWithBadPadding() {
// A wrong password must surface as a decryption failure (BadPadding), which
// LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error.
val e = assertThrows(Exception::class.java) {
Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray())
}
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
}
@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())
}
}
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatStatus
@@ -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
@@ -312,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 {
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
@@ -112,9 +130,12 @@ class LoTWRepositoryTest {
assertEquals("A50QO", qso.call)
assertEquals("FO-29", qso.satName)
assertEquals("CW", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("V/U", qso.bandLabel)
// ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
// Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
// BAND=2M/FREQ=145.85000 + BAND_RX=70CM/FREQ_RX=436.80500, i.e. BAND=uplink.
assertEquals("70CM", qso.bandUp)
assertEquals("2M", qso.bandDown)
assertEquals("U/V", qso.bandLabel)
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
}
@@ -169,6 +190,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 +292,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,292 @@
/*
* 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.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The logbook split reported 2026-09-28: a QSO uploaded from the app and the confirmation LoTW
* reported for it ended up as two rows, because the record keeps the tracking-source name
* ("SAUDISAT 1C") and the uplink band while the report carries the ARRL name ("SO-50") — and,
* before the direction fix, the mirrored band pair.
*/
class QsoRepositoryTest {
private val dao = FakeQsoDao()
private val repository = QsoRepository(dao, Dispatchers.Unconfined)
/** 2026-09-16 07:45Z, an SO-50 contact of the user's own report. */
private val qsoStart = 1_789_544_700_000L
@Test
fun mergeLoTW_confirmsTheUploadedRecordInsteadOfAddingASecondRow() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity()) // 145.850 -> 2M
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The row is also renamed to the ARRL spelling while it is merged.
assertEquals("SO-50", rows.first().satelliteName)
assertEquals(145_850_000L, rows.first().txFrequencyHz)
}
@Test
fun mergeLoTW_foldsRowsSplitByTheOldNameAndBandMismatch() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
// Row imported before the fix: ARRL name and the mirrored band pair (BAND_RX read as
// the uplink), so it never folded into the local record.
dao.save(
reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity()
)
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The operator's own frequencies survive the consolidation.
assertEquals(145_850_000L, rows.first().txFrequencyHz)
assertEquals(436_795_000L, rows.first().rxFrequencyHz)
}
@Test
fun mergeLoTW_keepsAnotherContactOfTheSamePassApart() = runBlocking {
dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
val otherOperator = reportConfirmation(satName = "SO-50").copy(theirCallsign = "BG5JVM")
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50"), otherOperator))
val rows = dao.getAll()
assertEquals(2, rows.size)
// The local row is confirmed by its own report entry; the second operator's contact
// is a separate QSO and stays a separate, separately confirmed row.
assertEquals(setOf("BG5JSB", "BG5JVM"), rows.map { it.theirCallsign }.toSet())
assertTrue(rows.all { it.lotwConfirmed })
}
@Test
fun consolidateConfirmations_repairsAnAlreadySplitLogbook() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
// Row imported before the fix: ARRL name + mirrored band pair.
dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
assertEquals(1, repository.consolidateConfirmations())
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// Nothing left to fold on a second pass.
assertEquals(0, repository.consolidateConfirmations())
}
@Test
fun consolidateConfirmations_renamesRowsImportedUnderATrackerName() = runBlocking {
// A record logged before names were normalised at write time.
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
repository.consolidateConfirmations()
val row = dao.getAll().single()
assertEquals(id, row.id)
assertEquals("SO-50", row.satelliteName)
// Upload state and the operator's own frequencies survive the rename.
assertTrue(row.lotwUploaded)
assertEquals(145_850_000L, row.txFrequencyHz)
// A satellite ARRL does not list keeps whatever the tracker published.
dao.save(loggedInApp("FOO-1", lotwUploaded = false).toEntity())
repository.consolidateConfirmations()
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
}
@Test
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
// mirrored (downlink stored as the uplink), and no local row to fold it into, so it
// sits as its own QSL row.
val staleId = dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(staleId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The stale row adopts the report's (uplink-first) direction instead of being skipped
// forever by the dedupe key.
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
}
@Test
fun save_editingAnUploadedRecordCreatesANewRecord() = runBlocking {
val originalId = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
val edited = loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = originalId, theirCallsign = "BG5JVM")
val newId = repository.save(edited)
assertNotEquals(originalId, newId)
val rows = dao.getAll()
assertEquals(2, rows.size)
// The original keeps its identity and upload state, untouched.
val original = rows.first { it.id == originalId }
assertEquals("BG5JSB", original.theirCallsign)
assertTrue(original.lotwUploaded)
// The edited content is a fresh, uploadable record.
val created = rows.first { it.id == newId }
assertEquals("BG5JVM", created.theirCallsign)
assertFalse(created.lotwUploaded)
assertFalse(created.lotwConfirmed)
}
@Test
fun save_editingAnUnuploadedRecordUpdatesInPlace() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false))
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("BG5JVM", rows.first().theirCallsign)
}
@Test
fun save_editingAnUploadedRecordWithoutChangesKeepsOneRow() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id))
assertEquals(1, dao.getAll().size)
}
@Test
fun save_editingAnUploadedRecordWithOnlySecondsChangedKeepsOneRow() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
// The edit dialog edits whole minutes and rounds the seconds away on save, so a
// seconds-only difference must not be treated as an edit (no new record).
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id, startUtcMillis = qsoStart + 10_000L))
assertEquals(1, dao.getAll().size)
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
startUtcMillis = qsoStart,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwUploaded = lotwUploaded
)
/** A confirmation as the report parser + [toConfirmedRecord] build it (BAND carries the uplink). */
private fun reportConfirmation(satName: String) = GridQso(
call = "BG5JSB",
epochMs = qsoStart,
satName = satName,
mode = "FM",
bandUp = "2M",
bandDown = "70CM",
dxcc = 318,
country = "China",
cqz = 24,
state = "GD",
myGrid = "OL62",
myGrids = setOf("OL62", "OL63")
).toConfirmedRecord("BA7OPF")
private fun QsoRecord.toEntity() = QsoEntity(
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 = status.name,
dedupeKey = listOf(startUtcMillis, theirCallsign, myCallsign, txFrequencyHz, mode, submode, satelliteName)
.joinToString("|"),
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
private class FakeQsoDao : QsoDao {
private val rows = linkedMapOf<Long, QsoEntity>()
private var nextId = 1L
override fun observeAll(): Flow<List<QsoEntity>> = flowOf(rows.values.toList())
override suspend fun find(id: Long): QsoEntity? = rows[id]
override suspend fun getAll(): List<QsoEntity> = rows.values.toList()
override suspend fun getDedupeKeys(): List<String> = rows.values.map { it.dedupeKey }
override suspend fun save(record: QsoEntity): Long {
val id = if (record.id == 0L) nextId++ else record.id
rows[id] = record.copy(id = id)
return id
}
override suspend fun importRecords(records: List<QsoEntity>): List<Long> = records.map { save(it) }
override suspend fun delete(id: Long) {
rows.remove(id)
}
override suspend fun markUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
}
}
}
@@ -112,22 +112,181 @@ class SelectionRepoSearchTest {
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
private fun createRepo(items: List<SatItem>): ISelectionRepo {
@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),
localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
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> = emptyList()
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
@@ -136,6 +295,7 @@ private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILoca
}
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())
@@ -170,8 +330,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun 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
@@ -179,6 +343,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
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
@@ -189,6 +355,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
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
Binary file not shown.
Binary file not shown.
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,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.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
/**
* Folds LoTW confirmations that were imported as their own rows back into the local
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
* Confirmations merged before the tracker-name/band identity fix split every contact in
* two; this repairs the existing logbook. Returns the number of merged rows.
*/
suspend fun consolidateConfirmations(): Int
}
@@ -0,0 +1,46 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.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,148 @@
/*
* 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.logbook
import java.util.Locale
/**
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
*
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
* satellite.
*
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
* exact config.tq6 satellite names. Generated 2026-09-27 from:
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
* here is a real config.tq6 name.
*/
object LoTWSatelliteAliases {
val table: Map<String, String> = mapOf(
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
"ZARYA" to "ARISS",
)
/**
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
*/
fun lookup(name: String): String? {
val normalized = normalize(name)
table[normalized]?.let { return it }
return table.entries
.filter { it.key.length >= 5 && normalized.contains(it.key) }
.maxByOrNull { it.key.length }
?.value
}
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
/**
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
* from [table], then an official name contained in the tracker name. Returns the official
* name, or null when the tracker name is not an ARRL satellite at all.
*/
fun resolve(name: String, officialNames: Collection<String>): String? {
val trimmed = name.trim()
if (trimmed.isEmpty()) return null
val normalized = trimmed.uppercase(Locale.US)
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
}
/**
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
* when the two share no characters.
*/
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
val normalized = normalize(query)
if (normalized.isEmpty()) return emptyList()
val fromAliases = table.entries
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
.map { it.value }
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
.distinct()
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
.take(limit)
}
}
@@ -0,0 +1,70 @@
/*
* 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.logbook
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
/**
* Wording for the reasons a QSO cannot be signed for LoTW.
*
* The logbook used to report every one of these as "invalid call/date", which sent the operator
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
* a satellite name ARRL does not know.
*/
fun LoTWProblem.label(): String = when (this) {
LoTWProblem.BAND -> "missing frequency/band"
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
LoTWProblem.MODE -> "mode not accepted by LoTW"
LoTWProblem.QSO_DATE -> "date outside the certificate"
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
LoTWProblem.STATION_GRID -> "station grid missing"
LoTWProblem.STATION_REGION -> "station region missing"
LoTWProblem.STATION_ZONE -> "station zones missing"
LoTWProblem.STATION_IOTA -> "station IOTA missing"
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
else -> name.lowercase().replace('_', ' ')
}
/**
* One-line explanation of why records were left out of an upload, e.g.
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
* Returns an empty string when nothing was skipped.
*/
fun unavailableUploadSummary(
total: Int,
reasons: Map<LoTWProblem, Int> = emptyMap(),
details: Map<LoTWProblem, String> = emptyMap(),
duplicates: Int = 0,
incomplete: Int = 0
): String {
if (total <= 0) return ""
val parts = reasons.entries
.sortedByDescending { it.value }
.map { (problem, count) ->
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
"$count× ${problem.label()}$detail"
}
.toMutableList()
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
if (incomplete > 0) parts += "$incomplete× record not completed"
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
return "$subject — $detail"
}
@@ -0,0 +1,178 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.logbook
import java.util.Locale
import kotlin.math.abs
/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
* parenthetical-free prefix. Kept as the last-resort identity for names the alias
* table does not know (see [satelliteIdentity]). */
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
/**
* The satellite identity both sides of a match resolve to.
*
* A local record keeps whatever the tracking source published ("SAUDISAT 1C",
* "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
* name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
* sides of the SAME contact unequal, so a downloaded confirmation was stored as a
* second, separate QSO instead of confirming the uploaded one.
*
* Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
* tracker names onto the ARRL names and every ARRL name onto itself. Names the table
* does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
* parenthetical-free prefix, i.e. they simply never match anything else.
*/
fun satelliteIdentity(name: String): String {
val trimmed = name.trim()
if (trimmed.isEmpty()) return ""
val prefix = satMatchKey(trimmed)
return LoTWSatelliteAliases.lookup(trimmed)
?: LoTWSatelliteAliases.lookup(prefix)
?: prefix
}
/**
* The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
* one, otherwise the name as published.
*
* Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
* every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
* one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
* for the logbook, the ADIF export and the signature. Names the alias table does not know are
* left exactly as they are: satellites ARRL does not list have no official name to store, and the
* recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
*/
fun officialSatelliteName(name: String, officialNames: Collection<String> = emptyList()): String {
val trimmed = name.trim()
if (trimmed.isEmpty()) return ""
LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
val prefix = satMatchKey(trimmed)
return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
}
/**
* Everything about a contact except its bands: the same opposite station, the same
* satellite, the same mode and less than a minute apart. LoTW reports the minute, so
* the time window is what makes a match unambiguous.
*/
fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
val localSat = satelliteIdentity(local.satelliteName)
return localSat.isNotBlank() &&
localSat == satelliteIdentity(remote.satelliteName) &&
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
}
/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
private fun sameBandOrBlank(one: String, other: String): Boolean =
one.isBlank() || other.isBlank() || one.equals(other, true)
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
).joinToString("|")
/** A local row and the confirmation row that belongs to it, as indices into one list. */
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
/**
* Rows that a confirmation should have been folded into but was not, so they can be
* consolidated back into one.
*
* Confirmations merged before the identity fix landed as their own rows: the tracker
* name vs ARRL name comparison failed, and the band direction of the report was read
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
* were stored twice. The pair is only accepted when the contact identity matches and
* the bands are either identical or exactly mirrored — the signature of that second
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
*/
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
val taken = mutableSetOf<Int>()
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
val confirmation = records[confirmationIndex]
val localIndex = pendingIndices.firstOrNull { index ->
index !in taken && sameContactIdentity(records[index], confirmation) &&
bandsMatchOrMirror(records[index], confirmation)
} ?: return@mapNotNull null
taken += localIndex
SplitConfirmationPair(localIndex, confirmationIndex)
}
}
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
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)
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
@@ -26,9 +26,12 @@ package com.rtbishop.look4sat.core.domain.model
* @param epochMs QSO date+time in UTC milliseconds
* @param satName satellite name (e.g. "FO-29")
* @param mode ADIF mode (FM / CW / SSB ...)
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
* LoTW did not include it
* @param bandUp uplink band, the band of the transmitted frequency (ADIF BAND:
* "2M", "70CM", "10M"...). Measured against the live report of the
* user's own uploads: SO-50 arrives as BAND=2M/FREQ=145.85 with
* BAND_RX=70CM/FREQ_RX=436.8, i.e. BAND carries the uplink.
* @param bandDown downlink band (ADIF BAND_RX) — may be empty when LoTW did not
* include it
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
* <DXCC>), or null when LoTW omitted it
* @param country DXCC entity name (ADIF <COUNTRY>), or null
@@ -49,8 +52,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
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
/** Latest release info fetched from the fork's GitHub releases API. */
@@ -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
)
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
@@ -75,9 +75,23 @@ data class OtherSettings(
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
val compassOffsetDegrees: Float = 0f
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>,
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.AmSatReportSubmission
@@ -0,0 +1,134 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.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
/** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
* signed, so the logbook UI validates/picks satellite names from this list. */
suspend fun satelliteCatalog(): List<String>
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,
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
val unavailableSkipped: Int = 0,
/** Why those records were un-signable, so the operator sees the real cause. */
val unavailableReasons: Map<LoTWProblem, Int> = emptyMap(),
/** Records dropped because the same contact already appears earlier in the batch. */
val duplicateSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList()
)
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
data class LoTWUploadAudit(
val total: Int,
val pending: Int,
val uploaded: Int,
val unknown: Int,
val unavailable: Int,
/** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
val reasons: Map<LoTWProblem, Int> = emptyMap(),
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
val details: Map<LoTWProblem, String> = emptyMap(),
/** Records dropped as duplicates of an earlier record in the same selection. */
val duplicates: Int = 0,
/** Records that are not marked complete, so they are not uploadable at all. */
val incomplete: Int = 0
)
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
}
@@ -0,0 +1,65 @@
/*
* 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 kotlinx.coroutines.flow.StateFlow
/**
* A single position fix from the device's location provider, with the
* horizontal accuracy reported by the platform.
*/
data class LocationFix(
val latitude: Double,
val longitude: Double,
val altitudeMeters: Double = 0.0,
val accuracyMeters: Float,
val epochMs: Long
) {
/** ARRL VUCC work needs a fix good to the 20 ft (6.1 m) rule. */
fun isPreciseEnough(limitMeters: Float): Boolean = accuracyMeters in 0f..limitMeters
}
/** What the live-location source is currently able to give. */
sealed interface LocationState {
/** No fix yet - either permission is missing or the provider is warming up. */
data class Waiting(val permissionGranted: Boolean, val providerEnabled: Boolean) : LocationState
data class Fix(val location: LocationFix) : LocationState
}
/**
* Live device position for field tools (currently the Grid Finder). Kept
* separate from [ISettingsRepo.stationPosition] on purpose: collecting fixes
* here must never move the user's station, which is what drives every pass
* prediction in the app.
*
* Callers register with [startUpdates] while their screen is alive and MUST
* call [stopUpdates] when it goes away.
*/
interface ILocationRepo {
val state: StateFlow<LocationState>
/** Whether the app currently holds the fine-location permission. */
fun hasPermission(): Boolean
/** Begins delivering fixes; no-op when permission is missing. */
fun startUpdates()
/** Stops delivering fixes and releases the platform listener. */
fun stopUpdates()
}
@@ -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,9 +35,15 @@ 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 locationRepo: ILocationRepo
val mutualPassData: StateFlow<MutualPassData>
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
val pendingLoTWStationGrid: StateFlow<String?>
fun setMutualPassData(data: MutualPassData)
fun setPendingLoTWStationGrid(grid: String?)
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -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>
}
@@ -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,6 +111,11 @@ 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
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.LatestRelease
@@ -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,193 @@
/*
* 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/>.
*
* Contains code ported from OrbitDeckiOS (https://github.com/prstoetzer/OrbitDeckiOS),
* Copyright (c) 2025 Paul Stoetzer, N8HM, licensed under the MIT License.
* See THIRD_PARTY_NOTICES.md for the full MIT license text.
*/
package com.rtbishop.look4sat.core.domain.utility
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.round
/**
* Grid-line / grid-corner geometry for the 4-character Maidenhead squares that
* VUCC credits, used by the Grid Finder field tool.
*
* A 4-character square is 1 degree tall (boundaries at integer latitudes) and
* 2 degrees wide (boundaries at even offsets from the -180 degree antimeridian),
* so a station's distance to the nearest VUCC line and corner is a matter of
* simple planar geometry at the station's latitude.
*
* Geometry ported from OrbitDeck for iOS (MIT License, (c) 2026 Paul Stoetzer,
* N8HM) - `GridGeometry` in `Views/HomeView.swift` and `vuccGrids` in
* `Engine/FeatureEngine.swift`. Golden values are pinned by GridGeometryTest.
*/
/** ARRL VUCC "20 ft rule": a fix within this distance of a boundary counts as on it. */
const val VUCC_BOUNDARY_TOLERANCE_METERS = 20.0 * 0.3048
/** One degree of latitude in metres; 4-character squares are 1 degree tall. */
private const val METERS_PER_DEGREE_LATITUDE = 111_320.0
/** Nudge used to name the square on the far side of a boundary (display only). */
private const val NEIGHBOUR_NUDGE_DEGREES = 0.0005
/** Nudge used by [vuccClaimableGrids] so the neighbour lands squarely across. */
private const val CLAIM_NEIGHBOUR_NUDGE_DEGREES = 0.001
private const val DEG_TO_RAD = 0.017453292519943295
private const val RAD_TO_DEG = 57.29577951308232
/** Where the nearest 4-character boundary lies relative to the fix. */
enum class GridBoundaryDirection { NORTH, SOUTH, EAST, WEST }
/** ARRL VUCC standing of a fix, derived from how many squares it may claim. */
enum class GridFixStatus { INSIDE_GRID, ON_GRID_LINE, ON_GRID_CORNER }
/**
* Distance and bearing to the nearest 4-character grid corner and grid line,
* plus the locators of the squares that meet there. All distances are metres
* computed on a flat approximation local to the fix (the same approximation
* the source implementation uses; over the <=100 km distances involved the
* error is far below a GPS fix's own accuracy).
*/
data class GridGeometry(
val grid4: String,
val grid6: String,
val grid8: String,
val latLineMeters: Double,
val latLineDirection: GridBoundaryDirection,
val lonLineMeters: Double,
val lonLineDirection: GridBoundaryDirection,
val cornerMeters: Double,
val cornerBearingDegrees: Double,
val cornerGrids: List<String>,
val nearestLineMeters: Double,
val nearestLineBearingDegrees: Double,
val nearestLineIsLatitude: Boolean,
val nearestLineGrids: List<String>
)
/**
* The geometry of the VUCC (4-character) grid around a position, or null when
* the position is out of range.
*/
fun gridGeometry(latitude: Double, longitude: Double): GridGeometry? {
val grid4 = positionToGrid4(latitude, longitude) ?: return null
val grid6 = positionToQth(latitude, longitude) ?: return null
val grid8 = positionToGrid8(latitude, longitude) ?: return null
// 4-character boundaries: integer latitudes, even-degree longitudes.
val latBoundary = round(latitude)
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
val dNorth = (latBoundary - latitude) * METERS_PER_DEGREE_LATITUDE
val dEast = (lonBoundary - longitude) * metersPerDegreeLon
val otherLat = latBoundary - if (latitude >= latBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
val otherLon = lonBoundary - if (longitude >= lonBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
val cornerGrids = setOfNotNull(
positionToGrid4(latitude, longitude),
positionToGrid4(otherLat, longitude),
positionToGrid4(latitude, otherLon),
positionToGrid4(otherLat, otherLon)
).sorted()
val onLatitude = abs(dNorth) <= abs(dEast)
val nearestLineGrids = if (onLatitude) {
setOfNotNull(grid4, positionToGrid4(otherLat, longitude)).sorted()
} else {
setOfNotNull(grid4, positionToGrid4(latitude, otherLon)).sorted()
}
return GridGeometry(
grid4 = grid4,
grid6 = grid6,
grid8 = grid8,
latLineMeters = abs(dNorth),
latLineDirection = if (dNorth >= 0) GridBoundaryDirection.NORTH else GridBoundaryDirection.SOUTH,
lonLineMeters = abs(dEast),
lonLineDirection = if (dEast >= 0) GridBoundaryDirection.EAST else GridBoundaryDirection.WEST,
cornerMeters = hypot(dNorth, dEast),
cornerBearingDegrees = bearingDegrees(dNorth, dEast),
cornerGrids = cornerGrids,
nearestLineMeters = if (onLatitude) abs(dNorth) else abs(dEast),
nearestLineBearingDegrees = if (onLatitude) {
if (dNorth >= 0) 0.0 else 180.0
} else {
if (dEast >= 0) 90.0 else 270.0
},
nearestLineIsLatitude = onLatitude,
nearestLineGrids = nearestLineGrids
)
}
/**
* The 4-character squares a station at this position may claim under ARRL VUCC
* rules: normally one, but two when the fix is on the line between two squares
* and four when it is on the corner where four squares meet. A fix within
* [toleranceMeters] of a boundary is considered to be on it (the 20 ft rule).
*/
fun vuccClaimableGrids(
latitude: Double,
longitude: Double,
toleranceMeters: Double = VUCC_BOUNDARY_TOLERANCE_METERS
): List<String> {
val own = positionToGrid4(latitude, longitude) ?: return emptyList()
val grids = mutableSetOf(own)
val latBoundary = round(latitude)
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
val onLatitude = abs(latitude - latBoundary) * METERS_PER_DEGREE_LATITUDE <= toleranceMeters
val onLongitude = abs(longitude - lonBoundary) * metersPerDegreeLon <= toleranceMeters
if (!onLatitude && !onLongitude) return grids.sorted()
// Step just past the boundary so the neighbour's locator is unambiguous.
val otherLat = latBoundary - if (latitude >= latBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
val otherLon = lonBoundary - if (longitude >= lonBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
if (onLatitude) {
positionToGrid4(otherLat, longitude)?.let(grids::add)
}
if (onLongitude) {
positionToGrid4(latitude, otherLon)?.let(grids::add)
}
if (onLatitude && onLongitude) {
positionToGrid4(otherLat, otherLon)?.let(grids::add)
}
return grids.sorted()
}
/** VUCC standing implied by the number of squares a fix may claim. */
fun gridFixStatus(claimableGrids: List<String>): GridFixStatus = when {
claimableGrids.size >= 4 -> GridFixStatus.ON_GRID_CORNER
claimableGrids.size >= 2 -> GridFixStatus.ON_GRID_LINE
else -> GridFixStatus.INSIDE_GRID
}
private fun bearingDegrees(dNorth: Double, dEast: Double): Double {
val bearing = atan2(dEast, dNorth) * RAD_TO_DEG
return if (bearing < 0.0) bearing + 360.0 else bearing
}
@@ -69,6 +69,45 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
/**
* Convert a position to its 4-char Maidenhead locator (field + square), the
* square VUCC credits, upper-cased. Returns null outside the Maidenhead grid
* (lat >= 90 is out of range: the top row is 80..90 N). The longitude is
* wrapped into [-180, 180) first, so fixes either side of the antimeridian
* resolve to the square they are actually in.
*/
fun positionToGrid4(latitude: Double, longitude: Double): String? {
if (latitude < -90.0 || latitude >= 90.0) return null
val normalizedLon = normalizeLongitude(longitude)
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((normalizedLon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val subLat = ((latitude + 90.0) % 10.0).toInt()
val subLon = ((normalizedLon + 180.0) % 20.0 / 2.0).toInt()
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
}
/**
* Convert a position to its 8-char Maidenhead locator: the 6-char locator plus
* the numeric extended-square pair (the Maidenhead maximum). Returns null for
* out-of-range positions.
*/
fun positionToGrid8(latitude: Double, longitude: Double): String? {
val normalizedLon = normalizeLongitude(longitude)
val base = positionToQth(latitude, normalizedLon) ?: return null
val latitudeOffset = latitude.coerceIn(-90.0, 89.999999) + 90.0
val longitudeOffset = normalizedLon.coerceIn(-180.0, 179.999999) + 180.0
val subLon = 2.0 / 24.0
val subLat = 1.0 / 24.0
val lonDigit = minOf(9, ((longitudeOffset % subLon) / subLon * 10).toInt())
val latDigit = minOf(9, ((latitudeOffset % subLat) / subLat * 10).toInt())
return "$base$lonDigit$latDigit"
}
/** Wrap any longitude into [-180, 180). */
private fun normalizeLongitude(longitude: Double): Double {
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.utility
/**
@@ -0,0 +1,53 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import kotlin.math.abs
/** Nominal uplink frequency: the middle of the passband when one is published. */
fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
/** Nominal downlink frequency: the middle of the passband when one is published. */
fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
/**
* The FM voice repeater of a satellite, if it has one.
*
* A voice repeater is the single-frequency FM transceiver the operator actually
* talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
* CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
* uplink and a downlink already filters them out; the FM mode check removes
* AFSK/APRS and linear entries.
*
* Ranking when several FM duplex entries exist (ISS is the awkward case, it also
* lists crew V/V channels):
* 1. entries described as a repeater / voice channel,
* 2. amateur service entries,
* 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
*/
fun List<SatRadio>.voiceRepeater(): SatRadio? = asSequence()
.filter { it.isAlive }
.filter { it.uplinkHz() != null && it.downlinkHz() != null }
.filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
.sortedWith(
compareByDescending<SatRadio> { it.info.contains("repeater", true) || it.info.contains("voice", true) }
.thenByDescending { it.service.equals("Amateur", true) }
.thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
)
.firstOrNull()
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
@@ -0,0 +1,78 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
class LoTWSatelliteAliasesTest {
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
@Test
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
}
@Test
fun `normalize strips separators and case`() {
assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
}
@Test
fun `lookup ignores unknown and placeholder names`() {
assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
// Recycled temporary designations must never resolve: they would sign the wrong object.
assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
}
@Test
fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
}
@Test
fun `resolve rejects names that are not ARRL satellites`() {
assertNull(LoTWSatelliteAliases.resolve("", catalogue))
assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
}
@Test
fun `suggestions find the ARRL name from a tracker spelling`() {
assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
}
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
@@ -0,0 +1,140 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
* satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
class VoiceRepeaterTest {
private fun radio(
uuid: String,
info: String,
up: Long? = null,
upHigh: Long? = null,
down: Long? = null,
downHigh: Long? = null,
upMode: String? = null,
downMode: String? = null,
alive: Boolean = true,
service: String? = null,
catnum: Int = 12345
) = SatRadio(
uuid = uuid, info = info, isAlive = alive,
downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
isInverted = false, catnum = catnum, service = service
)
// SO-50 (27607)
private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
private val so50Voice = radio(
"so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
)
// ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
private val issCrew2 = radio(
"iss-crew2", "Mode V/V FM (crew R2+3)",
up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
)
private val issCrew1 = radio(
"iss-crew1", "Mode V/V FM (crew R1)",
up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
)
private val issVoice = radio(
"iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
service = "Amateur", catnum = 25544
)
private val issAprs = radio(
"iss-aprs", "Mode V APRS",
up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
)
// AO-123 / ASRTU-1 (61781)
private val ao123Voice = radio(
"ao123-voice", "Mode V/U - FM Transceiver",
up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
service = "Amateur", catnum = 61781
)
@Test
fun voiceRepeater_picksSo50VoicePair() {
val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
assertEquals("so50-voice", repeater?.uuid)
assertEquals(145_850_000L, repeater?.uplinkHz())
assertEquals(436_795_000L, repeater?.downlinkHz())
}
@Test
fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
// Order must not matter - ranking decides.
assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
}
@Test
fun voiceRepeater_picksAo123Transceiver() {
val repeater = listOf(ao123Voice).voiceRepeater()
assertEquals(145_850_000L, repeater?.uplinkHz())
assertEquals(435_400_000L, repeater?.downlinkHz())
}
@Test
fun voiceRepeater_ignoresBeaconsWithoutUplink() {
assertNull(listOf(so50Tlm).voiceRepeater())
}
@Test
fun voiceRepeater_ignoresDeadEntries() {
val dead = so50Voice.copy(uuid = "dead", isAlive = false)
assertNull(listOf(dead).voiceRepeater())
}
@Test
fun voiceRepeater_ignoresLinearTransponders() {
val linear = radio(
"lin", "Linear Transponder",
up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
upMode = "LSB", downMode = "USB"
)
assertNull(listOf(linear).voiceRepeater())
}
@Test
fun uplinkHz_usesPassbandMiddleWhenPresent() {
val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
assertEquals(145_850_000L, wide.uplinkHz())
assertEquals(145_850_000L, so50Voice.uplinkHz())
}
@Test
fun downlinkHz_isNullWhenNoDownlink() {
assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
}
}
@@ -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,273 @@
/*
* 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.assertNotEquals
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)
}
// region logbook split (tracker name / band direction, 2026-09-28)
@Test
fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
// Local records keep the tracking-source name, LoTW reports the ARRL name; both
// must land on one identity or every confirmation becomes a second row.
assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
assertEquals("SO-50", satelliteIdentity("SO-50"))
assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
assertEquals("ARISS", satelliteIdentity("ARISS"))
assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
assertEquals("FO-29", satelliteIdentity("JAS 2"))
}
@Test
fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
// Guards the whole table: an entry whose target does not resolve back to the same
// identity would split that satellite's contacts again.
LoTWSatelliteAliases.table.forEach { (tracker, official) ->
assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
}
}
@Test
fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
// "OBJECT xx" names are reused between objects and deliberately unmapped, so they
// must never resolve onto a real ARRL satellite.
assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
}
@Test
fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
// The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
// picked up the confirmation LoTW reported for it.
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
assertTrue(sameConfirmedContact(uploaded, reported))
}
@Test
fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
.copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
assertFalse(sameConfirmedContact(uploaded, otherOperator))
assertFalse(sameConfirmedContact(uploaded, anotherMinute))
}
@Test
fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
// Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
// mirrored band direction the old parser produced (band=downlink).
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 1L, lotwUploaded = true)
val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
val pairs = splitConfirmationPairs(listOf(uploaded, imported))
assertEquals(1, pairs.size)
assertEquals(0, pairs.first().localIndex)
assertEquals(1, pairs.first().confirmationIndex)
}
@Test
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 1L, lotwUploaded = true)
// Another operator on another satellite in the same minute must not be folded in.
val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
.copy(id = 2L, theirCallsign = "BG5JVM")
// A contact on a satellite the local record does not name at all stays separate.
val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
}
/** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
@Test
fun officialSatelliteName_storesTheArlName() {
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
assertEquals("FO-29", officialSatelliteName("JAS 2"))
// Already-ARRL names are left alone (the rewrite is idempotent).
assertEquals("SO-50", officialSatelliteName("SO-50"))
assertEquals("AO-123", officialSatelliteName("AO-123"))
// Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
assertEquals("FOO-1", officialSatelliteName("FOO-1"))
assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
assertEquals("", officialSatelliteName(" "))
}
@Test
fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
val catalogue = listOf("BO-102", "SO-50")
// "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
}
private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
startUtcMillis = REPORTED_START,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
txFrequencyHz = uplinkHz,
rxFrequencyHz = downlinkHz,
band = frequencyBand(uplinkHz),
rxBand = frequencyBand(downlinkHz),
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwUploaded = true
)
/** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
startUtcMillis = REPORTED_START,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
band = band,
rxBand = rxBand,
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true
)
private companion object {
/** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
const val REPORTED_START = 1_789_544_700_000L
}
// endregion
}
@@ -21,6 +21,7 @@ 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
@@ -261,6 +262,9 @@ class LoTWGridSyncTest {
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
@@ -281,6 +285,8 @@ class LoTWGridSyncTest {
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()
@@ -0,0 +1,171 @@
/*
* 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.utility
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Golden values were produced by a line-by-line port of the reference
* implementation (OrbitDeck for iOS, MIT) covering four hemispheres/quadrant
* cases plus the two awkward longitudes: the Greenwich meridian and the
* antimeridian.
*/
class GridGeometryTest {
private val meterDelta = 0.5
private val bearingDelta = 0.05
@Test
fun `station at home square reports both boundaries and the corner`() {
val geometry = requireNotNull(gridGeometry(22.3542, 113.6250))
assertEquals("OL62", geometry.grid4)
assertEquals("OL62TI", geometry.grid6)
assertEquals("OL62TI55", geometry.grid8)
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
assertEquals(38607.9, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(55183.8, geometry.cornerMeters, meterDelta)
assertEquals(135.60, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
assertEquals(38607.9, geometry.nearestLineMeters, meterDelta)
assertEquals(90.0, geometry.nearestLineBearingDegrees, bearingDelta)
assertEquals(false, geometry.nearestLineIsLatitude)
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
}
@Test
fun `fix on the 114 east line sits on the boundary itself`() {
val geometry = requireNotNull(gridGeometry(22.3542, 114.0000))
assertEquals("OL72", geometry.grid4)
assertEquals("OL72AI", geometry.grid6)
assertEquals("OL72AI05", geometry.grid8)
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
assertEquals(0.0, geometry.lonLineMeters, meterDelta)
assertEquals(39429.5, geometry.cornerMeters, meterDelta)
assertEquals(180.00, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
}
@Test
fun `fix on the 22 north 114 east corner names all four squares`() {
val geometry = requireNotNull(gridGeometry(22.00002, 114.00003))
assertEquals("OL72", geometry.grid4)
assertEquals(2.2, geometry.latLineMeters, meterDelta)
assertEquals(3.1, geometry.lonLineMeters, meterDelta)
assertEquals(3.8, geometry.cornerMeters, meterDelta)
assertEquals(234.28, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
assertEquals("OL71", geometry.nearestLineGrids.first())
}
@Test
fun `greenwich meridian fix reports a zero distance latitude line to the north`() {
val geometry = requireNotNull(gridGeometry(45.0000, -0.0004))
assertEquals("IN95", geometry.grid4)
assertEquals("IN95XA", geometry.grid6)
assertEquals("IN95XA90", geometry.grid8)
assertEquals(0.0, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.NORTH, geometry.latLineDirection)
assertEquals(31.5, geometry.lonLineMeters, meterDelta)
assertEquals(listOf("IN94", "IN95", "JN04", "JN05"), geometry.cornerGrids)
assertEquals(true, geometry.nearestLineIsLatitude)
assertEquals(listOf("IN94", "IN95"), geometry.nearestLineGrids)
}
@Test
fun `antimeridian fix wraps into the eastern square`() {
val geometry = requireNotNull(gridGeometry(0.05, 179.9998))
assertEquals("RJ90", geometry.grid4)
assertEquals("RJ90XB", geometry.grid6)
assertEquals("RJ90XB92", geometry.grid8)
assertEquals(22.3, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(179.77, geometry.cornerBearingDegrees, bearingDelta)
// The corner's four squares straddle the antimeridian, so the western
// pair rolls over to the A-series fields.
assertEquals(listOf("AI09", "AJ00", "RI99", "RJ90"), geometry.cornerGrids)
assertEquals(listOf("AJ00", "RJ90"), geometry.nearestLineGrids)
}
@Test
fun `southern hemisphere fix keeps its bearings right way round`() {
val geometry = requireNotNull(gridGeometry(-33.86, 151.21))
assertEquals("QF56", geometry.grid4)
assertEquals("QF56OD", geometry.grid6)
assertEquals(15584.8, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
assertEquals(73027.8, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(102.05, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(180.0, geometry.nearestLineBearingDegrees, bearingDelta)
}
@Test
fun `out of range positions yield no geometry`() {
assertNull(gridGeometry(90.0, 0.0))
assertNull(gridGeometry(-91.0, 0.0))
}
@Test
fun `vucc claims one square when the fix is inside`() {
assertEquals(listOf("OL62"), vuccClaimableGrids(22.3542, 113.6250))
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(vuccClaimableGrids(22.3542, 113.6250)))
}
@Test
fun `vucc claims both squares on a line`() {
val claimed = vuccClaimableGrids(22.3542, 114.0000)
assertEquals(listOf("OL62", "OL72"), claimed)
assertEquals(GridFixStatus.ON_GRID_LINE, gridFixStatus(claimed))
}
@Test
fun `vucc claims all four squares on a corner`() {
val claimed = vuccClaimableGrids(22.00002, 114.00003)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), claimed)
assertEquals(GridFixStatus.ON_GRID_CORNER, gridFixStatus(claimed))
}
@Test
fun `vucc claims nothing extra just outside the 20 foot tolerance`() {
// Same corner, but ~38 m away: outside the 20 ft (6.096 m) rule.
val claimed = vuccClaimableGrids(22.0002, 114.0003)
assertEquals(listOf("OL72"), claimed)
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(claimed))
}
@Test
fun `vucc tolerance boundary is 20 feet`() {
assertEquals(6.096, VUCC_BOUNDARY_TOLERANCE_METERS, 0.0001)
// 5 m north of the 22 N line: inside the tolerance for a custom, tighter
// call, outside it when the caller demands sub-metre precision.
assertTrue(vuccClaimableGrids(22.00005, 113.62).size >= 2)
assertEquals(1, vuccClaimableGrids(22.00005, 113.62, toleranceMeters = 1.0).size)
}
@Test
fun `grid4 ignores longitudes beyond the antimeridian`() {
assertNull(positionToGrid4(91.0, 0.0))
// A longitude of 200 degrees is the same meridian as -160 degrees.
assertEquals(positionToGrid4(30.0, -160.0), positionToGrid4(30.0, 200.0))
}
}
@@ -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
@@ -447,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
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.presentation
import androidx.compose.foundation.Canvas
@@ -61,8 +61,9 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
SideEffect {
val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
// Light theme needs dark status/nav bar icons; the dark theme needs light ones.
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@@ -45,6 +45,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object RadarDestination : NavKey
/** Full-screen field tool: guides the operator onto a VUCC grid line or corner. */
@Serializable
data object GridFinderDestination : NavKey
/** LoTW upload certificate + station location page, reached from the Grid Finder. */
@Serializable
data object LoTWUploadDestination : NavKey
interface IDeeplinkMatcher {
fun match(deeplink: String): NavKey?
}
@@ -0,0 +1,82 @@
/*
* 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.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The fork's Project Wiki: a short overview of what Look4Sat-BA7OPF adds over upstream
* and how to use those features. Shown in the What's-New card (first launch after an
* update) and behind the Settings → Project Wiki entry. Text lives in string resources,
* so it always renders even when the GitHub page is unreachable.
*/
@Composable
fun ProjectWikiBody(modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.project_wiki_intro),
fontSize = 13.sp
)
WikiSection(stringResource(R.string.project_wiki_section_logbook))
WikiEntry(stringResource(R.string.project_wiki_logbook_1))
WikiEntry(stringResource(R.string.project_wiki_logbook_2))
WikiEntry(stringResource(R.string.project_wiki_logbook_3))
WikiHow(stringResource(R.string.project_wiki_logbook_how))
WikiSection(stringResource(R.string.project_wiki_section_mutual))
WikiEntry(stringResource(R.string.project_wiki_mutual_1))
WikiHow(stringResource(R.string.project_wiki_mutual_how))
WikiSection(stringResource(R.string.project_wiki_section_grids))
WikiEntry(stringResource(R.string.project_wiki_grids_1))
WikiHow(stringResource(R.string.project_wiki_grids_how))
}
}
@Composable
private fun WikiSection(text: String) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
@Composable
private fun WikiEntry(text: String) {
Text(text = text, fontSize = 12.sp)
}
@Composable
private fun WikiHow(text: String) {
Text(
text = text,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
}
@@ -0,0 +1,386 @@
/*
* 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.presentation
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.fillMaxWidth
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.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
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 java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.math.roundToLong
private const val DATE_PATTERN = "yyyy-MM-dd"
private const val TIME_PATTERN = "HH:mm:ss"
/**
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
* re-derived from them, because that derivation is what LoTW validates: a record with
* no uplink frequency has no BAND and cannot be signed.
*
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
* a name that resolves to no ARRL satellite cannot be saved. A tracker name such as
* "SAUDISAT 1C" is accepted and stored as the ARRL name it resolves to ("SO-50").
*/
@Composable
fun QsoEditDialog(
record: QsoRecord,
satelliteCandidates: List<String> = emptyList(),
onDismiss: () -> Unit,
onSave: (QsoRecord) -> Unit
) {
val utc = remember { TimeZone.getTimeZone("UTC") }
fun formatDate(millis: Long) = SimpleDateFormat(DATE_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
fun formatTime(millis: Long) = SimpleDateFormat(TIME_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
var callsign by remember(record.id) { mutableStateOf(record.theirCallsign.trim()) }
var dateText by remember(record.id) { mutableStateOf(formatDate(record.startUtcMillis)) }
var timeText by remember(record.id) { mutableStateOf(formatTime(record.startUtcMillis)) }
var txText by remember(record.id) { mutableStateOf(formatMhz(record.txFrequencyHz)) }
var rxText by remember(record.id) { mutableStateOf(formatMhz(record.rxFrequencyHz)) }
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
var pickerOpen by remember(record.id) { mutableStateOf(false) }
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
var commentText by remember(record.id) { mutableStateOf(record.comment) }
var error by remember(record.id) { mutableStateOf("") }
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
val officialSatellite = remember(satelliteText, satelliteCandidates) {
LoTWSatelliteAliases.resolve(satelliteText, satelliteCandidates)
}
val satelliteSuggestions = remember(satelliteText, satelliteCandidates) {
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
}
val txHz = parseMhz(txText)
val rxHz = parseMhz(rxText)
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
.joinToString(" / ")
val errCallsign = stringResource(R.string.qso_edit_err_callsign)
val errDateTime = stringResource(R.string.qso_edit_err_datetime)
val errFrequency = stringResource(R.string.qso_edit_err_frequency)
val errSatellite = stringResource(R.string.qso_edit_err_satellite)
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.qso_edit_title)) },
text = {
Column(
modifier = Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (record.lotwUploaded || record.lotwConfirmed) {
Text(
text = stringResource(R.string.qso_edit_uploaded_notice),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
EditField(stringResource(R.string.qso_edit_callsign), callsign, KeyboardCapitalization.Characters) {
callsign = it.uppercase(Locale.US)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_date), dateText, KeyboardCapitalization.None, Modifier.weight(1f)) {
dateText = it
}
EditField(stringResource(R.string.qso_edit_time), timeText, KeyboardCapitalization.None, Modifier.weight(1f)) {
timeText = it
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_tx), txText, KeyboardCapitalization.None, Modifier.weight(1f)) {
txText = it
}
EditField(stringResource(R.string.qso_edit_rx), rxText, KeyboardCapitalization.None, Modifier.weight(1f)) {
rxText = it
}
}
Text(
text = stringResource(R.string.qso_edit_band, bandText.ifBlank { "—" }),
fontSize = 12.sp,
color = if (txHz == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
EditField(stringResource(R.string.qso_edit_mode), modeText, KeyboardCapitalization.Characters) {
modeText = it.uppercase(Locale.US)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
EditField(
label = stringResource(R.string.qso_edit_satellite),
value = satelliteText,
capitalization = KeyboardCapitalization.Characters,
modifier = Modifier.weight(1f)
) { satelliteText = it.uppercase(Locale.US) }
TextButton(
onClick = { pickerOpen = true },
enabled = satelliteCandidates.isNotEmpty()
) { Text(stringResource(R.string.qso_edit_satellite_pick), fontSize = 12.sp) }
}
when {
// Resolves to an ARRL satellite under a different name: say so, the
// ARRL name is what gets stored and signed.
officialSatellite != null && !officialSatellite.equals(satelliteText.trim(), true) ->
Text(
text = stringResource(R.string.qso_edit_satellite_official, officialSatellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Not an ARRL satellite at all: offer the closest catalogue names.
satelliteText.isNotBlank() && officialSatellite == null -> {
Text(
text = stringResource(R.string.qso_edit_err_satellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.error
)
if (satelliteSuggestions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
satelliteSuggestions.take(4).forEach { candidate ->
FilterChip(
selected = false,
onClick = { satelliteText = candidate },
label = { Text(candidate, fontSize = 11.sp) }
)
}
}
}
}
else -> Text(
text = stringResource(R.string.qso_edit_hint_satellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
sentText = it
}
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
receivedText = it
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
gridText = it.uppercase(Locale.US)
}
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
commentText = it
}
}
if (txHz == null) {
Text(
text = stringResource(R.string.qso_edit_warn_no_freq),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
if (error.isNotBlank()) {
Text(text = error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
TextButton(onClick = {
val millis = parseUtc(dateText, timeText, utc)
val badFrequency = (txText.isNotBlank() && txHz == null) || (rxText.isNotBlank() && rxHz == null)
when {
callsign.isBlank() -> error = errCallsign
millis == null -> error = errDateTime
badFrequency -> error = errFrequency
// Only ARRL satellite names may be stored: a record that cannot be
// resolved here would be rejected at upload time anyway.
satelliteCandidates.isNotEmpty() && officialSatellite == null ->
error = errSatellite
else -> {
val mode = modeText.trim().uppercase(Locale.US).ifBlank { "FM" }
val satellite = officialSatellite ?: satelliteText.trim()
onSave(
record.copy(
theirCallsign = callsign.trim().uppercase(Locale.US),
startUtcMillis = millis,
endUtcMillis = record.endUtcMillis?.let { millis },
theirGrid = gridText.trim().uppercase(Locale.US),
sentReport = sentText.trim(),
receivedReport = receivedText.trim(),
txFrequencyHz = txHz,
rxFrequencyHz = rxHz,
band = frequencyBand(txHz),
rxBand = frequencyBand(rxHz),
mode = if (mode == "FT4") "MFSK" else mode,
submode = if (mode == "FT4") "FT4" else "",
satelliteName = satellite,
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
comment = commentText.trim()
)
)
}
}
}) { Text(stringResource(R.string.qso_edit_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
}
)
if (pickerOpen) {
SatellitePickerDialog(
candidates = satelliteCandidates,
onDismiss = { pickerOpen = false },
onPick = {
satelliteText = it
pickerOpen = false
}
)
}
}
/**
* Full ARRL satellite catalogue with a fuzzy filter: typing "saud" or "saudisat 1c" narrows the
* list down to "SO-50", so a tracker name can always be turned into the name LoTW accepts.
*/
@Composable
private fun SatellitePickerDialog(
candidates: List<String>,
onDismiss: () -> Unit,
onPick: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
val matches = remember(query, candidates) { filteredCandidates(query, candidates) }
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.qso_edit_satellite_pick_title)) },
text = {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = query,
onValueChange = { query = it.uppercase(Locale.US) },
label = { Text(stringResource(R.string.qso_edit_satellite_search), fontSize = 12.sp) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(matches, key = { it }) { name ->
Text(
text = name,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(name) }
.padding(horizontal = 4.dp, vertical = 8.dp)
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
}
)
}
/** Catalogue filtered by the fuzzy query; the whole list when nothing matches or the query is blank. */
internal fun filteredCandidates(query: String, candidates: List<String>): List<String> {
if (query.isBlank()) return candidates
val matches = LoTWSatelliteAliases.suggestions(query, candidates, limit = 500)
return matches.ifEmpty { candidates }
}
@Composable
private fun EditField(
label: String,
value: String,
capitalization: KeyboardCapitalization,
modifier: Modifier = Modifier.fillMaxWidth(),
onValueChange: (String) -> Unit
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label, fontSize = 12.sp) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
keyboardOptions = KeyboardOptions(capitalization = capitalization),
modifier = modifier.padding(vertical = 1.dp)
)
}
/** "436.795" (MHz) -> 436_795_000 Hz. Null when blank or unparsable. */
internal fun parseMhz(text: String): Long? = text.trim()
.takeIf { it.isNotEmpty() }
?.toDoubleOrNull()
?.let { (it * 1_000_000.0).roundToLong() }
/** Hz -> "436.795" (MHz), trailing zeros trimmed. */
internal fun formatMhz(hz: Long?): String = hz
?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }
.orEmpty()
internal fun parseUtc(date: String, time: String, zone: TimeZone): Long? = runCatching {
SimpleDateFormat("$DATE_PATTERN $TIME_PATTERN", Locale.US).apply {
timeZone = zone
isLenient = false
}.parse("${date.trim()} ${time.trim()}")?.time
}.getOrNull()
@@ -0,0 +1,58 @@
/*
* 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.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
/**
* Styling of the app's sheet dialogs ([DialogShell] / [SharedDialog] / [InfoDialog], all built on
* ModalBottomSheet). AlertDialog-based dialogs (logbook, QSO editor, upload preview) use these so
* their container colour, corner radius and title look identical to the bottom sheets.
*/
/**
* Container colour of the app's bottom sheets. Equal to `BottomSheetDefaults.ContainerColor`
* (Material3 resolves that token to `ColorScheme.surfaceContainerLow`) — spelled out here so the
* dialogs don't have to opt into the experimental bottom-sheet API.
*/
@Composable
fun sheetDialogContainerColor(): Color = MaterialTheme.colorScheme.surfaceContainerLow
/** Corner radius of the app's bottom sheets (`MaterialTheme.shapes.medium`, 12 dp). */
@Composable
fun sheetDialogShape(): Shape = MaterialTheme.shapes.medium
/** Title row of the app's sheet dialogs: 16 sp medium, primary colour, single line. */
@Composable
fun SheetDialogTitle(text: String) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.presentation
import androidx.compose.animation.core.animateDpAsState
@@ -0,0 +1,13 @@
<!--
Grid Finder icon: a GPS crosshair.
Ported from Material Design Icons (crosshairs-gps), Apache License 2.0.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z" />
</vector>
@@ -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>
@@ -83,7 +83,6 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Soporte</string>
<string name="prefs_privacy_title">Política de privacidad</string>
<string name="prefs_updated_title">Actualizado: %s</string>
@@ -82,7 +82,6 @@
<string name="map_visible">Видимый</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Поддержать</string>
<string name="prefs_privacy_title">Политика конфиденциальности</string>
<string name="prefs_updated_title">Обновлено: %s</string>
@@ -83,7 +83,6 @@
<string name="map_visible">දෘශ්‍ය</string>
<!-- Settings screen -->
<string name="prefs_donate_title">දායකත්ව</string>
<string name="prefs_privacy_title">රහස්යතා ප්රතිපත්තිය</string>
<string name="prefs_updated_title">යාවත්කාලීන කරන ලදී: %s</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
<string name="btn_accept">Kabul et</string>
<string name="btn_cancel">İptal</string>
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
@@ -88,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>
@@ -104,18 +104,16 @@
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Gizlilik Politikası</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_updated_title">Güncellendi: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -209,7 +207,8 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA BG5JSU (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>
@@ -83,7 +83,6 @@
<string name="map_visible">Видимий</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Підтримати</string>
<string name="prefs_privacy_title">Політика конфіденційності</string>
<string name="prefs_updated_title">Оновлено: %s</string>
@@ -41,7 +41,6 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<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>
@@ -131,10 +130,16 @@
<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 -->
<string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
@@ -184,6 +189,36 @@
<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 加密,请用 TQSL 默认加密重新导出后再试</string>
<string name="prefs_lotw_upload_error_format_alg">不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试</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>
@@ -240,6 +275,15 @@
<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>
@@ -258,6 +302,7 @@
<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_light_theme">浅色主题</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
@@ -266,7 +311,20 @@
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_title">致谢与声明</string>
<string name="prefs_project_wiki">Project Wiki</string>
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
<string name="project_wiki_section_mutual">双站过境匹配</string>
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
<string name="project_wiki_section_grids">通联网格地图</string>
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
@@ -275,8 +333,13 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA BG5JSU(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
\n• BG7NTA BG5JSU(CW解码功能)
\n• BH2VSQ BG5JSU(天地图渲染支持)
\n• BG5JSU(LoTW上传支持)
</string>
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
上游项目:https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
<!-- 匹配页 Mutual match page -->
<string name="mutual_title">过境匹配</string>
@@ -301,6 +364,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>
@@ -322,4 +388,89 @@
<string name="update_check_download_error">下载失败,请重试。</string>
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
<!-- QSO edit dialog -->
<string name="qso_edit_title">编辑通联记录</string>
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
<string name="qso_edit_callsign">呼号</string>
<string name="qso_edit_date">日期 (UTC)</string>
<string name="qso_edit_time">时间 (UTC)</string>
<string name="qso_edit_tx">上行频率 (MHz)</string>
<string name="qso_edit_rx">下行频率 (MHz)</string>
<string name="qso_edit_band">频段:%1$s</string>
<string name="qso_edit_mode">模式</string>
<string name="qso_edit_satellite">卫星</string>
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
<string name="qso_edit_sent">发送报告</string>
<string name="qso_edit_received">接收报告</string>
<string name="qso_edit_grid">对方网格</string>
<string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</string>
<string name="qso_edit_cancel">取消</string>
<string name="qso_edit_err_callsign">呼号不能为空</string>
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
<string name="qso_edit_satellite_pick">选择</string>
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
<string name="gridfinder_title">网格寻线</string>
<string name="gridfinder_status_corner">位于网格点上</string>
<string name="gridfinder_status_line">位于网格线上</string>
<string name="gridfinder_status_inside">在网格内部</string>
<string name="gridfinder_status_unknown">位置不可用</string>
<string name="gridfinder_status_waiting">正在获取定位…</string>
<string name="gridfinder_status_blocked">需要定位权限</string>
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
<string name="gridfinder_grant_permission">授予定位权限</string>
<string name="gridfinder_card_location">位置</string>
<string name="gridfinder_card_corner">最近网格点引导</string>
<string name="gridfinder_card_line">最近网格线引导</string>
<string name="gridfinder_card_lines">两条边界线</string>
<string name="gridfinder_card_map">近距离网格图</string>
<string name="gridfinder_card_fix">定位质量</string>
<string name="gridfinder_card_compass">罗盘</string>
<string name="gridfinder_label_claimable">此处可计分的网格</string>
<string name="gridfinder_label_grid4">网格 (4)</string>
<string name="gridfinder_label_grid6">网格 (6)</string>
<string name="gridfinder_label_grid8">网格 (8)</string>
<string name="gridfinder_label_latitude">纬度</string>
<string name="gridfinder_label_longitude">经度</string>
<string name="gridfinder_label_altitude">海拔</string>
<string name="gridfinder_label_accuracy">水平精度</string>
<string name="gridfinder_label_fix_age">定位时刻</string>
<string name="gridfinder_label_provider">定位来源</string>
<string name="gridfinder_value_provider_on">已开启</string>
<string name="gridfinder_value_provider_off">未开启</string>
<string name="gridfinder_label_distance">距离</string>
<string name="gridfinder_label_bearing">方位角</string>
<string name="gridfinder_label_direction">方向</string>
<string name="gridfinder_label_grids">网格</string>
<string name="gridfinder_label_lat_line">纬线</string>
<string name="gridfinder_label_lon_line">经线</string>
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
<string name="gridfinder_label_heading">朝向</string>
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
<string name="gridfinder_action_set_station">设为我的台址</string>
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
<string name="gridfinder_action_calibrate">校准罗盘</string>
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
<string name="gridfinder_direction_north">北</string>
<string name="gridfinder_direction_south">南</string>
<string name="gridfinder_direction_east">东</string>
<string name="gridfinder_direction_west">西</string>
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
</resources>
+162 -13
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -51,8 +51,6 @@
<string name="pass_welcome_message">
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">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>
@@ -128,7 +126,8 @@
<!-- 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>
@@ -151,21 +150,26 @@
<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 -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -214,6 +218,36 @@
<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">Unsupported .p12 encryption. Re-export from TQSL and try again.</string>
<string name="prefs_lotw_upload_error_format_alg">Unsupported .p12 encryption (%1$s). Re-export from TQSL and try 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>
@@ -269,6 +303,15 @@
<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>
@@ -287,6 +330,7 @@
<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_light_theme">Light theme</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>
@@ -295,7 +339,20 @@
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_title">Credits &amp; Notices</string>
<string name="prefs_project_wiki">Project Wiki</string>
<string name="project_wiki_intro">Look4Sat-BA7OPF is a customized fork of Look4Sat. All upstream features, plus:</string>
<string name="project_wiki_section_logbook">Satellite Logbook &amp; LoTW</string>
<string name="project_wiki_logbook_1">Satellite logbook — one-tap logging of callsign, time, frequency, mode, report and grid.</string>
<string name="project_wiki_logbook_2">Direct LoTW upload — import your ARRL certificate and upload logs to LoTW right from the app, no PC needed.</string>
<string name="project_wiki_logbook_3">Confirmation sync — one-tap sync of LoTW confirmations, marked as uploaded/confirmed. Editing an uploaded entry creates a new one, leaving the original untouched.</string>
<string name="project_wiki_logbook_how">How: tap Record after a QSO → Upload on the log page → Sync in Settings.</string>
<string name="project_wiki_section_mutual">Mutual Pass Matching</string>
<string name="project_wiki_mutual_1">Enter your QTH and a friend\'s to find passes visible to both; tap a pass to open the radar.</string>
<string name="project_wiki_mutual_how">How: Match tab → set both stations → Query.</string>
<string name="project_wiki_section_grids">Worked-Grid Map</string>
<string name="project_wiki_grids_1">Grids synced from LoTW/Wavelog shown in green, with award boundaries and first-QSO callsign labels; tap a grid to see its QSOs and match a friend.</string>
<string name="project_wiki_grids_how">How: enable Grid mode on the map → sync LoTW/Wavelog in Settings.</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -305,8 +362,12 @@
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\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>
<string name="prefs_outro_notice">Look4Sat-BA7OPF is an unofficial modified build based on Look4Sat (GPL-3.0), independently maintained by BA7OPF. It is not affiliated with or endorsed by the original Look4Sat project.
Upstream: https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_outro_license">Licensed under the GNU General Public License v3 · The app comes with no warranty</string>
<!-- Mutual match page -->
<string name="mutual_title">Pass Match</string>
@@ -331,6 +392,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>
@@ -352,4 +416,89 @@
<string name="update_check_download_error">Download failed. Try again.</string>
<string name="update_check_no_apk">No APK attached to this release.</string>
<!-- QSO edit dialog -->
<string name="qso_edit_title">Edit QSO</string>
<string name="qso_edit_uploaded_notice">This QSO was already uploaded or confirmed. Saving keeps the original record unchanged and creates a new one.</string>
<string name="qso_edit_callsign">Callsign</string>
<string name="qso_edit_date">Date (UTC)</string>
<string name="qso_edit_time">Time (UTC)</string>
<string name="qso_edit_tx">Uplink (MHz)</string>
<string name="qso_edit_rx">Downlink (MHz)</string>
<string name="qso_edit_band">Band: %1$s</string>
<string name="qso_edit_mode">Mode</string>
<string name="qso_edit_satellite">Satellite</string>
<string name="qso_edit_hint_satellite">LoTW only accepts its official satellite name</string>
<string name="qso_edit_sent">Sent RST</string>
<string name="qso_edit_received">Received RST</string>
<string name="qso_edit_grid">Opposite grid</string>
<string name="qso_edit_comment">Comment</string>
<string name="qso_edit_save">Save</string>
<string name="qso_edit_cancel">Cancel</string>
<string name="qso_edit_err_callsign">Callsign is required</string>
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
<string name="qso_edit_err_satellite">Not an ARRL satellite name — pick one from the list</string>
<string name="qso_edit_satellite_pick">Pick</string>
<string name="qso_edit_satellite_pick_title">ARRL satellite name</string>
<string name="qso_edit_satellite_search">Search name or catalogue alias</string>
<string name="qso_edit_satellite_official">Saved and signed as %1$s</string>
<string name="qso_edit_warn_no_freq">No uplink frequency: LoTW requires a band, so this QSO cannot be uploaded yet</string>
<!-- Grid Finder (VUCC grid line / corner field guide) -->
<string name="gridfinder_title">Grid Finder</string>
<string name="gridfinder_status_corner">ON GRID POINT</string>
<string name="gridfinder_status_line">ON GRID LINE</string>
<string name="gridfinder_status_inside">INSIDE GRID</string>
<string name="gridfinder_status_unknown">Position unavailable</string>
<string name="gridfinder_status_waiting">Waiting for a fix…</string>
<string name="gridfinder_status_blocked">Location permission required</string>
<string name="gridfinder_status_disabled">Location services are off on this device</string>
<string name="gridfinder_status_confirmed">Confirmed by %1$d consecutive fixes inside the 20 ft rule</string>
<string name="gridfinder_status_unconfirmed">Not confirmed yet: %1$d of %2$d consecutive fixes good to 6.1 m</string>
<string name="gridfinder_grant_permission">Grant location access</string>
<string name="gridfinder_card_location">Location</string>
<string name="gridfinder_card_corner">Guide to the nearest grid point</string>
<string name="gridfinder_card_line">Guide to the nearest grid line</string>
<string name="gridfinder_card_lines">Both boundary lines</string>
<string name="gridfinder_card_map">Close-up grid map</string>
<string name="gridfinder_card_fix">Fix quality</string>
<string name="gridfinder_card_compass">Compass</string>
<string name="gridfinder_label_claimable">VUCC grids here</string>
<string name="gridfinder_label_grid4">Grid (4)</string>
<string name="gridfinder_label_grid6">Grid (6)</string>
<string name="gridfinder_label_grid8">Grid (8)</string>
<string name="gridfinder_label_latitude">Latitude</string>
<string name="gridfinder_label_longitude">Longitude</string>
<string name="gridfinder_label_altitude">Altitude</string>
<string name="gridfinder_label_accuracy">Horizontal accuracy</string>
<string name="gridfinder_label_fix_age">Fix age</string>
<string name="gridfinder_label_provider">Location provider</string>
<string name="gridfinder_value_provider_on">On</string>
<string name="gridfinder_value_provider_off">Off</string>
<string name="gridfinder_label_distance">Distance</string>
<string name="gridfinder_label_bearing">Bearing</string>
<string name="gridfinder_label_direction">Direction</string>
<string name="gridfinder_label_grids">Grids</string>
<string name="gridfinder_label_lat_line">Latitude line</string>
<string name="gridfinder_label_lon_line">Longitude line</string>
<string name="gridfinder_line_value">%1$s to the %2$s</string>
<string name="gridfinder_line_latitude">This is the latitude boundary, which runs east to west</string>
<string name="gridfinder_line_longitude">This is the longitude boundary, which runs north to south</string>
<string name="gridfinder_label_heading">Heading</string>
<string name="gridfinder_label_compass_accuracy">Compass accuracy</string>
<string name="gridfinder_action_set_station">Set as my station</string>
<string name="gridfinder_action_station_saved">Station position updated — pass predictions now use this fix</string>
<string name="gridfinder_action_calibrate">Calibrate compass</string>
<string name="gridfinder_map_legend">The centre dot is you, dashed axes are north and east, the straight lines are the two grid boundaries, the green dot is the nearest grid point, the ring is the closest point of the nearest line and the red needle is your heading</string>
<string name="gridfinder_compass_disabled_hint">The compass is switched off in Settings, so the dials stay north-up. The bearing numbers still guide you</string>
<string name="gridfinder_compass_unavailable_hint">This device has no magnetic sensor — navigate with the bearing numbers</string>
<string name="gridfinder_compass_hint">Hold the phone flat; the arrow points the way to walk</string>
<string name="gridfinder_vucc_rule_note">VUCC 20 ft rule: %1$.1f m</string>
<string name="gridfinder_direction_north">north</string>
<string name="gridfinder_direction_south">south</string>
<string name="gridfinder_direction_east">east</string>
<string name="gridfinder_direction_west">west</string>
<string name="gridfinder_action_set_lotw_station">Set as LoTW station (%1$s)</string>
<string name="gridfinder_lotw_station_hint">Prefills the upload station grid — LoTW then records these squares (2 on a line, 4 on a corner). The certificate callsign stays unchanged.</string>
</resources>
@@ -1,3 +1,21 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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/>.
*/
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
*
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more