Compare commits

...
112 Commits
Author SHA1 Message Date
mckero c42e1d7b4e fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT"
Satellite QSOs uploaded with BAND=SAT, which is not a legal ADIF Band
enumeration value (the legal values are concrete bands: 160M/80M/.../
2M/70CM/23CM...). Loggers that fail to parse an unknown band fall back
to a default — observed as QSOs landing in 160m. SAT is only legal as
PROP_MODE (propagation mode), which is already sent for v1.

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

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

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

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

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

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

Verification:
- 8 unit tests pass (CQ/HELLO at 18-20 wpm, A-J at 30 wpm with
  adaptive tracking, dot/dash/Farnsworth edge cases) — all decode
  correctly from synthesized CW.
- :feature:cw and :feature:radar compile; app assembleDebug succeeds.
- APK contains no ve3nea/nativedecoder/morse_expert classes.
2026-08-08 13:58:19 +00:00
mckero fb014a1183 chore(release): bump versionCode to 458 (4.5.5 upload fixes) 2026-08-05 11:28:53 +00:00
mckero 78049a29d9 fix(wavelog): persist gridsquare in the queue serialization
WavelogQueue serialized and deserialized every field except
gridsquare: updateGridsquare() wrote it, but save() skipped
put("gridsquare") and all() never read it back, so the QRZ-backfilled
grid was always empty at upload time (GRIDSQUARE never made it into
the ADIF). Add both directions.
2026-08-05 11:17:47 +00:00
mckero 3818c58eff fix(wavelog): send v2 extra freq fields as MHz strings with unit suffix
WaveLog's parse_frequency() (Logbook_model.php) treats integer input
as Hz but reads string suffixes ("145.852038M" -> 145852038 Hz).
The bare-integer freq/freq_rx values in the v2 JSON envelope could be
misread as MHz by older WaveLog versions, corrupting the band
derivation (145.852 MHz showed up as 160m). ADIF string (v1/v2) was
already correct; now the extra fields match the same semantics.
2026-08-05 11:09:51 +00:00
mckero 4c62b2dfb2 Revert "feat(nav): 4.5.6 AMSAT to bottom bar, Radar to More menu, tablet rail"
This reverts commit d8e1ee3266.
2026-08-05 10:07:45 +00:00
mckero d8e1ee3266 feat(nav): 4.5.6 AMSAT to bottom bar, Radar to More menu, tablet rail
Per upstream author feedback (PR #233) and tablet UX report:
- Bottom bar keeps max 5 primary destinations: Satellites/Passes/
  AMSAT/Map/Settings; Radar moves to the More menu (still reachable
  from Passes via item click)
- Legacy migration: persisted orders are rewritten in memory
  (main menu drops Radar + appends AMSAT; More menu drops AMSAT +
  appends Radar) so existing installs get the new layout
- Wide screens / tablets (width breakpoint) now use the side
  navigation rail instead of the bottom bar - fixes the wasted
  bottom strip ("big chin") in landscape/tablet layouts
- New AMSAT tab icon: MDI satellite-variant (Apache 2.0,
  https://pictogrammers.com)
- What's new updated in 5 locales; version 4.5.6 (457)
2026-08-05 09:46:54 +00:00
mckero 90bb584813 fix(status): drop appcompat tint attr from ic_refresh (library module has no such attr) 2026-08-05 08:53:05 +00:00
mckero f789a15338 fix(status): AMSAT page load failure, refresh spinner, retry button
- P0: fetchStatus() now runs on Dispatchers.IO - the previous
  synchronous URLConnection on the main thread threw
  NetworkOnMainThreadException and showed "load failed" on every open
- P1: refresh button rotates a vector icon (ic_refresh) instead of the
  "↻" text glyph, whose off-center font metrics made the spinner
  orbit around a shifted pivot
- P2: error state gains a Retry button (4 locales); amsat_refresh
  string added (5 locales)
- versionCode 456 (bump for reinstalling over 455), versionName stays 4.5.5
2026-08-05 08:50:01 +00:00
mckero 6221516893 feat(amsat): 4.5.5 official AMSAT API + edge-to-edge status page
- Replace HTML parsing with the official AMSAT Satellite Status API v1
  (catalog.php + reports.php, JSON): AmSatApiClient (pure JVM, hand
  rolled ISO-8601/epoch parsing for minSdk 24) + rewritten
  AmSatRepository (satellite list from catalog, reports slotted into
  6 days x 12 two-hour slots, status colors per report value)
- Fix edge-to-edge: status bar / navigation bar insets on the status
  page (refresh button and update time were unreachable)
- Version 4.5.5 (455), What's new in all 5 locales
2026-08-05 08:12:17 +00:00
mckero c9cab8457d chore(i18n): translate all code comments to English
All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
2026-08-05 07:59:35 +00:00
mckero 19926c58ac Revert "feat(ft8): 4.5.5 integrate FT8CN 0.93 (full port, original UI)"
This reverts commit 8a2b0a9aa3.
2026-08-05 07:47:18 +00:00
mckero 955871c27b Revert "fix(ft8): register FT8 in settings page-order list (UI settings)"
This reverts commit 4d7952d293.
2026-08-05 07:47:17 +00:00
mckero bfcef27b90 Revert "docs(credits): thank BG7YOZ (FT8CN author) and BG7NIP in all locales"
This reverts commit 7dd256f65d.
2026-08-05 07:47:17 +00:00
mckero 449d1a9eec Revert "fix(ft8): unify osmdroid version to fix duplicate classes"
This reverts commit fc1bec1915.
2026-08-05 07:47:17 +00:00
mckero fe97339ece Revert "fix(ft8): repair FT8CN locale string resources for AGP9 strictness"
This reverts commit 2c26272e0b.
2026-08-05 07:47:17 +00:00
mckero 4d5a413fa8 Revert "fix(ft8): remove application tag from module manifest (merge conflict)"
This reverts commit f4a22d9779.
2026-08-05 07:47:17 +00:00
mckero 25078b3121 Revert "fix(ft8): remove application tag from module manifest (merge conflict)"
This reverts commit 84d11f289f.
2026-08-05 07:47:17 +00:00
mckero 96a4d15424 Revert "fix(ft8): restore Look4Sat Pro label/icon, add AppCompat theme"
This reverts commit bf4cd5c07c.
2026-08-05 07:47:17 +00:00
mckero bf4cd5c07c fix(ft8): restore Look4Sat Pro label/icon, add AppCompat theme
User reported the app installed as "FT8CN" with the FT8CN icon and
FT8 not opening. Root causes (verified by aapt badging on the release
APK):
1. module values/strings.xml carried <string name="app_name">FT8CN
   which won the resource merge -> application-label became FT8CN.
   Removed it; dialogs that referenced R.string.app_name now use a
   module-local ft8cn_app_name (HelpDialog/ClearCacheDataDialog).
2. module shipped its own mipmap ic_launcher* (8 files) which
   overrode our launcher icon -> deleted all of them.
3. MainActivity extends AppCompatActivity but the module's
   <application> lost android:theme when the conflicting attributes
   were stripped -> startup crash. Re-added android:theme=Theme.Ft8CN
   (MaterialComponents) on the module application element.
Verified: processReleaseManifest/Resources + ft8 javac + app kotlin
BUILD SUCCESSFUL. Plan: .hermes/plans/2026-08-05_150000-v455-fix-plan.md
2026-08-05 06:52:13 +00:00
mckero 84d11f289f fix(ft8): remove application tag from module manifest (merge conflict)
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
2026-08-05 06:33:54 +00:00
mckero f4a22d9779 fix(ft8): remove application tag from module manifest (merge conflict)
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
2026-08-05 06:29:21 +00:00
mckero 0e767f4250 feat(wavelog): QRZ grid lookup, LoTW satellite list refresh, RST 59
User-prioritized WaveLog fixes (4.5.5, commit-only per instruction):

- QRZ 对方网格爬虫: QrzGridClient (domain, pure JVM) fetches
  https://www.qrz.com/db/{call} with user-supplied cookies (parses
  EditThisCookie JSON or raw "k=v; k=v"), extracts Grid Square from
  the Detail table. Cookies NEVER built in - entered in settings.
- Settings: WaveLog card top-right gear opens QRZ cookie dialog with
  test query button (detects logged-in callsign from cookie, looks up
  its grid, shows result or failure in the dialog).
- LogTab: on Enter, async lookup of the other station's grid ->
  queue.updateGridsquare -> uploaded with QSO (postQso gridsquare).
- LoTW satellite list: 112 names embedded (lotw.arrl.org config.tq6),
  normalizeSatName maps Celestrak TLE names to LoTW names (SAUDISAT-1C
  -> SO-50, FUNCUBE-1 -> AO-73, DIWATA-2B -> PO-101, ZARYA/ARISS ->
  ARISS); "Update sats" button in WaveLog card downloads the live list
  (LotwSatellitesRepo, SharedPreferences persisted, never in build).
- RST: rst_sent/rst_rcvd = 59/59 in both v1 ADIF and v2 JSON.
- Grid mismatch dialog kept (cloud station grid vs station QTH);
  QSO gridsquare no longer uses station grid.
- New strings in 5 locales; check_strings 9 files OK.
Verified: core:domain/data + feature:settings/radar + app
compileDebugKotlin BUILD SUCCESSFUL.
2026-08-05 06:23:47 +00:00
mckero 2c26272e0b fix(ft8): repair FT8CN locale string resources for AGP9 strictness
FT8CN 0.93 shipped malformed strings that old AGP tolerated but AGP9
rejects: 148 unclosed tags (</string>>), unclosed XML comments,
trailing garbage after tags, and non-positional multi-placeholder
formats. Fixed across all 8 locale files: closed tags/comments,
removed trailing garbage, numbered placeholders in argument order
(%1$s %2$d %.3$1f style). aapt2 compile of every values-* dir now
passes 0 errors; :feature:ft8 + :app compileDebugKotlin SUCCESSFUL.
2026-08-05 05:23:57 +00:00
mckero fc1bec1915 fix(ft8): unify osmdroid version to fix duplicate classes
CI build failed at :app:checkReleaseDuplicateClasses: local
osmdroid-android-6.1.14.aar (copied from FT8CN) clashed with the
project's osmdroid 6.1.20 (feature/map remote dependency). Switched
feature/ft8 to libs.other.osmdroid (6.1.20), removed the local aar.
Verified: compileDebugJavaWithJavac + :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 05:08:51 +00:00
mckero 7dd256f65d docs(credits): thank BG7YOZ (FT8CN author) and BG7NIP in all locales
Added to the prefs_outro_thanks list after BG7NTA in all 5 locale
files (values/zh/tr/in/id), keeping the original bullet style and the
24dp spacer before the license block (user: keep bottom spacing).
Verified: check_strings 9 files OK.
2026-08-05 05:01:19 +00:00
mckero 4d7952d293 fix(ft8): register FT8 in settings page-order list (UI settings)
User spot-check found FT8 missing from the settings screens list
(SettingsScreen.kt:703) - page order drag/hide UI could not show or
reorder it. Added "nav_ft8 to FT8" after AMSAT (name must match
Screen.screenId). Verified: :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 04:53:37 +00:00
mckero 8a2b0a9aa3 feat(ft8): 4.5.5 integrate FT8CN 0.93 (full port, original UI)
Full FT8 mode integration (user chose scope C: complete port, keep
original visual style):

- feature/ft8 module: 213 Java classes from FT8CN 0.93 source
  (com.bg7yoz.ft8cn namespace preserved so code needs zero changes),
  DataBinding enabled, BuildConfig fields (APPLICATION_ID/VERSION_NAME/
  apkBuildTime) restored for AGP9
- libft8cn.so from the user's FT8CN_0.93 APK (armeabi-v7a, 1.53MB;
  differs from repo copy, APK version used)
- 237 res files + 58 assets + 4 local libs (MPAndroidChart spectrum,
  commons-net, nanohttpd, osmdroid map) copied verbatim
- Proguard keep rules for JNI classes (RegisterNatives)
- Entry: More menu "FT8" (default sub menu, after AMSAT) launches
  MainActivity via Intent (external activity, LAUNCHER filter removed
  to avoid double launcher)
- Manifest merged via module (RECORD_AUDIO/BT/location perms)
- Version 4.5.5/455 + What's new 5 languages (REPLACED per rule)
Verified: :feature:ft8:compileDebugJavaWithJavac + :app:compileDebugKotlin
BUILD SUCCESSFUL; check_strings 9 files OK.
2026-08-05 04:49:35 +00:00
mckero c9d87be289 fix(aprs): upload feedback, manual report reliability, station position
User testing round 3 (4.5.4): no success feedback on upload, aprs.fi
shows nothing, passcode calculator OK, notification present.

- Manual report now works even when service not running: ACTION_REPORT_NOW
  starts the service first (Toast "not configured" if missing callsign)
- Upload result feedback guaranteed: Toast always shows (short OK /
  long fail+reason), last result persisted (time/ok/detail) and shown
  in the settings card "Last report: HH:mm:ss OK/failed - detail"
- Position source: station position from settingsRepo (user decision)
  with live GPS last-known as fallback
- sendPacket reads the server confirmation line (short 3s timeout);
  server error text (Invalid/error) surfaces in Toast + card
- Strings EN/ZH/TR/IN/ID +4 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:29:36 +00:00
mckero 012ea1eeb6 fix(aprs): passcode compute button, crash log, notif permission, login verify
User feedback round 2 (4.5.4): no notification shown, report not
sending, no error visibility on crash. Diagnosis: APRS-IS port 14580
reachable (verified with real login test), 24580 SSL refused; login
format OK. Fixes:

- Settings dialog: "Compute passcode" button - fills passcode from
  callsign via the ported 0x73E2 algorithm (user can see the result)
- Global crash handler in MainApplication: stack trace appended to
  files/crash_log.txt so crashes are diagnosable (user: no crash logs
  were available before)
- POST_NOTIFICATIONS runtime permission requested when enabling APRS
  (Android 13+ otherwise silently hides the service notification)
- AprsIsClient: read the login response (aprsc "# logresp ...
  unverified"/"Invalid") and surface the server message as the error
- AprsForegroundService: Toast on manual report result (OK / failure
  with server reason)
- Strings EN/ZH/TR/IN/ID +3 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:06:09 +00:00
mckero 920af7bde2 fix(aprs): crash when enabling APRS with callsign set (foreground service type)
Root cause: user reported crash on enabling APRS with a callsign set
(empty callsign worked because the service stops early and never
reaches startForeground). Android 14+ requires the service to declare
foregroundServiceType when startForeground passes a type; the service
had none -> process died on toggle.

- Manifest: add android:foregroundServiceType="dataSync"
- AprsCard: use startForegroundService() for start/report actions
  (Android 8+ standard for foreground services)
- AprsForegroundService: try-catch around startForeground, fallback
  stopSelf instead of killing the process
- AprsReporter: passcode "-1" (APRSdroid "no auth" convention) also
  auto-computes from callsign
- Merged upstream 418a05e3 (zh wording fix, no conflicts)
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 02:50:40 +00:00
mckero 88a8e795d1 Merge remote-tracking branch 'atsunatsu/main' 2026-08-05 02:50:28 +00:00
mckero 44ae5cef5d fix(aprs): settings crash from format arg type mismatch + service class name
- prefs_aprs_summary uses %2$d but AprsCard passed port.toString()
  (String) -> String.format threw IllegalFormatConversionException
  on every Settings screen open (crash). Pass Int now.
- Manifest service name was ".AprsForegroundService" (resolves under
  applicationId) but the class lives in com.rtbishop.look4sat.app ->
  ClassNotFoundException when toggling. Use full class name.
Verified: :feature:settings:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 02:39:14 +00:00
mckero 518d9a4d22 feat(aprs): 4.5.4 APRS-IS network reporting (ported from APRSdroid 1.6.3d)
Core logic extracted from APRSdroid 1.6.3d (jadx reverse-engineered,
verified against APRS spec):
- core/domain/aprs/AprsPacket.kt: passcode algorithm (0x73E2 XOR),
  login line, uncompressed/compressed position encoding (ab0oo)
- core/data/aprs/AprsIsClient.kt: APRS-IS TCP client (login + packet
  lines, 30s reconnect strategy)
- core/data/aprs/AprsReporter.kt: periodic reporting scheduler
  (default 5 min, manual trigger support)
- core/data/aprs/AprsStore.kt: SharedPreferences persistence
  (fill once, saved)
- app/AprsForegroundService.kt: foreground service with notification
  (keeps APRS alive across pages), START_STICKY, last-known position
  provider
- feature/settings/AprsCard.kt: APRS card between DataCard and
  OutputCard, gear button opens settings dialog (server/port/
  callsign/ssid/passcode/interval/status/symbol), manual report button
- Manifest: service registration + FOREGROUND_SERVICE(_DATA_SYNC) +
  POST_NOTIFICATIONS
- Strings in EN/ZH/TR/IN/ID (23 keys x 5)
- Version 4.5.4/454 + What's new 5 languages
2026-08-05 02:07:11 +00:00
mckero 72f507163a fix(status): shrink day cells so long satellite names stay visible
User feedback: on narrow screens long names (e.g. AO-123_[FM]) were
truncated before the mode tag. Scaled the 6 day cells down
proportionally (weight 1f -> 0.8f, height 26dp -> 24dp) and widened
the name column (weight 1.4f -> 2f) in both header and rows.
Verified: :feature:status:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 16:41:41 +00:00
mckero f7a3dbaa2d fix(i18n): use AGP9 localeFilters for language keep-list
resourceConfigurations is rejected by AGP 9.3 ("When localeFilters
are specified, resourceConfigurations cannot include locale
qualifiers"). Switch to androidResources.localeFilters.
Verified: :app:mergeReleaseResources + :app:processReleaseResources
BUILD SUCCESSFUL.
2026-08-04 16:34:39 +00:00
mckero c525f3a11f fix(i18n): force-keep in/id locale configs via resourceConfigurations
Root cause of Indonesian not matching: release build has
isShrinkResources=true and R8 was dropping the Indonesian resource
configs entirely - aapt dump badging showed locales only
'--_--' es ru si tr uk zh (no in, no id). values-in/values-id were
merged at merge time but stripped before packaging.

Fix: explicit resourceConfigurations in app/build.gradle.kts keeping
en zh tr in id es ru si uk. Verified: :app:mergeReleaseResources
BUILD SUCCESSFUL.
2026-08-04 16:28:39 +00:00
mckero 2777607ab5 fix(i18n): drop values-b+in (duplicates values-in under aapt2)
aapt2 treats in/b+in as the same locale config as id -> Duplicate
resources build failure. in and id are equivalent in Android's
resource matcher (both normalize to id), so values-in + values-id
is sufficient coverage for Indonesian devices.
2026-08-04 16:18:50 +00:00
mckero a222cbb839 fix(i18n): use BCP-47 values-b+in for Indonesian legacy locale
values-in-rID did not survive aapt2 linking (normalized away).
Switch to values-b+in (BCP-47) which compiles cleanly and keeps an
explicit "in" language config alongside values-id. Verified: aapt2
compile of values-in/values-id/values-b+in OK.
2026-08-04 16:16:40 +00:00
mckero 0ef8f05dc4 fix(i18n): keep legacy "in" locale config for old Indonesian devices
Root cause: aapt2 merges values-in into values-id (in is the legacy
alias of id), so the APK only carried the (id) config. New devices
report "id" and match; older devices report "in" and find no (in)
config -> fall back to English (friend's report: only system date
showed Indonesian).

Fixes:
- Add values-in-rID (core/presentation + feature/cw) so the APK
  keeps a real "in" language config; values-in and values-id both
  get translatable="false" on the 27 entries that English marks
  (aapt2 rejects those with multiple %-substitutions otherwise)
- Verified: aapt2 compile of values/values-in/values-id/values-in-rID
  OK; :core:presentation:mergeDebugResources + :feature:cw:mergeDebugResources
  BUILD SUCCESSFUL
2026-08-04 16:04:04 +00:00
mckero f7402a3531 feat(status): 4.5.3 AMSAT satellite status page + radar log pass divider
New feature/status module: fetches https://amsat.org/status/ and
renders a live status grid in the official site colors:
- Parser (AmSatParser): 47 satellites x 6 days x 12 two-hour slots,
  official colors (blue=Active, orange=TLM/Beacon, pink=Not Heard,
  deep-orange=Conflicting, gray=none); 598+ report details extracted
  from inline JS tooltips (callsign/date/time/grid)
- Three-level viz: color grid -> report count -> tap day cell opens
  report list dialog
- Manual refresh with spin animation + last-updated timestamp +
  legend row; loading/error states
- New "AMSAT" entry in the More menu (Screen.AmSat), integrated with
  page-order / hide-page settings (SettingsScreen screens list,
  defaultSubMenuOrder, allNavItems, migration for existing users)
- AmSatRepository via IRemoteSource.getStatusHtml() (UA header);
  shared remoteSource promoted to a lazy class property in MainContainer

Radar page Log tab: local entries now grouped by pass session with a
thick divider + satellite label between groups (matches the log page).

What's new updated in EN/ZH/TR/IN/ID. Version bumped to 4.5.3/453.
Not released (user gates all releases).
2026-08-04 15:57:41 +00:00
mckero dd378990fe feat(wavelog): group log entries by pass session (satellite + AOS timestamp)
User request: logs should be separated by satellite/pass. Each pass
session gets an ID = satellite name + AOS timestamp (the second the
elevation hits 0, from OrbitalPass.aosTime), e.g.
"ASRTU-1-20260804-2014". Log page groups entries by session:
group title (satellite - local time) + thick divider line between
groups (md --- style); legacy entries without sessionId fall into
"Ungrouped" at the end.

- WavelogQso: +sessionId (persisted in queue JSON)
- LogTab: sessionId built from satelliteName + aosTimeMs (radar page
  passes currentPass.aosTime)
- WavelogLogScreen: grouped rendering + wavelog_ungrouped string (4 locales)
- sessionId UTC yyyyMMdd-HHmm; display converts to local time

Verified: check_strings OK (9 files); :app:compileDebugKotlin
BUILD SUCCESSFUL. Not released (batched).
2026-08-04 14:39:01 +00:00
mckero a23bf34ccf fix(radar): swipe-delete visuals restored (yellow trash + undo countdown)
Previous fix added the content-layer background OUTSIDE the offset
modifier, so the background stayed at the original position and
permanently covered the swipe-reveal area - the yellow trash icon,
75% undo button and 5s countdown were invisible while swiping.
Moved the background INSIDE the offset (background follows the
content): idle = fully covered (no bleed into Sent column), swiping
= yellow trash / undo countdown revealed as before.

Verified: :feature:radar:compileDebugKotlin BUILD SUCCESSFUL.
Not released (batched with pending fixes).
2026-08-04 14:22:27 +00:00
mckero a724c0823d fix(i18n): add values-id resource dir so Indonesian devices pick the locale
Friend's device (system language Bahasa Indonesia) fell back to
English even though values-in exists. Modern Android devices report
the Indonesian locale as "id" (ISO-639-1 current code; "in" is the
legacy alias) and resource matching is strict. Added values-id as a
copy of values-in in core/presentation and feature/cw (both language
directories ship the same translations).

Verified: check_strings OK (9 files incl. values-id);
:app:compileDebugKotlin BUILD SUCCESSFUL. Not released (batch with
pending fixes).
2026-08-04 14:18:00 +00:00
mckero cda6cf8583 fix(gps,data): official-code fixes - real GPS result callback, no fake update success (4.5.2, release pending)
Official code issues found during review (user-reported):
1. GPS "success" was shown instantly even when no fix was obtained:
   - setStationPosition() always returned true (permissions exception
     swallowed, async requestLocationUpdates without waiting)
   - now: suspend + LocationManagerCompat.getCurrentLocation (GPS
     first, network fallback), permission check upfront, 15s timeout,
     success only when onLocationChanged fires; SettingsRepo takes
     Context for the permission check; ViewModel waits for the real
     result and shows "Unable to get location - check permission and
     GPS/network signal" on failure (4 locales)
2. Data update faked success on total failure:
   - updateFromRemote now counts successful sources; 0 success throws
     IOException -> timestamp NOT refreshed, Toast "Update failed -
     check your network" (4 locales, new IShowToast resId overload)
   - OkHttp timeouts widened: connect 15s / read 20s / write 20s

Verified: check_strings OK; all modules compile.
Release intentionally NOT triggered (user: fix everything first, then
one release).
2026-08-04 14:08:38 +00:00
mckero c09e87fa4e fix(radar,settings): realtime doppler freq in Log tab, trash overlay fix, sub-menu swap (4.5.2 overwrite 5)
User-test fixes:
1. Log tab frequency now refreshes every second with the transponder
   panel (multi-Doppler): selectedRadio derived by uuid from the
   per-second transceivers list instead of a remembered stale
   reference; display and upload use radio.uplinkLow/downlinkLow
   (the same doppler-corrected values the transceiver panel shows).
2. SwipeDeleteRow: content layer now has an opaque background so the
   trash icon only appears while swiping (was bleeding through into
   the "Sent" column).
3. Settings page order: More-menu items can now move back into the
   main menu - button always visible; when the main menu is full (5),
   the last non-Settings item is automatically swapped into More.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-04 13:39:22 +00:00
atsunatsu 418a05e343 fix(i18n): update Chinese station position wording 2026-08-04 21:03:35 +08:00
mckero 0bcf73e1f9 fix(wavelog,radar): sat name LoTW normalization, full grid lines, RX freq restored (4.5.2 overwrite 4) 2026-08-04 12:29:31 +00:00
mckero ab1a0c0891 fix(nav,radar,wavelog): 4.5.2 third overwrite - log page entry visible, freq format 3 decimals, direct freq extraction
User-test fixes (3rd overwrite, version stays 4.5.2/452):

1. Log page was invisible everywhere outside the radar tab:
   - allNavItems in MainScreen.kt was missing Screen.WavelogLog ->
     not in bottom nav, not in More menu
   - SettingsScreen UI-order lists were missing the WavelogLog row ->
     not in page order settings either
   Now the Log page appears in the More menu (default sub menu tail,
   migration appends it) AND in UI settings page-order lists.
2. Frequency display: formatFrequency was MHz.kHz.Hz (145.900.000);
   now MHz.kHz (145.900) per request. Applies to transceiver panel
   and Log page alike (shared formatter).
3. Log tab frequency: now extracts the exact numbers shown in the
   transceiver panel (txBaseFrequencyHz for TX, uplinkLow/uplinkHigh
   range for linear) - no re-computation, no extra decimals. Upload
   uses the same tuned frequency (Hz precision kept internally).
4. Log page (More menu) table: date+time column (MM-dd HH:mm),
   row separators (table lines), uploaded checkmark kept.

Verified: check_strings OK (8 files); :app:compileDebugKotlin
BUILD SUCCESSFUL (includes upstream merge 1a552417).
2026-08-04 12:07:45 +00:00
mckero 1a55241791 merge: upstream atsunatsu CW decoder + linear calculator (keep ours, absorb Calculator tab)
Upstream added a333192d (CW decoder and linear calculator page) and
c80e3512 (linear calculator offset mapping fix). Merged with all 7
conflicts resolved by hand:

- app/build.gradle.kts: keep ours (keystore signing config + abiFilters)
- CwDecodeScreen.kt: keep ours (only difference is localized strings
  vs upstream hardcoded English)
- g3/d.java: keep ours (identical lookup table, 4-line diff)
- app_values.xml: keep ours (3 CW permission strings upstream lacks)
- feature/radar/build.gradle.kts: keep ours (same dependency line)
- TransceiversPage.kt: take upstream (includes CalculatorPage +
  our CW panel + INV title logic; conflicts were comment-only)
- RadarScreen.kt: both sides - keep our wavelog imports + Log tab,
  take upstream DopplerFrequencyCalculator import + Calculator tab;
  pages = Transceivers, Log, [Calculator if any named linear
  transponder per upstream isNamedLinearTransponder], Sstv

Result: radar page tabs are Transceivers / Log / Calculator (linear
sats only, upstream logic) / SSTV. WaveLog Log tab and all our
localization/signing preserved.

NOTE: committed WITHOUT building per user request - merge compile
verification deferred.
2026-08-04 11:59:15 +00:00
mckero 9d437a90c9 fix(wavelog,radar): 4.5.2 second overwrite - v1 API support, error dialog with copy, frequency sync
Background: user's WaveLog server has NO v2 API (all /api/v2/* return
404; /api/qso v1 works - verified with curl). First fix only tried v2
paths, so uploads still failed with 404. Also: Log tab frequencies did
not match the Doppler panel, linear transponders showed a single
frequency instead of the passband range, and errors were toast-only
(no copy).

Changes:
- WaveLogApi: full v1 support with auto fallback
  - v2 first (Bearer header + JSON fields), on 404 fall back to v1
    (key inside JSON body + ADIF string) - both with and without
    index.php prefix
  - test connection: v2 GET api/v2/token -> v1 POST
    api/get_contacts_adif (validates key + station id)
  - station gridsquare is v2-only; on v1 the uploader falls back to
    the user's QTH grid (grid check skipped/equal)
  - v1 ADIF: call/band=SAT/mode/freq+freq_rx (MHz)/qso_date/time_on
    (UTC, compact)/gridsquare(4)/sat_name/prop_mode=SAT, byte-length
    field prefixes
  - 409 duplicate counts as success in both versions
- Error dialog with copy: test/upload failures now open an AlertDialog
  with the full error (incl. actual URL + HTTP code), Copy button
  (ClipboardManager) and Cancel; uploader collects the first failure
  message
- Log tab frequency sync: LogTab receives txBaseFrequencyHz from the
  radar page (the tuned frequency shown in the transceiver panel);
  RX is computed through the same Doppler mapping as the Doppler
  panel; linear transponders show the full uplink/downlink range
  (Doppler-corrected low-high) instead of a single frequency
- Restored uploadWavelogQueue (lost in an earlier patch)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:47:06 +00:00
mckero 5351afe508 fix(wavelog,radar): 4.5.2 user-test fixes (404 upload, swipe delete, transponder picker, log page)
Background: user tested 4.5.2 and reported 4 issues. Same-version
overwrite per user (4.5.2 exists solely for the logbook system).

Fixes:
1. Upload 404 — root cause: user server URL ending in /index.php was
   concatenated again (/index.php/index.php/api/v2/...) -> 404. Now:
   - normalizeUrl strips trailing /index.php
   - every request tries the index.php path first, falls back to the
     rewritten path on 404
   - 409 conflict (duplicate QSO) counts as success (moves out of queue)
   - failure messages include the actual URL + HTTP code for debugging
2. Swipe-to-delete was dead: rowWidth was never measured (0) so the
   75% threshold was 0 and the drag was clamped to 0. Now measured via
   onSizeChanged + smooth spring/tween snap-back animation.
3. Transponder picker: long card list replaced with an
   ExposedDropdownMenuBox dropdown (scrollable menu, pick one).
4. New Log page under the More menu: table view (time / frequency /
   satellite / callsign / uploaded checkmark). WavelogQso gains
   uploaded flag; uploader marks instead of removing; queue keeps
   uploaded entries (500 cap). Old persisted subMenuOrder gets
   WavelogLog appended (migration).

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 11:06:47 +00:00
atsunatsu c80e351212 fix: correct linear calculator offset mapping 2026-08-04 18:41:53 +08:00
mckero 916a0d0f0a feat(wavelog,radar,settings): WaveLog logbook integration + Log tab
Background: satellite operators want to log QSOs during a pass while
watching live frequencies. 4.5.2 adds WaveLog (logbook server) API v2
integration: log from the radar page, upload to a self-hosted WaveLog
instance with grid-mismatch protection.

Changes:
- Radar page: new third tab "Log" between Transceivers and SSTV
  - pick a transponder, watch live TX/RX Doppler-corrected frequencies
  - enter callsign, Enter stores locally (UTC time + that second's
    frequencies sampled together)
  - local entry list shows time/frequency/callsign only (no upload
    status, per user: proves the entry was saved)
  - swipe-to-delete: yellow trash while swiping, turns into Undo at
    75%, 5s countdown before auto-delete (custom gesture, no
    SwipeToDismissBox)
- Settings: new WaveLog card (server URL / API key / station ID /
  auto-upload switch / test connection / upload now buttons) matching
  the user's reference screenshot layout
- Upload pipeline: POST /index.php/api/v2/qso with required fields
  (station_profile_id, call, band=SAT, mode, qso_date, time_on UTC)
  plus freq/freq_rx (Hz), gridsquare (from station profile via
  GET /api/v2/station/{id}), sat_name; RST omitted per user
- Grid check: station gridsquare (first 4) vs user QTH (first 4);
  mismatch shows a confirm dialog (ignore & upload / cancel)
- Auto upload: 10-minute in-app retry loop (only when switch on);
  manual upload button; local queue capped at 500, all entries stored
  locally regardless of switch
- Fixed: subMenuOrder was never persisted (4.5.1 regression)
- core/domain: compileOnly org.json (runtime uses Android's)
- Version 4.5.2 (452)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
2026-08-04 10:29:40 +00:00
atsunatsu a333192df5 feat: add CW decoder and linear calculator page 2026-08-04 17:49:28 +08:00
mckero 38cf7a3569 feat(nav,settings): bottom nav 5+N collapsible menu + Indonesian locale
Background: 8 bottom-nav items squeeze long English labels on narrow
screens. 4.5.1 introduces the 5+N pattern: 5 main tabs plus a fixed
6th "More" button that pops a second-level menu (spring bounce) with
the remaining pages.

Changes:
- MainScreen: nav split into main (<=5, screenOrder-driven) + more
  (subMenuOrder); More button with popup panel + spring animation;
  BackHandler closes the menu before navigating back
- MoreMenuPopup: bottom-end card, current page highlighted, scrim
  click to dismiss
- UI Settings: page order card now has two zones (main menu, max 5,
  Settings locked last with no drag handle / more menu); move buttons
  between zones, drag-to-reorder within zones; new subMenuOrder pref
- Defaults: main = Satellites/Passes/Radar/Map/Settings,
  more = Mutual/Roaming/CwDecode; old screenOrder migrates by
  classifying pages against the default sub menu
- Hard-coded UI strings localized (Tracking/Lat/Lon/Qth/Connect/
  Track/Stop/CW permission prompts) into EN/ZH/TR
- NEW Indonesian locale (values-in, 181 strings + cw module strings)
  - user rule: every future release must update EN/ZH/TR/IN
- Version 4.5.1 (451)

Verified: check_strings.py OK (8 files, no bare apostrophes);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
2026-08-04 08:58:05 +00:00
mckero 760a26b007 Merge remote-tracking branch 'atsunatsu/main' 2026-08-04 08:10:17 +00:00
mckero 47f3e424ff feat(radar,cw): transponder CW panel switches to the Morse Expert engine
Background: the transponder panel CW decoder (added by the upstream
fork author) used a lightweight Kotlin Bayesian engine (core/domain/cw,
kept untouched as a fallback). This change makes the panel use the
Morse Expert engine ported in 4.5.0, so both CW entry points share the
same decoder with a live waterfall.

Changes:
- New mini layout cw_panel_main.xml (waterfall 80dp + decoded text,
  status line hidden but ID kept for controller lookup)
- MainActivity.onCreate overload with applyImmersive flag; panel binds
  with false so the host window system bars are not touched
- CwDecoderPanel now embeds the mini layout via AndroidView and drives
  the MainActivity controller: start/stop/reset map to the engine,
  lifecycle follows panel expand (start) / collapse (release mic)
- radar module now depends on feature:cw (+ constraintlayout 2.2.1,
  same as cw) for layout + controller reuse
- What's new rewritten in EN/TR/ZH for this release only

Verified: :feature:radar:compileDebugKotlin and :app:compileDebugKotlin
BUILD SUCCESSFUL locally; check_strings.py OK (7 files, no bare
apostrophes).
2026-08-04 07:56:11 +00:00
mckero f731775346 fix(cw): repair FFT pipeline (NaN twiddle table + dead cond_22 path)
The waterfall showed mirrored/upside-down garbage because the FFT
never produced a valid spectrum:
1. g3.c.f() (high-precision sin) had its quadrant-0 case mangled by
   jadx into a nested-if that returned NaN for small angles - the
   twiddle factor table ended up with 329/1024 NaN entries.
   Restored the smali switch: case0->g(), case1->c(), case2->-g(),
   case3->-c().
2. i3.d.k() routed the runtime FFT (a5==0, single-thread path) into
   the else of if(a5!=1) instead of if(a5!=0), so the FFT never ran
   and the output stayed in the time domain (peak at bin 357 for a
   669Hz tone instead of bin 86).

Verified with a JVM harness (static-block tables + pure-tone inputs):
200Hz->bin26, 400Hz->bin51, 669Hz->bin86, 1000Hz->bin128, all exact.
Twiddle table NaN count: 329 -> 0.
2026-08-04 06:55:03 +00:00
mckero 3c5486cc5c fix(cw): restore FFT dispatch (cond_22), CW UI settings entry, default order
Decode page showed stats but empty waterfall and no decoded text:
the ported i3/d.k() FFT dispatch was broken by a jadx structure
misplacement - the runtime path (k=1 -> a5=0 -> cond_22 single-thread
FFT) was replaced by a hallucinated `throw null` else-branch while the
real cond_22 code sat in a dead else. Verified against smali
(7030-7263): j3.c.q forward FFT + post-processing loop + tail + small-
array branch now live in the a5==0 branch.

Also:
- UiSettingsCard now lists CwDecode (toggle + drag-reorder) between
  Roaming and Map
- unknown screenIds in persisted screenOrder fall back to
  defaultScreenOrder position (CwDecode lands between Roaming and Map
  for existing users instead of trailing after Settings)

Verified: :feature:cw + :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-04 05:36:48 +00:00
mckero 544515feba fix(cw): restore app label, fix R8 code stripping, force 32-bit ABI
Three release-breaking issues found in the v4.5.0 APK (app crashed on
launch, label showed "Morse Expert", dex shrank to 282KB vs 3.5MB):
1. app_name: the ported app_values.xml shipped a "Morse Expert"
   app_name string which overrode Look4Sat Pro's label during resource
   merging - removed (no other string collisions).
2. R8 stripped nearly all code: the in-app sun.misc.Unsafe/Cleaner
   stubs clashed with android.jar library classes. Moved stubs to
   com.rtbishop.look4sat.feature.cw.suncompat and updated k3.d/s/r
   imports (k3.r keeps the reflective Class.forName("sun.misc.Unsafe")
   string, which returns null on Android hidden-API limits).
3. proguard-rules.pro added (AGP 9 variant-level
   CanProduceConsumerProguardFiles): keep pas.** (JNI RegisterNatives
   resolves by class name) plus all ported CW classes.
4. app-level ndk abiFilters forced to armeabi-v7a: the ported
   libnativedecoderjni.so is v7a-only, so a multi-ABI APK would crash
   with UnsatisfiedLinkError on arm64 devices.

Verified: :feature:cw:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 04:56:41 +00:00
mckero 9a54da0808 build(cw): bump to 4.5.0 with what's-new for CW decoder release
- versionCode 449 -> 450, versionName 4.4.9 -> 4.5.0
- pass_whatsnew_message rewritten (en/zh/tr) to describe ONLY this
  release: new CW Decoder page (live Morse decoding + waterfall) and
  its settings (message type, font size, 9 color themes)

Verified: check_strings.py OK (no bare apostrophes).
2026-08-04 04:21:19 +00:00
mckero 8bc1f16e2c feat(cw): wire CW decoder page into navigation (between Roaming and Map)
Compose integration of the ported CW decoder engine:
- CwDecodeScreen: AndroidView embedding the ported activity_main.xml,
  lifecycle delegated to the ported MainActivity controller (onCreate ->
  onResume, onDispose -> onPause/onDestroy), RECORD_AUDIO runtime
  permission flow (with permanent-denial -> app settings), original
  options_menu actions as a top button row (pause/clear/save/record/
  settings), double-back-to-exit preserved
- CwSettingsDialog: message_type (general_text/ham_radio_qso),
  text_font_size (7-99), and the 9 color keys (bg_color/text_color/...)
  reading/writing the same prefs keys as the original app
  (getPackageName()+"_preferences"), colors sourced from I2.b tables
- Navigation: Screen.CwDecode ("CwDecode") placed between Roaming and
  Map in the default order; defaultScreenOrder updated; ic_cw morse icon;
  nav_cw strings (en/zh/tr); app depends on :feature:cw

Verified: :feature:cw:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL (first pass, no errors).
2026-08-04 04:06:31 +00:00
mckero 5dc7ce6a22 feat(cw): port Morse Expert 1.15 CW decoder engine (feature:cw compiles)
Full port of the CW/Morse decoder from Morse Expert 1.15
(com.ve3nea.morse_expert), preserving obfuscated class names and logic.
Ads (gms.ads), billing (BillingClient), and library code were removed per
user; SettingsActivity deferred to Compose integration.

Ported modules:
- pas.*: decoder engine interface + JNI (libnativedecoderjni.so, armeabi-v7a)
- k3.*: FFT library (pseudo-enum q rewritten from smali; sun.misc stubs
  for Unsafe/Cleaner since Android lacks them; FFT path uses float[] branch)
- i3.*, g3.*, j3.*, s.*, d1.AbstractC1518b: DSP/sin-cos tables (long[] tables
  f/f11589g restored from smali)
- H2.a/b: audio capture + decode control core (jadx catch-block illusions
  cleaned per smali; recording try/catch restored around write block)
- J2.a/b: waterfall OpenGL renderer + palette (r10 = anchor color per smali)
- E2.g: waterfall touch frequency picker (height = ScaleView per smali)
- B0.b/B.RunnableC0001b: status-bar + decoded-text UI runnables (real
  constructors recovered from smali)
- MainActivity → controller class (Activity injected; immersive status bar
  inlined); C1646n trimmed to view holder; D.n keeps waterfall texture case
- F2.a: text selection menu (Save/Share)

Verified: :feature:cw:compileDebugJavaWithJavac BUILD SUCCESSFUL.
2026-08-04 03:57:25 +00:00
mckero ff408747cd fix(settings): drag-reorder tracks live position; grip becomes a single dot
Two drag issues from user testing:
1. Only adjacent swaps worked - the drag gesture could not move an
   item past multiple positions. Root cause: the pointerInput closure
   kept the index captured at composition; after the live swap
   (items.add(target, removeAt(index))) the closure's index was
   stale, so subsequent targets were computed from the wrong origin
   and the saved order got corrupted (also made previously moved
   items snap back).
   Fix: track the dragged item's live position via draggingIndex -
   onDragStart resolves it with items.indexOf(screen), onDrag computes
   the target from draggingIndex and updates it after each swap.
2. The six-dot grip icon was ugly; replaced with a single 8dp themed
   dot (Box + CircleShape inside the 48dp touch area, onSurfaceVariant
   color). ic_drag.xml removed.

What's-new rewritten in en/zh/tr with ONLY this release's changes
(user rule: replace, never append history). Version stays 4.4.9
(覆盖 per user). Verified: settings + app compile, check_strings.py
clean.
2026-08-04 01:10:13 +00:00
mckero 6636dd8e98 fix(settings): crash when scrolling Settings (nested LazyColumn unbounded height)
v4.4.9 page-order list crashed the Settings screen on scroll: the
drag-reorder LazyColumn sits inside the Settings LazyVerticalGrid
item, and a vertically scrollable child measured with infinite max
height throws IllegalStateException, killing the app before the UI
Settings card even renders.

Fix: give the LazyColumn a bounded height (itemHeight * items.size
= 7 rows × 48dp = 336dp). Drag logic, animation and persistence
unchanged.

What's-new rewritten in en/zh/tr with ONLY this fix (user rule:
replace, never append history). Version stays 4.4.9 (覆盖 per user).
Verified: settings + app compile; check_strings.py clean.
2026-08-04 00:52:43 +00:00
mckero 15ab82f1c8 fix(strings): escape apostrophe in tr whatsnew entry
"UI Ayarları'nda" broke aapt again (Invalid unicode escape
sequence). Escape the apostrophe as \'.
2026-08-04 00:36:07 +00:00
mckero 0ea9c0ede1 feat(settings): drag-to-reorder page order in UI Settings card
Users can now change the bottom navigation order (previously fixed):

- New "Page order" section under the Settings toggle in the UI
  Settings card: vertical list of all 7 pages, each with a drag
  handle (new ic_drag drawable, Material drag_indicator glyph) on
  the right.
- Drag a handle up/down: the item follows the finger with live
  swap + animateItem() placement animation; on release the order is
  persisted (OtherSettings.screenOrder, comma-separated in prefs so
  order survives; StringSet would not).
- "Reset order" button restores the default order
  (Satellites/Passes/Radar/Mutual/Roaming/Map/Settings).
- MainScreen now sorts navItems by screenOrder (empty = default,
  stable sort keeps the canonical order); hiddenScreens filtering
  unchanged; Settings entry always visible.
- New strings prefs_ui_order_title / prefs_ui_order_reset in
  en/zh/tr; What's-new updated in all three locales.

Version stays 4.4.9 (覆盖 per user). Verified: core:data tests
pass, settings + app compile.
2026-08-04 00:28:53 +00:00
mckero c496d90d5a fix(version): actually bump to 4.4.9 (449)
The previous commit message claimed the bump but the edit never
landed (script aborted on a syntax error before touching the toml);
versionName stayed 4.4.8/448. Fix the version now - the v4.4.9 tag
must carry versionName 4.4.9.
2026-08-03 18:21:52 +00:00
mckero c101870b71 feat(settings,ui): custom-source toggles default off; new UI Settings card; bump to 4.4.9
Two changes per user request:

1. Data source toggles default OFF with legacy-URL migration
   (continuation of the 4.4.8 fix, now also in the dialog): old
   example.com placeholder URLs are replaced by the real defaults and
   the custom toggles stay off, so the online update never points at a
   dead source after upgrading.

2. New "UI Settings" card between Other Settings and Credits:
   one switch per bottom-navigation page (卫星/过境/雷达/匹配/漫游/地图/设置
   in fixed order). Turning a page off removes it from the nav bar and
   the remaining items close up automatically; order is never
   rearranged. The Settings entry is always visible (switch disabled)
   so the user can never lose access to settings. Persisted via
   OtherSettings.hiddenScreens (StringSet of Screen.screenId; screenId
   added to the Screen sealed class - simpleName is unsafe under R8).

Version bump per fork convention: 4.4.8 -> 4.4.9, versionCode 449.
What's-new updated in en/zh/tr.

Verified: core:data + core:domain tests pass, all modules compile.
2026-08-03 18:14:53 +00:00
mckero 9a4ceddcb0 fix(settings): force custom-source toggles off when legacy example.com URL present
Old installs that once enabled the custom TLE/transceiver toggles kept
the legacy "https://example.com/tle.txt" placeholder in preferences.
Under the new "toggle replaces the online-update default source"
semantics this pointed the All/SatNOGS fetch at a dead URL and broke
satellite updates after upgrading to 4.4.8.

Migration in getDataSourcesSettings(): example.com placeholders are
replaced by the real default URLs (Celestrak All / SatNOGS) and the
toggles are forced OFF unless a non-default, real user URL is set.
Also disable the reset (loop) icon buttons while their toggle is off.

Verified: core:data tests pass, settings + app compile. Version stays
4.4.8 (覆盖 per user).
2026-08-03 17:03:48 +00:00
mckero 50233cadc1 fix(drawable): remove unsupported colorControlNormal tint from ic_reset
Pure-Compose project has no attr/colorControlNormal; aapt fails at
release resource linking. Icon composable applies its own tint.
2026-08-03 16:48:02 +00:00
mckero 8d88100b15 feat(settings): custom URL now overrides the online-update default source
The "Custom URL" dialog's two URL fields now control the default
source used by the online update, per the user's intent (one-time
setup, no extra steps per update):

- Defaults are real URLs now: TLE = Celestrak "All" (active group
  CSV), transceivers = SatNOGS API. The example.com placeholders are
  gone.
- updateFromRemote(): the "All" TLE entry and the "SatNOGS" entry are
  replaced by the user's URLs when the corresponding custom toggle is
  ON and the URL is non-blank; otherwise the real defaults are used.
  The old "Other" appended-source logic is removed - the toggle now
  means "use my URL for this source" instead of "download an extra
  source". Each source (TLE / transceivers) is independent.
- Each URL field gained a reset (loop) icon button on the right that
  restores the default URL text (new ic_reset drawable, Autorenew
  vector). Strings added in en/zh/tr.

Test updated: custom TLE URL data now lands under the "All" type
instead of "Other". Verified: core:data tests pass, settings + app
compile. Version stays 4.4.8 (覆盖 per user).
2026-08-03 16:41:14 +00:00
mckero 59d6e2ae05 fix(strings): escape apostrophe in tr whatsnew entry
"URL'leri" in the new Turkish whatsnew line broke aapt again
(Invalid unicode escape sequence). Escape the apostrophe as \'.
2026-08-03 16:22:23 +00:00
mckero e2b5c6d673 feat(settings): rename Import button to Custom URL; bump to 4.4.8
The online update always pulls every preset Celestrak/amsat URL and
the custom URL lived hidden behind the "Import" dialog's toggle. The
user wants a discoverable entry point to edit the custom source URLs.

Rename the data card button "Import" -> "Custom URL" (en: Custom URL,
zh: 自定义URL, tr: Özel URL). The existing dialog already hosts both
URL text fields (TLE + transceivers) and the file-import buttons, so
no dialog changes are needed - the import feature stays inside, as
the user requested.

Version bump per fork convention (feature change): 4.4.7 -> 4.4.8,
versionCode 447 -> 448. v4.4.7 release stays downloadable.

What's-new dialog updated in all three locales with this change.

Verified: settings + app compile, unit tests unaffected.
2026-08-03 16:15:02 +00:00
mckero da89d6426f fix(strings): escape apostrophe in tr whatsnew text
A bare apostrophe in "QTH定位器 2.0'dan" broke aapt resource
compilation (Invalid unicode escape sequence) and failed the release
build. Escape it as \' per Android string resource rules.
2026-08-03 15:52:57 +00:00
mckero c19209655b fix(roaming,settings): theme colors for night mode; unclamp cards; brand title and APK name
Three detail fixes plus branding, per user review:

1. Night mode visibility (roaming page): hardcoded reference blues
   (#01DDFF/#0BACF1) collapse to black under the red ColorMatrix
   filter (R channel only), making the page unreadable. Replace ALL
   colors with MaterialTheme.colorScheme (background/surface/
   surfaceVariant/onSurface/onSurfaceVariant/primary/error). GPS
   status dots become themed circles (primary/error) so they survive
   the red filter; red marker keeps the original pnt drawable.

2. Settings cards unclamped:
   - OtherCard: fixed height(268.dp) squeezed the last toggle row
     (spacing 42/42/42/15px on device); drop the fixed height.
   - CardCredits: same 268dp + SpaceBetween overflowed, gluing the
     last thanks entry to the warranty line; drop fixed height, use
     spacedBy(8.dp) and insert 24dp before the warranty text.

3. Branding:
   - Settings top title: "Look4Sat v%s" -> "Look4Sat Pro v%s" (en/tr);
     title Text no longer marquees and wraps instead (heightIn(min=48)).
   - APK asset name: look4sat-<ver>.apk -> Look4Sat-Pro-<ver>.apk.
   - What's-new dialog (pass_whatsnew_message) rewritten in all three
     locales (en/zh/tr) with this release's changes; zh gets its own
     localized title "Look4Sat Pro 更新内容".

Verified: roaming/settings/passes/app compile, 11 unit tests pass.
2026-08-03 15:46:21 +00:00
mckero d456bdf4b5 fix(roaming): stretch side cells to 80dp like the reference RelativeLayout
The user spotted two white "beams" (20dp gaps) between the center
cell and the side cells. Root cause: the reference app's RelativeLayout
ignores the fixed 60dp width when a child has BOTH a left rule
(alignParentLeft) and a right rule (toLeftOf=center cell) - the width
is stretched to right-rule minus left-rule, i.e. 80dp on a 360dp
screen. The three cells therefore sit flush with only 2dp margins
between them, no gaps.

Port that behavior: side cells 60dp -> 80dp (6 places), center cell
stays 200dp centered. Verified against the reference screenshot pixel
measurements (side cells ~76.5dp incl. margins, center ~198dp).

Verified: feature + app compile, 11 unit tests pass. Marker lookup,
grid math and GPS logic untouched.
2026-08-03 15:22:08 +00:00
mckero f06b17a194 fix(roaming): align grid columns like reference and lift footer
Two remaining proportion issues on the user's device:

1. Right-side gap: the three grid rows used a continuous Row
   (60+200+60dp), leaving ~84px of blank space on the right of the
   screen. The reference app uses a RelativeLayout where the right
   column is pinned to the screen edge and the 20dp gaps sit on both
   sides of the centered middle column. Convert each row to a Box:
   left cell align(CenterStart), middle cell align(Center), right
   cell align(CenterEnd) - pixel-identical to the reference.

2. Footer legibility: the credit line ("制作:US1PM  汉化:BA7LCE")
   had a fixed 15dp height with no bottom margin, so it sat directly
   against the navigation bar and part of the text was hard to read.
   Drop the fixed height and add 10dp bottom padding so the text
   renders fully with breathing room above the nav bar.

Verified: feature + app compile, 11 unit tests pass. Marker lookup,
grid math and GPS logic untouched.
2026-08-03 15:09:45 +00:00
mckero 675194075e fix(roaming): inset page content from system bars
The ported page rendered edge-to-edge: the GPS bar started right at
the top of the screen and the footer sat against the navigation bar,
so system UI overlapped the content (user: "顶头"). The reference app
is not edge-to-edge and keeps its content inside the safe area.

Add windowInsetsPadding(WindowInsets.systemBars) on the page root
column, shrinking the top and bottom by the status/navigation bar
height exactly as the user requested ("上方和下方往里面缩一点点").
Grid columns (60/200/60dp), rows, marker lookup and all logic are
untouched - verified pixel-identical column ratios vs the reference
screenshot (147:395:147 vs 146:395:148).

Verified: feature + app compile, 11 unit tests pass.
2026-08-03 14:55:46 +00:00
mckero 0ecccc4746 feat(roaming): port QTH定位器 2.0 page and logic verbatim
The roaming page was repeatedly rebuilt by hand and the red marker
still rendered at the wrong spot on the user's device. Per the user's
explicit instruction the whole page is now a faithful, line-by-line
port of the reference app (QTH定位器 2.0, com.us1pm.gridsquarelocator)
with zero UI or logic changes.

UI (res/layout/main.xml, byte-verified via aapt2 dump):
- 25dp holo-blue GPS bar: "GPS" 14sp, green/red status dot 15dp
  (original mipmaps copied as drawables), centered date, right time,
  translucent-yellow "设置启用GPS" button that opens location settings
- Latitude/longitude rows: 16sp black labels, right-aligned decimal
  values, DMS label format "纬度  22° 18' 50" N" exactly as reference
- 43sp bold black locator, centered, with progress spinner + notice
- 3x3 continuous grid: 60/200/60dp columns, middle row fixed 205dp,
  edge cells #0BACF1, center cell 200x200dp holo-blue (#01DDFF as
  shown on the user's device), center label 100x80dp 30sp bold white
  (textColorHighlight) centered, bottom "制作:US1PM  汉化:BA7LCE"
- Red marker: original pnt.png (red square with white outline),
  10x10dp, absolutely positioned by the reference lookup tables
  (lon 3rd pair a..x -> leftMargin -2..190dp, lat 3rd pair a..x ->
  topMargin 190..-2dp, screen-Y inverted)

Logic (MainActivity.java showLocation/checkEnabled/onResume):
- 8-char locator via the reference range-lookup tables
- 3x3 neighbor grid via parseInt3 five-branch logic incl. all four
  corner-carry tables (00/09/99/90)
- Live GPS + network updates 10s/10m while the page is shown,
  provider filtered to gps/network, checkEnabled three-state
  (green dot / red dot + settings button) exactly like the reference
- Time = cached hour prefix + fix minutes; date "dd MMM yyyy"

Dropped only the Play-store ad banner (conflicts with GPL project).

Verified: 11 unit tests pass (locator, marker lookup, grid, DMS,
time, all edge branches); feature + app modules compile.

RoamingState.kt removed - state and math now live in RoamingScreen.kt.
2026-08-03 14:29:44 +00:00
atsunatsu a7b41c95c7 fix: improve match header and daily sun times
Match page UI:
- Always show a meaningful status line in the top bar instead of leaving the second row blank on first entry.
- Add a compact status chip for waiting, calculating, result, no-match, and error states.
- Remove the duplicate intro card so the first screen starts directly with station inputs.

Pass list sun times:
- Compute sunrise/sunset from each visible date group's 00:00 in the selected timezone.
- Avoid using an arbitrary pass AOS as the rise/set search start, which could jump later-day headers to the following day's events.
2026-08-03 21:38:57 +08:00
mckero 88d1498eac fix(roaming): red marker tracks fix in BOTH axes via 3rd-pair lookup
The marker's vertical position was hardcoded (10dp below the label) — markerY never participated, so the dot could only move horizontally and could not reflect where the GPS fix sits inside the 4-char square.

Now the marker is positioned in both axes from the cell's top-left corner:
- x = markerX * cellWidth, y = markerY * cellHeight (screen Y)
- markerX/markerY come from the 3rd character pair via the reference lookup tables (lon a=-2..x=190 as leftMargin; lat a=190..x=-2 as topMargin, i.e. latitude inverted on screen)
- Verified numerically against the decompiled tables: a=0/1.0, i=0.333/0.667, x=0.958/0.042 — matching within 3% (the reference table is slightly non-uniform)

Label stays at upper-middle (28sp bold); marker is only drawn when a valid locator exists.
2026-08-03 12:55:37 +00:00
mckero 31ae04329d fix(roaming): clock ticker moves the header time; GPS mirrors station pos
Two bugs from the last release:

1. The header clock was frozen: Date() was only evaluated during recomposition, and with no state changes the time never moved. Added a 1s LaunchedEffect ticker that updates a now-state, so the date/time text re-renders and actually advances — matching the reference app, which refreshes the clock on every location callback.

2. GPS indicator: replaced the 10-minute freshness check with a direct 'has a real fix' check (timestamp > 0 and coords non-zero). The page mirrors the station position (站位) from the shared StateFlow, so the GPS dot is green whenever the station has a fix — GPS shows exactly what the station GPS says, nothing more.
2026-08-03 12:48:17 +00:00
mckero eae6304124 feat(roaming): faithful continuous-table grid layout, not card-based
Reworks the Roaming grid to structurally match the reference app instead of a card-style panel:

- Removed the outer ElevatedCard, rounded cells, cell gaps and inner padding: the 3x3 grid is now one continuous table that fills the panel, cells connected edge-to-edge.
- Cells separated by 2dp divider lines that run the full width/height of each row/column, crossing at right angles like a real coordinate grid (the reference app's continuous separator lines).
- Cells are square-cornered (no rounded corners), background fills each cell fully.
- Column widths 21.4% : 56.2% : 21.4%, row heights 31.5% : 35.9% : 31.7% retained.
- Center cell: OL42 is larger (28sp bold) and placed at upper-middle; the red marker sits BELOW the text with a 10dp gap, horizontally offset by the 3rd-pair fraction — never overlapping the label, matching the reference 'text above, marker below' layout.
- Surrounding labels bumped to 16sp Medium (larger/stronger than before).
- Info header also switched from an ElevatedCard to a flat continuous block so the page reads as one continuous surface, like the reference.

The grid proportions, locator algorithm, marker mapping and boundary logic were already faithful; this change makes the visual structure faithful too.
2026-08-03 12:32:45 +00:00
mckero 97c6d609ac fix(roaming): drop auto GPS polling, show station position directly
Reverts the auto-update machinery after review — the page now simply mirrors the station position (站位) from the shared settingsRepo.stationPosition StateFlow, exactly what the Settings page shows:

- RoamingScreen: removed the LocationManager listeners, the 30s re-request loop, the provider filter and the location-disabled hint. No polling, no auto-updates; coordinates are whatever the station GPS says.
- Settings: removed the '漫游位置实时更新' toggle (stateOfRoamingLive, key, action, strings en/zh/tr) that caused the 'Other' card to overflow — the sixth unlabeled switch clipped past the card's rounded bottom edge was that row overflowing a fixed-height card. Card height back to 268.dp, five rows fit again.

Verified: core:domain tests, roaming/settings/app compile clean; zero references to RoamingLive remain.
2026-08-03 12:24:07 +00:00
mckero 44c442ddd3 feat(roaming): port live GPS tracking and location-disabled hint
Completes the port of the QTH定位器 app's location logic (read from the decompiled MainActivity):

1. Live location updates: a LocationListener registers GPS+NETWORK providers (10s / 10m, matching the reference onResume) while the Roaming page is visible and removes itself on dispose. Every fix is pushed through settingsRepo.setStationPosition, so the shared stationPosition StateFlow updates the Settings page and the map in lockstep — the page now refreshes in real time instead of only showing stale cached coordinates.

2. Provider filtering: only gps/network fixes are accepted, mirroring the reference showLocation() guard that rejects passive fixes.

3. Location-disabled hint: when GPS is off or permission is missing, the header shows a tappable '定位未开启,点击前往系统设置' row that opens ACTION_LOCATION_SOURCE_SETTINGS — the port of the reference btnLocationSettings button. The GPS status dot now has three states: fresh fix (primary), provider on but stale (error), provider off (outline).

4. Periodic recovery: a 30s re-request loop (honoring the 漫游位置实时更新 toggle) re-arms the location request after the chip falls idle.

Not ported (conflict, deliberate): the reference app's Play-store ad banner, 'New! Grid Square with map' promo and GP_IN preferences — commercial advertising does not belong in a GPL satellite tracker.
2026-08-03 11:59:31 +00:00
mckero f9250ec1f1 fix(roaming): live coords from shared flow, marker by locator pair, versioned apk
Addresses three review findings:

1. Coordinates now come straight from settingsRepo.stationPosition in the screen (collectAsStateWithLifecycle) — the exact same StateFlow the Settings page shows. Previously a separate ViewModel re-derived them, and it could lag behind the Settings page (user: '设置页更新了站位但漫游页死活不更新'). With the shared source the two pages can never disagree. RoamingViewModel removed; state derivation moved to RoamingState.fromPosition().

2. Red marker placement ported faithfully from the QTH定位器 app: it is driven by the 3rd character pair of the 8-char locator (the 'ih' in OL42ih45), mapped to a 0..1 fraction (lon a=west..x=east, lat inverted a=south..x=north), then scaled to the actual center-cell size. The grid now uses the reference proportions (columns 21.4/56.2/21.4, rows 31.5/35.9/31.7) and fills the screen, so the marker lands accurately on any device.

3. Workflow now uploads a versioned APK (look4sat-<version>.apk instead of look4sat.apk).

Also: Settings 'Other' card rows got vertical spacing (Arrangement.spacedBy) so the new roaming toggle is not glued to the night-mode row.
2026-08-03 11:49:46 +00:00
mckero bff3e1af09 chore: bump version to 4.4.7
Next release: versionCode 446 -> 447, versionName 4.4.6 -> 4.4.7. Includes the reworked Roaming page (faithful QTH定位器 port, live-update toggle, night-mode-safe theme colors).
2026-08-03 11:27:25 +00:00
mckero 6dbe30b3f6 feat(roaming): faithful QTH定位器 port, live-update toggle, night-mode safe
Rework the Roaming page after user review. It is now a faithful port of the QTH定位器 location panel, not a loose re-skin:

UI (matching the reference layout):
- GPS status dot (real: green when a fresh fix exists, outline when stale/missing) + date + time header
- Lat/Lon rows with DMS and 5-decimal display, big 8-char locator centered below
- 3x3 grid of neighboring 4-char squares with the reference proportions: center column ~2.6x wider (21.4% : 56.2% : 21.4%) and center row the tallest (31.5% : 35.9% : 31.7%); red position marker now placed at the fractional position of the fix inside the center cell (was fixed center)
- No oversized GPS button: live updating is now a Settings toggle '漫游位置实时更新' (stateOfRoamingLive, default on) that drives periodic location refresh

Style & night-mode safety:
- All colors come from MaterialTheme.colorScheme (surfaceVariant/secondaryContainer/error) — no hardcoded cyan/blue from the original app. The red night filter (ColorMatrix keeping only the R channel) blanked the old hardcoded palette; theme colors survive it.
- Chinese nav label '漫游' added to values-zh (was missing, showing English 'Roaming')

Settings:
- OtherSettings.stateOfRoamingLive persisted (default true), toggle row in Other card, height adjusted

Verified: feature:roaming, feature:settings, app compile clean.
2026-08-03 11:18:35 +00:00
mckero 8a920acead build: revert gradle heap cap, keep CI builds unconstrained
The 768m heap cap broke GitHub Actions (KSP OutOfMemoryError: Metaspace) because CI runners read the same gradle.properties. Restore -Xmx6g for CI; the 2GB local server should pass a one-off -Dorg.gradle.jvmargs instead.
2026-08-03 10:49:59 +00:00
mckero 929f652b8e chore: bump version to 4.4.6
Next release: versionCode 445 -> 446, versionName 4.4.5 -> 4.4.6. Adds the Roaming page (QTH定位器-style 3x3 Maidenhead grid) between Match and Settings, credits BG7NTA & the original author in the thanks title, and caps local Gradle heap for the 2GB build server.
2026-08-03 10:47:39 +00:00
mckero 610768501e build: add roaming module gradle file, cap local JVM heap
Include feature:roaming/build.gradle.kts (missed from the module commit) and keep the reduced Gradle heap (-Xmx768m) so local light builds don't freeze the 2GB server.
2026-08-03 10:46:32 +00:00
mckero 427e6e1862 i18n: credit BG7NTA and original author in thanks title
The settings outro title now reads 'BG7NTA & the original author would like to thank' (en) / 'BG7NTA 与原作者感谢' (zh) / 'BG7NTA ve orijinal yazar teşekkür eder' (tr), alongside the BA7OPF/BG7NTA entries added to the credits list.
2026-08-03 10:46:20 +00:00
mckero bfa5e5f474 feat(roaming): add Roaming page with 3x3 Maidenhead grid
Ports the QTH定位器 (com.us1pm.gridsquarelocator) location panel into Look4Sat as a new 'Roaming' page, restyled with the app's own look.

UI (top to bottom):
- Info header: GPS status dot, big 8-char locator, Lat/Lon rows with DMS + 5-decimal display, and a GPS 定位 button
- 3x3 grid panel: the current 4-char Maidenhead square (e.g. OL42) centered, surrounded by its 8 neighbors (OL33..OL51), with a red position marker in the center cell

Logic:
- QthConverter gains qthNeighbors(square) building the 3x3 grid with field/square carry at boundaries (AA00 wraps to RR99, IO91 crosses into J field), and qthToSquare(locator) extracting the 4-char square
- Verified against the decompiled app algorithm and the reference screenshot (OL42 grid matches exactly); 9 unit tests cover normal, boundary and field-wrap cases

Navigation:
- New bottom-nav item 'Roaming' between Match and Settings, with a crosshair icon
- New feature:roaming module (ViewModel + Compose screen) registered in the app

Build config:
- Lowered Gradle JVM heap from -Xmx6g to 768m: the 2GB build server froze on the old value; heavy release builds stay on GitHub Actions
2026-08-03 10:46:14 +00:00
mckero 213425379c merge: merge atsunatsu/main (mutual radar overlay arrows)
Merge upstream commit 2298d8ee 'feat: add mutual radar overlay arrows'. Clean auto-merge: upstream changed RadarView.kt arrow drawing, our sweep optimization stayed intact. No conflicts.

First v4.4.5 was built from c3444aed; this release rebuilds from the merged tree.
2026-08-03 10:04:09 +00:00
mckero c3444aed24 chore: bump version to 4.4.5
Next release: versionCode 444 -> 445, versionName 4.4.4 -> 4.4.5, following the upstream scheme without suffix. Includes the station panel layout fix, 5-decimal coordinates, and credits additions already merged on main.
2026-08-03 09:43:10 +00:00
mckero 368bd199e5 fix: station panel layout wraps QTH to its own line
With 5-decimal coordinates the Lat/Lon/Qth row overflowed, pushing the QTH value to a wrapped line. Split into two rows: Lat + Lon on the first line, Qth on the second.

Also append BA7OPF (pass matching feature) and BG7NTA to the credits list in en/zh/tr string resources.
2026-08-03 09:33:40 +00:00
atsunatsu 2298d8eea4 feat: add mutual radar overlay arrows 2026-08-03 17:27:48 +08:00
mckero 7bb0bbbb46 build: load release signing config from local keystore.properties
Conditionally applies the BG7NTA signingConfig when keystore.properties exists (gitignored). CI signs via apksigner with GitHub Secrets, so this only affects local builds.
2026-08-03 09:25:53 +00:00
mckero deec5581ae feat: rebrand to Look4Sat Pro, bump version to 4.4.4
App display name changed from Look4Sat to Look4Sat Pro (fork identity). Version follows upstream scheme without suffix: 4.4.3 -> 4.4.4, versionCode 443 -> 444.
2026-08-03 09:20:41 +00:00
mckero 1de5632428 feat: increase station coordinate precision to 5 decimals
Station lat/lon was stored rounded to 4 decimals (~11 m). Bump to 5 decimals (~1.1 m), slightly better than GPS hardware accuracy without showing noise.

- SettingsRepo.setStationPosition: round(4) -> round(5)
- QthConverter.qthToPosition: round(4) -> round(6) so 10-char locators fully roundtrip
- Update QthConverterTest expected values to 6-decimal precision
2026-08-03 09:14:13 +00:00
mckero 2dfd76825b fix: fork under own applicationId com.rtbishop.look4sat.bg7nta
The official app signs com.rtbishop.look4sat with its own certificate. A fork sharing that applicationId cannot be installed over the official build (signature mismatch) and users saw overwrite/install failures.

- Add applicationId version catalog entry; namespace stays com.rtbishop.look4sat so source imports are untouched, applicationId becomes com.rtbishop.look4sat.bg7nta.
- Update PROPERTY_SATELLITE_DATA_OPTIMIZED meta-data to the fork id.
- Document the fork-applicationId requirement in the version catalog.
2026-08-03 08:38:21 +00:00
mckero 06fc8bc2fb ci: set GH_TOKEN for release creation step
gh CLI in Actions requires GH_TOKEN to authenticate. Use the built-in github.token.
2026-08-03 08:15:20 +00:00
mckero 75322390b8 ci: allow specifying release tag on manual dispatch
workflow_dispatch defaults TAG_NAME to the branch name (main), which breaks gh release create. Accept an optional tag_name input and fall back to github.ref_name for tag-triggered runs.
2026-08-03 08:12:20 +00:00
mckero d1312f1f09 ci: add manual workflow_dispatch trigger
Allow triggering the release build from the Actions tab in case tag events are not picked up.
2026-08-03 08:06:19 +00:00
mckero 75acbbd633 ci: simplify release workflow for fork builds
Remove Google Play upload (needs SERVICE_ACCOUNT_JSON we don't have) and AAB signing. Use built-in GITHUB_TOKEN instead of RELEASE_TOKEN secret. Pin actions to stable versions (checkout@v4, setup-java@v4, setup-gradle@v4). Builds assembleRelease, signs APK via apksigner with KEY_STORE secrets, creates GitHub release with APK. Triggered by v** tags.
2026-08-03 08:03:16 +00:00
mckero a37fb8f773 feat(domain): support 6/8/10-char Maidenhead grid square conversion
Port the 8-char (4-pair) Maidenhead grid algorithm from the
"QTH定位器 2.0" app (com.us1pm.gridsquarelocator) into QthConverter
so locator precision matches common grid tools instead of being
truncated to 6 chars.

Previously positionToQth() emitted only 6-char locators and
qthToPosition() discarded everything past the 6th character via
take(6), losing the finer 30" x 15" resolution carried by 8-char
grid squares.

What changed:
- positionToQth(lat, lon, precision = 8) now emits 8-char locators
  by default; precision = 6 / 10 available for backwards
  compatibility and maximum resolution (1.25" x 0.625").
- qthToPosition() parses 6/8/10-char locators and returns the center
  of the finest encoded cell (30" x 15" for 8-char, 1.25" x 0.625"
  for 10-char) instead of the 6-char cell center.
- Locator validation regex tightened: 6/8/10 chars accepted,
  4-char strings like "JN58" are now rejected as invalid.
- Boundary clamping added so lat = 90 / lon = 180 no longer overflow
  the A-R / 0-9 / a-x alphabet (previously produced invalid chars).

Also fixed a pre-existing compile error in RadarView.kt: a delegated
property was assigned after declaration ("by" on an already declared
val). Converted the sweep angle to an if/else expression.

Verification:
- QthConverterTest extended to 5 test cases covering 6/8/10-char
  roundtrips, invalid input, boundary coordinates and roundtrip
  stability.
- Cross-checked against a Python reference model of the decompiled
  APK algorithm: 20k random roundtrips at 8 and 10 chars, 0 failures.
- :core:domain:test green; :app:compileDebugKotlin passes.
2026-08-03 07:25:31 +00:00
mckero 9d0d971500 refine: AOS binary-search refinement, getRadios signature cleanup
Refine AOS crossing to ~500ms via binary search instead of linear stepping; simplify getRadios to take satPos; tidy radar view/viewmodel.
2026-08-03 06:47:49 +00:00
101 changed files with 9325 additions and 311 deletions

No files matched your search

+19 -40
View File
@@ -4,10 +4,15 @@ on:
push:
tags:
- v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env:
TAG_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
jobs:
release:
@@ -16,66 +21,40 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
uses: gradle/actions/setup-gradle@v4
- name: Assemble Artifacts
run: ./gradlew assembleRelease bundleRelease
- name: Assemble APK
run: ./gradlew assembleRelease
- name: Sign APK
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
VERSION=${TAG_NAME#v}
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
${BUILD_TOOLS}apksigner sign \
--ks keystore.jks \
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
--out app/build/outputs/apk/release/look4sat.apk \
--out "$SIGNED_APK" \
"$APK"
rm keystore.jks
- name: Sign Bundle
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Deploy Bundle to Google Play
run: |
gem install multi_json
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
gh release upload $TAG_NAME "$SIGNED_APK"
+1 -2
View File
@@ -39,8 +39,7 @@ captures/
.idea/
# Keystore files
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
*.jks
/*.properties
/keystore.properties
/app/keystore.jks
+36
View File
@@ -1,3 +1,39 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
// CW 解码已迁移为纯 Kotlin fldigi 引擎, 不再有 native 库;
// 取消 armeabi-v7a 强制, 按设备 ABI 打包(含 x86_64 模拟器)
defaultConfig {
}
androidResources {
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
localeFilters += listOf(
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
)
}
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
}
+9 -2
View File
@@ -14,7 +14,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -43,7 +46,11 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" />
android:value="com.rtbishop.look4sat.bg7nta" />
<service
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,190 @@
package com.rtbishop.look4sat.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
import com.rtbishop.look4sat.core.data.aprs.AprsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* APRS foreground service: kept alive by a system notification; keeps working across pages.
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
*/
class AprsForegroundService : Service() {
companion object {
const val ACTION_START = AprsStore.ACTION_START
const val ACTION_STOP = AprsStore.ACTION_STOP
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reporter: AprsReporter? = null
private var lastState: AprsState = AprsState.Idle
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
startReporting()
}
reporter?.reportNow()
}
else -> startReporting()
}
return START_STICKY
}
private fun startReporting() {
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
}
stopSelf()
return
}
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
}
)
reporter = rep
rep.start()
}
private fun stopReporting() {
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun startForegroundWithNotification(cfg: AprsConfig) {
try {
val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notif)
}
} catch (e: Exception) {
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
stopSelf()
}
}
private fun buildNotification(cfg: AprsConfig): Notification {
val pi = PendingIntent.getActivity(
this, 0, packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val stopPi = PendingIntent.getService(
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE
)
val stateText = when (lastState) {
AprsState.Connected -> getString(R.string.aprs_notif_connected)
AprsState.Error -> getString(R.string.aprs_notif_error)
else -> getString(R.string.aprs_notif_running)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_radios)
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
.setContentText(stateText)
.setContentIntent(pi)
.setOngoing(true)
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
.build()
}
private fun updateNotification(cfg: AprsConfig) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
private fun stationPosition(): Pair<Double, Double>? {
// 1. Station QTH (position set in settings)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
}.getOrNull()
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// 2. Fallback: last live GPS position
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
android.location.LocationManager.GPS_PROVIDER,
android.location.LocationManager.NETWORK_PROVIDER
)
for (p in providers) {
val loc = lm.getLastKnownLocation(p) ?: continue
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
return Pair(loc.latitude, loc.longitude)
}
}
null
}.getOrNull()
}
private fun createChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID, getString(R.string.aprs_notif_channel),
NotificationManager.IMPORTANCE_LOW
)
nm.createNotificationChannel(channel)
}
override fun onDestroy() {
reporter?.stop()
reporter = null
super.onDestroy()
}
}
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
private lateinit var container: IMainContainer
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
private fun installCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
val log = StringBuilder()
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
log.append("Thread: ").append(thread.name).append("\n")
val sw = java.io.StringWriter()
throwable.printStackTrace(java.io.PrintWriter(sw))
log.append(sw.toString()).append("\n")
val file = java.io.File(filesDir, "crash_log.txt")
file.appendText(log.toString())
}
defaultHandler?.uncaughtException(thread, throwable)
}
}
override fun getMainContainer(): IMainContainer = container
override fun onCreate() {
super.onCreate()
installCrashHandler()
container = MainContainer(this)
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
@@ -17,16 +17,23 @@
*/
package com.rtbishop.look4sat
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -53,6 +60,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -80,11 +90,15 @@ 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.cw.CwDecodeScreen
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.status.SatStatusScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -130,12 +144,35 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
if (it != -1) it else Int.MAX_VALUE
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
}
val moreNavItems = remember(allNavItems, subOrder) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// Intercept Back while the More menu is open: close the menu first
BackHandler(enabled = moreExpanded) { moreExpanded = false }
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
@@ -150,12 +187,15 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
) {
NavigationSuiteScaffold(
navigationSuiteItems = {
navItems.forEach { screen ->
mainNavItems.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.AmSat -> screen is Screen.AmSat
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -166,11 +206,26 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
selected = isSelected,
onClick = {
if (isSelected) return@item
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
if (moreNavItems.isNotEmpty()) {
item(
icon = {
Icon(
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
)
},
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
selected = moreExpanded,
onClick = { moreExpanded = !moreExpanded }
)
}
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
@@ -181,10 +236,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Column {
Box {
Column(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).fillMaxWidth(),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
@@ -204,7 +260,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
// navigateToRadar()
}
}
entry<Screen.Radar> {
@@ -224,6 +280,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
)
}
entry<Screen.Roaming> {
RoamingScreen()
}
entry<Screen.CwDecode> {
CwDecodeScreen()
}
entry<Screen.AmSat> {
SatStatusScreen(container = container)
}
entry<Screen.WavelogLog> {
WavelogLogScreen(queue = container.wavelogQueue)
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -262,7 +330,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -278,6 +346,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
enter = expandVertically(
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
MoreMenuPopup(
items = moreNavItems,
currentKey = currentKey,
onDismiss = { moreExpanded = false },
onSelect = { screen ->
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
}
}
}
}
@@ -0,0 +1,106 @@
/*
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
*
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
* (MainScreen's AnimatedVisibility + spring).
*/
package com.rtbishop.look4sat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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
import androidx.navigation3.runtime.NavKey
import com.rtbishop.look4sat.core.presentation.Screen
@Composable
fun MoreMenuPopup(
items: List<Screen>,
currentKey: NavKey?,
onDismiss: () -> Unit,
onSelect: (Screen) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
.clickable(onClick = onDismiss)
) {
Card(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
items.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(screen) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId),
tint = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(screen.titleResId),
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Text(
text = "›",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>
@@ -31,12 +31,15 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:cw"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = libs.versions.packageName.get()
applicationId = libs.versions.applicationId.get()
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
@@ -0,0 +1,106 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
*/
class AprsIsClient(
private val host: String,
private val port: Int,
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** Connect + login (synchronous/blocking; call from a background thread) */
@Throws(Exception::class)
fun connect() {
disconnect()
val s = Socket()
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
}
/**
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
}
fun disconnect() {
synchronized(lock) {
runCatching { writer?.close() }
runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
socket = null
}
}
}
@@ -0,0 +1,126 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS connection state */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS config (persisted in SharedPreferences, saved as filled) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
val port: Int = 14580,
val callsign: String = "",
val ssid: String = "",
val passcode: String = "",
val intervalMin: Int = 5,
val statusText: String = "Look4Sat APRS",
val symbolTable: String = "/",
val symbolCode: String = ">",
val includeCourseSpeed: Boolean = true,
val includeAltitude: Boolean = true
)
/** APRS report result */
data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: AprsIsClient? = null
private var job: Job? = null
private var manualJob: Job? = null
val isRunning: Boolean get() = job?.isActive == true
/** Start periodic reporting (called by the foreground service) */
fun start() {
stop()
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) {
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
}
fun stop() {
job?.cancel()
job = null
runCatching { client?.disconnect() }
client = null
onState(AprsState.Idle)
}
/** Trigger one report manually (immediately, without waiting for the cycle) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
}
private suspend fun reportOnce() {
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
}
}
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
}
}
@@ -0,0 +1,83 @@
package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS config storage + service action constants (shared by feature/settings and the app service,
* so feature never depends on app).
*/
object AprsStore {
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
private const val PREFS = "aprs_config"
private const val KEY_ENABLED = "enabled"
private const val KEY_SERVER = "server"
private const val KEY_PORT = "port"
private const val KEY_CALLSIGN = "callsign"
private const val KEY_SSID = "ssid"
private const val KEY_PASSCODE = "passcode"
private const val KEY_INTERVAL = "interval"
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** Read config (saved as filled; no need to re-enter each time) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
enabled = p.getBoolean(KEY_ENABLED, false),
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
port = p.getInt(KEY_PORT, 14580),
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
ssid = p.getString(KEY_SSID, "") ?: "",
passcode = p.getString(KEY_PASSCODE, "") ?: "",
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
)
}
/** Last report result (shown on the settings card) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return LastReport(
time = p.getLong(KEY_LAST_TIME, 0L),
ok = p.getBoolean(KEY_LAST_OK, false),
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
)
}
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
.putBoolean(KEY_LAST_OK, ok)
.putString(KEY_LAST_DETAIL, detail)
.apply()
}
/** Save config */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
.putString(KEY_SERVER, cfg.server)
.putInt(KEY_PORT, cfg.port)
.putString(KEY_CALLSIGN, cfg.callsign)
.putString(KEY_SSID, cfg.ssid)
.putString(KEY_PASSCODE, cfg.passcode)
.putInt(KEY_INTERVAL, cfg.intervalMin)
.putString(KEY_STATUS, cfg.statusText)
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
.apply()
}
}
@@ -29,6 +29,8 @@ 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.repository.AmSatRepository
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -40,6 +42,9 @@ 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.wavelog.IWavelogQueueStore
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -66,16 +71,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() }
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo = AmSatRepository(com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient())
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -96,6 +104,22 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
})
}
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
private val lotwRepo: LotwSatellitesRepo by lazy {
LotwSatellitesRepo(context).also { it.restore() }
}
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
@@ -149,7 +173,6 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
@@ -160,7 +183,14 @@ class MainContainer(private val context: Context) : IMainContainer {
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
return RemoteSource(
Dispatchers.IO, context.contentResolver,
OkHttpClient.Builder()
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.build()
)
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -176,6 +206,6 @@ class MainContainer(private val context: Context) : IMainContainer {
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(manager, appPreferences, appVersionName)
return SettingsRepo(context, manager, appPreferences, appVersionName)
}
}
@@ -0,0 +1,93 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
import com.rtbishop.look4sat.core.domain.amsat.ApiReport
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Calendar
import java.util.TimeZone
/** AMSAT status repository: official API v1 -> SatStatusPage (replaces the HTML parser). */
class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository {
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val names = apiClient.fetchCatalog()
val reports = apiClient.fetchAllReports(hours = 168)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NoReportGray, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ActiveBlue
"telemetry only" -> TlmOrange
"not heard" -> NotHeardPink
else -> ConflictDeepOrange
}
companion object {
private const val ActiveBlue = 0xFF648FFF
private const val TlmOrange = 0xFFFFB000
private const val NotHeardPink = 0xFFDC267F
private const val ConflictDeepOrange = 0xFFFE6100
private const val NoReportGray = 0xFFC0C0C0
}
}
@@ -69,23 +69,33 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
if (successCount == 0) {
throw java.io.IOException("All data sources failed to download")
}
// parse fetched data concurrently and associate with types
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val importedEntries = tleResults.flatMap { (url, stream) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
val importedRadios = radioResults.flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
// insert parsed data into the database
@@ -95,14 +95,8 @@ class SatelliteRepo(
}
}
override suspend fun getRadios(
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -242,13 +236,16 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision via binary search.
// Elevation is monotonic across the horizon crossing, so binary search
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
while (aosHi - aosLo > 500L) {
val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -262,13 +259,14 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision via binary search (same monotonic argument)
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
while (losHi - losLo > 500L) {
val mid = (losLo + losHi) / 2
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
}
calendarTimeMillis = losHi
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class SettingsRepo(
private val context: android.content.Context,
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
@@ -76,6 +78,8 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyHiddenScreens = "hiddenScreens"
private val keyScreenOrder = "screenOrder"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -91,6 +95,11 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val keySubMenuOrder = "subMenuOrder"
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogApiKey = "wavelogApiKey"
private val keyWavelogStationId = "wavelogStationId"
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val separatorComma = ","
//region # Satellites selection settings
@@ -178,23 +187,41 @@ class SettingsRepo(
return true
}
override fun setStationPosition(): Boolean {
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
override suspend fun setStationPosition(): Boolean {
// Permission gate: fail fast without location permission (no swallowed exceptions)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
println("GPS: no fine location permission")
return false
}
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
try {
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
val location = locationManager.getLastKnownLocation(providerDef)
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
println("Requesting location for $provider provider")
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
} else {
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
val signal = android.os.CancellationSignal()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val executor = java.util.concurrent.Executor { handler.post(it) }
// 15 s timeout
val timeout = handler.postDelayed({
signal.cancel()
if (cont.isActive) cont.resume(false) { }
}, 15_000L)
val listener = androidx.core.util.Consumer<Location> { location ->
handler.removeCallbacksAndMessages(null)
setStationPosition(location.latitude, location.longitude, location.altitude)
if (cont.isActive) cont.resume(true) { }
}
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
val provider = if (hasGps) providerGps else providerNet
try {
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
} catch (exception: SecurityException) {
println("No permissions were given - $exception")
handler.removeCallbacksAndMessages(null)
if (cont.isActive) cont.resume(false) { }
}
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
}
return true
}
override fun setStationPosition(locator: String): Boolean {
@@ -213,8 +240,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -356,6 +383,13 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(","))
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
putString(keyWavelogUrl, new.wavelogUrl)
putString(keyWavelogApiKey, new.wavelogApiKey)
putString(keyWavelogStationId, new.wavelogStationId)
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
}
new
}
@@ -371,8 +405,15 @@ class SettingsRepo(
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
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())),
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
)
//endregion
@@ -390,12 +431,20 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
}
//endregion
//region # Radio control settings
@@ -0,0 +1,136 @@
package com.rtbishop.look4sat.core.data.source
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import java.util.regex.Pattern
/**
* AMSAT satellite status page parser (https://amsat.org/status/)
*
* Page structure (static HTML, verified 2026-08):
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
* docTips.show('id') link
* - Report details live in the page's inline JS:
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
*
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
*/
object AmSatParser {
private val STATUS_COLORS = mapOf(
"#648fff" to 0xFF648FFF.toLong(),
"#ffb000" to 0xFFFFB000.toLong(),
"#dc267f" to 0xFFDC267F.toLong(),
"#fe6100" to 0xFFFE6100.toLong()
)
private val GRAY = 0xFFC0C0C0.toLong()
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
private val tipRe = Pattern.compile(
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
)
/** Parse the full page */
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
val reports = parseReports(html)
val statuses = parseStatusTable(html, reports)
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
}
/** Extract all reports (tips.* JS arrays) */
fun parseReports(html: String): Map<String, SatReport> {
val map = mutableMapOf<String, SatReport>()
val m = tipRe.matcher(html)
while (m.find()) {
val id = m.group(1)
val parts = m.group(2).split("<br>")
map[id] = SatReport(
id = id,
statusText = parts.getOrElse(0) { "" }.trim(),
call = parts.getOrElse(1) { "" }.trim(),
grid = parts.getOrElse(2) { "" }.trim(),
dateUtc = parts.getOrElse(3) { "" }.trim(),
timeUtc = parts.getOrElse(4) { "" }.trim()
)
}
return map
}
/** Parse the status table (48 satellite rows) */
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
val result = mutableListOf<SatStatus>()
val tables = extractTables(html)
for (table in tables) {
val rows = rowRe.matcher(table)
val parsed = mutableListOf<SatStatus>()
var rowIndex = 0
var dayHeaders: List<String> = emptyList()
while (rows.find()) {
val rowHtml = rows.group(1)
val cells = cellRe.matcher(rowHtml)
val cellList = mutableListOf<String>()
while (cells.find()) cellList.add(cells.group(1))
if (rowIndex == 0) {
// Header: Name + 6 days (each colspan=12)
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
rowIndex++
continue
}
if (cellList.size < 7) { rowIndex++; continue }
val name = stripHtml(cellList[0])
if (name.isBlank()) { rowIndex++; continue }
val days = mutableListOf<SatDay>()
for (d in 0 until 6) {
val start = 1 + d * 12
val end = start + 12
val slots = (start until end).mapNotNull { i ->
cellList.getOrNull(i)?.let { cell ->
val bg = bgRe.matcher(cell)
val color = if (bg.find()) {
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
} else GRAY
val link = linkRe.matcher(cell)
val ids = mutableListOf<String>()
while (link.find()) ids.add(link.group(1))
val hasReport = ids.isNotEmpty()
val count = if (hasReport) {
stripHtml(cell).trim().toIntOrNull() ?: ids.size
} else 0
SatSlot(
statusColor = if (hasReport) color else GRAY,
count = count,
reportIds = ids
)
}
}
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
}
parsed.add(SatStatus(name, days))
rowIndex++
}
if (parsed.isNotEmpty()) {
result.addAll(parsed)
break
}
}
return result
}
private fun extractTables(html: String): List<String> {
val result = mutableListOf<String>()
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
while (m.find()) result.add(m.group())
return result
}
private fun stripHtml(s: String): String =
s.replace(Regex("<[^>]+>"), "").trim()
}
@@ -43,6 +43,22 @@ class RemoteSource(
}
}
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://amsat.org/status/")
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat status exception: $exception")
null
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
override fun invoke(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
override fun invoke(resId: Int) {
invoke(context.getString(resId))
}
}
@@ -0,0 +1,62 @@
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
*/
package com.rtbishop.look4sat.core.data.wavelog
import android.content.Context
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.URL
import java.util.zip.GZIPInputStream
import kotlin.random.Random
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
/** Restore the last downloaded list from SharedPreferences (call at process start) */
override fun restore() {
val saved = prefs.getString(KEY_NAMES, null)
if (!saved.isNullOrBlank()) {
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
}
}
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
try {
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
val raw = conn.getInputStream()
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
val text = reader.use { it.readText() }
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
.map { it.groupValues[1].uppercase() }
.toSet()
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
LotwSatellites.updateNames(names)
prefs.edit()
.putString(KEY_NAMES, names.joinToString(","))
.putLong(KEY_UPDATED, System.currentTimeMillis())
.apply()
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
} catch (e: Exception) {
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
}
}
companion object {
private const val KEY_NAMES = "names"
private const val KEY_UPDATED = "updated"
}
}
@@ -102,7 +102,8 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
private fun validCsvStream(): InputStream = """
+5
View File
@@ -1,3 +1,8 @@
plugins {
alias(libs.plugins.convention.coreDomainPlugin)
}
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -0,0 +1,98 @@
/* AmSatApiClient.kt - AMSAT official status API v1 client (pure JVM).
* Endpoints (verified 2026-08):
* GET https://www.amsat.org/status/api/v1/catalog.php -> satellite list
* GET https://www.amsat.org/status/api/v1/reports.php?hours=N&limit=500 -> reports
* Report fields: id, name ("SO-50_[FM]"), callsign, report, grid_square, reported_time (ISO 8601 UTC).
* Status values: Heard / Telemetry Only / Not Heard / Crew Active.
*/
package com.rtbishop.look4sat.core.domain.amsat
import org.json.JSONObject
import org.json.JSONArray
import java.io.BufferedReader
import java.net.HttpURLConnection
import java.net.URL
/** One report from the AMSAT API. */
data class ApiReport(
val id: String,
val name: String, // "SO-50_[FM]" (API name, includes mode suffix)
val callsign: String,
val report: String, // Heard / Telemetry Only / Not Heard / Crew Active
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT official satellite status API v1 client. */
class AmSatApiClient(private val baseUrl: String = "https://www.amsat.org/status/api/v1") {
/** Fetch the full satellite catalog. Returns API names (e.g. "SO-50_[FM]"). */
fun fetchCatalog(): List<String> {
val body = httpGet("$baseUrl/catalog.php") ?: return emptyList()
return try {
val arr = JSONObject(body).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
emptyList()
}
}
/** Fetch reports for a rolling UTC window. Empty on failure. */
fun fetchAllReports(hours: Int = 168): List<ApiReport> {
val body = httpGet("$baseUrl/reports.php?hours=$hours&limit=500") ?: return emptyList()
return try {
val arr = JSONObject(body).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
emptyList()
}
}
/** Parse "2026-08-05T07:30:00Z" to epoch seconds (minSdk 24: no java.time). */
fun parseIsoUtcSec(iso: String): Long {
val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L
val (y, mo, d, h, mi, s) = m.destructured
val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt())
return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt()
}
/** Days since 1970-01-01 (civil calendar, proleptic Gregorian). */
private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
val y = if (month <= 2) year - 1 else year
val era = (if (y >= 0) y else y - 399) / 400
val yoe = y - era * 400
val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
return era * 146097L + doe - 719468
}
private fun httpGet(url: String): String? {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 15000
conn.readTimeout = 20000
conn.setRequestProperty("User-Agent", "Look4Sat/4.5.5")
if (conn.responseCode !in 200..299) {
conn.disconnect()
return null
}
val text = conn.inputStream.bufferedReader().use(BufferedReader::readText)
conn.disconnect()
text
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,116 @@
package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
*/
object AprsPacket {
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
fun passcode(callsign: String): Int {
val s = callsign.split("-")[0].uppercase() + "\u0000"
var hash = 29666 // 0x73E2
var i = 0
while (i <= s.length - 2) {
hash = hash xor (s[i].code * 256 + s[i + 1].code)
i += 2
}
return hash and 0x7FFF
}
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
return "$callsign-$ssid"
}
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/** Altitude extension /A=00000 (feet) */
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
}
/** Speed/course extension (knots/degrees) */
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt()
return String.format("/%03d/%03d", bearing.toInt(), knots)
}
}
/**
* APRS position encoding (reverse-ported from ab0oo Position.java).
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
*/
class AprsPosition(
val latitude: Double,
val longitude: Double,
val symbolTable: Char,
val symbolCode: Char,
val positionAmbiguity: Int = 0
) {
/** Uncompressed format (APRS-IS default reporting format) */
fun toUncompressedString(): String {
val lat = getDMS(latitude, true)
val lon = getDMS(longitude, false)
return "$lat$symbolTable$lon$symbolCode"
}
/** Compressed format (base91, standard APRS algorithm) */
fun toCompressedString(): String {
val jRound = round((90.0 - latitude) * 380926.0).toLong()
val j = jRound / 753571 + 33
val j2 = jRound % 753571
val j3 = j2 / 8281 + 33
val j4 = j2 % 8281
val i = (j4 % 91).toInt() + 33
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
val j5 = jRound2 / 753571 + 33
val j6 = jRound2 % 753571
val j7 = 33 + j6 / 8281
val j8 = j6 % 8281
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
j5.toInt().toChar() + j7.toInt().toChar() +
((j8 / 91).toInt() + 33).toChar() +
((j8 % 91).toInt() + 33).toChar() + symbolCode
}
/** Single-axis DMS encoding (hundredths) */
private fun getDMS(value: Double, isLat: Boolean): String {
var iRound = round(value * 6000.0).toInt()
if (iRound < 0) iRound = -iRound
val degrees = iRound / 6000
val minutes = (iRound / 100) % 60
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format("%d . ", minutes / 10)
3 -> String.format("%02d. ", minutes)
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
else -> String.format("%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format("%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format("%03d%s%c", degrees, frac, ew)
}
}
}
@@ -0,0 +1,139 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/**
* FFT lowpass/bandpass filter with overlap-add, ported from fldigi
* fftfilt.cxx (fftfilt class). Used by the CW decoder for the
* matched receive filter.
*/
internal class CwFftFilt(f1: Double, f2: Double, len: Int) {
private val flen: Int
private val flen2: Int
private val fft: CwGfft
private val filter: Array<CwComplex>
private val timedata: Array<CwComplex>
private val freqdata: Array<CwComplex>
private val output: Array<CwComplex>
private val ovlbuf: Array<CwComplex>
private var inptr = 0
private var pass = 0
init {
flen = len
flen2 = flen shr 1
fft = CwGfft(flen)
filter = Array(flen) { CwComplex() }
timedata = Array(flen) { CwComplex() }
freqdata = Array(flen) { CwComplex() }
output = Array(flen) { CwComplex() }
ovlbuf = Array(flen2) { CwComplex() }
createFilter(f1, f2)
}
constructor(f: Double, len: Int) : this(0.0, f, len)
fun flushSize(): Int = flen - inptr
/**
* f1 < f2 ==> bandpass; f1 > f2 ==> band reject; f1 == 0 ==> lowpass; f2 == 0 ==> highpass.
*/
private fun createFilter(f1: Double, f2: Double) {
for (i in 0 until flen) {
filter[i] = CwComplex()
}
val bLowpass = f2 != 0.0
val bHighpass = f1 != 0.0
for (i in 0 until flen2) {
var h = 0.0
if (bLowpass) h += fsinc(f2, i, flen2)
if (bHighpass) h -= fsinc(f1, i, flen2)
if (bHighpass && f2 < f1 && i == flen2 / 2) h += 1.0
filter[i] = CwComplex(h * blackman(i, flen2), 0.0)
}
// forward FFT of the impulse response
fft.forward(filter)
// normalize unity gain
var scale = 0.0
for (i in 0 until flen2) {
val mag = kotlin.math.sqrt(filter[i].re * filter[i].re + filter[i].im * filter[i].im)
if (mag > scale) scale = mag
}
if (scale != 0.0) {
for (i in 0 until flen) {
filter[i] = CwComplex(filter[i].re / scale, filter[i].im / scale)
}
}
pass = 1
}
fun createLpf(f: Double) = createFilter(0.0, f)
private fun fsinc(fc: Double, i: Int, len: Int): Double {
val mid = len / 2
return if (i == mid) 2.0 * fc
else sin(2.0 * PI * fc * (i - mid)) / (PI * (i - mid))
}
private fun blackman(i: Int, len: Int): Double =
0.42 - 0.50 * cos(2.0 * PI * i / len) + 0.08 * cos(4.0 * PI * i / len)
/**
* Feed one complex input sample. Returns output array + count when flen/2
* samples are ready, otherwise null.
*/
fun run(input: CwComplex): Array<CwComplex>? {
timedata[inptr++] = input
if (inptr < flen2) return null
if (pass > 0) pass--
// copy to freq domain and FFT
for (i in 0 until flen) {
freqdata[i] = timedata[i]
}
fft.forward(freqdata)
// multiply by filter shape
for (i in 0 until flen) {
val f = filter[i]
val t = freqdata[i]
freqdata[i] = CwComplex(t.re * f.re - t.im * f.im, t.re * f.im + t.im * f.re)
}
// inverse FFT
fft.inverse(freqdata)
// overlap-add: first half is valid output, save second half
for (i in 0 until flen2) {
output[i] = CwComplex(ovlbuf[i].re + freqdata[i].re, ovlbuf[i].im + freqdata[i].im)
ovlbuf[i] = freqdata[i + flen2]
}
inptr = 0
if (pass > 0) return null
return output
}
}
@@ -0,0 +1,530 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.abs
import kotlin.math.log10
import kotlin.math.roundToInt
/**
* CW decoder ported from fldigi (w1hkj/fldigi) cw.cxx — full receive
* chain: NCO down-conversion, FFT lowpass filter, decimation, AGC,
* hysteresis keying detection, element timing state machine, adaptive
* speed tracking, SOM codebook matching and morse table lookup.
*
* Drop-in replacement for the reverse-engineered Morse Expert decoder:
* same processBuffer(FloatArray) entry point, same StateFlow outputs.
*/
class CwFldigiDecoder(
val sampleRate: Int = CwFldigiConstants.CW_SAMPLERATE,
private val frequency: Double = 600.0,
private val useSom: Boolean = true,
private val initialWpm: Int = CwFldigiConstants.DEFAULT_SPEED
) {
private companion object {
const val CW_DOT = '.'
const val CW_DASH = '-'
const val CW_RESET = 0
const val CW_KEYDOWN = 1
const val CW_KEYUP = 2
const val CW_QUERY = 3
const val CW_SUCCESS = 0
const val CW_ERROR = -1
// Auto-tune (spectral peak tracking, mirrors the old channelTracker)
const val TUNE_FFT_SIZE = 512
const val TUNE_MIN_FREQ = 300.0
const val TUNE_MAX_FREQ = 1500.0
const val TUNE_ENERGY_FRACTION = 0.35
}
private enum class CwRxState { IDLE, IN_TONE, AFTER_TONE }
// Outputs (API-compatible with the old CwDecoder)
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(frequency.toFloat())
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
// --- auto-tune state (spectral peak tracking) ---
private var tuneFreq = frequency
private val tuneBuffer = DoubleArray(TUNE_FFT_SIZE)
private var tuneIdx = 0
private var tuneFft = CwGfft(TUNE_FFT_SIZE)
private var tunePeakFreq = frequency
private var tuneHasPeak = false
private var tuneLocked = false
private val tuneWindow = DoubleArray(TUNE_FFT_SIZE)
// --- fldigi cw state ---
private var phaseacc = 0.0
private var FFTphase = 0.0
private var FFTvalue = 0.0
private var smpl_ctr = 0
private var agc_peak = 1.0
private var noise_floor = 1.0
private var sig_avg = 0.0
private var metric = 0.0
private var siglevel = 0.0
private var upper_threshold = CwFldigiConstants.CW_UPPER_THRESHOLD
private var lower_threshold = CwFldigiConstants.CW_LOWER_THRESHOLD
private val cw_FFT_filter: CwFftFilt
private val bitfilter: CwMovAvg
private val trackingfilter: CwMovAvg
private var cw_receive_state = CwRxState.IDLE
private var old_cw_receive_state = CwRxState.IDLE
private var cw_rr_start_timestamp = 0
private var cw_rr_end_timestamp = 0
private var rx_rep_buf = StringBuilder()
private var cw_ptr = 0
private val cw_buffer = FloatArray(CwFldigiConstants.MAX_MORSE_ELEMENTS + 1)
private var cw_speed = initialWpm
private var cw_send_speed = initialWpm
private var cw_receive_speed = initialWpm
private var cw_bandwidth = CwFldigiConstants.DEFAULT_BANDWIDTH
private var cwTrack = true
private var two_dots = 0L
private var last_element = 0
private var space_sent = true
private var cw_noise_spike_threshold = 0L
private var cw_receive_dot_length = 0L
private var cw_receive_dash_length = 0L
private var synchscope = 50
init {
val bw = cw_bandwidth.toDouble() / sampleRate
cw_FFT_filter = CwFftFilt(bw, CwFldigiConstants.CW_FFT_SIZE)
val bfv = (symbollen() / (2 * CwFldigiConstants.DEC_RATIO)).coerceAtLeast(1)
bitfilter = CwMovAvg(bfv)
trackingfilter = CwMovAvg(CwFldigiConstants.TRACKING_FILTER_SIZE)
two_dots = (2 * CwFldigiConstants.KWPM / cw_speed).toLong()
cw_noise_spike_threshold = two_dots / 4
cw_receive_dot_length = (CwFldigiConstants.KWPM / cw_receive_speed).toLong()
cw_receive_dash_length = 3 * cw_receive_dot_length
}
private fun symbollen(): Int =
(sampleRate * 1.2 / cw_speed).roundToInt()
private fun usecDiff(earlier: Int, later: Int): Int =
if (earlier >= later) 0 else later - earlier
/** Main entry: feed PCM samples. */
fun processBuffer(buffer: FloatArray) {
for (sample in buffer) {
feedTuner(sample.toDouble())
rxSample(sample.toDouble())
}
}
/**
* Spectral peak tracking (auto-tune). Collects raw samples, runs an FFT
* every TUNE_FFT_SIZE samples, finds the strongest peak in the CW range
* and smoothly steers tuneFreq toward it — mirroring the old
* channelTracker behaviour so off-tune signals still decode.
*/
private fun feedTuner(sample: Double) {
tuneBuffer[tuneIdx++] = sample
if (tuneIdx < TUNE_FFT_SIZE) return
tuneIdx = 0
// Hann window
for (i in 0 until TUNE_FFT_SIZE) {
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (TUNE_FFT_SIZE - 1))
tuneWindow[i] = tuneBuffer[i] * w
}
val data = Array(TUNE_FFT_SIZE) { CwComplex(tuneWindow[it], 0.0) }
tuneFft.forward(data)
// Find the strongest bin in the CW range (300..1500 Hz)
val binMin = (TUNE_MIN_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
val binMax = (TUNE_MAX_FREQ * TUNE_FFT_SIZE / sampleRate).toInt()
var bestBin = -1
var bestMag = 0.0
var totalMag = 0.0
for (b in binMin..binMax) {
val mag = data[b].abs()
totalMag += mag
if (mag > bestMag) {
bestMag = mag
bestBin = b
}
}
if (bestBin < 0) return
val meanMag = totalMag / (binMax - binMin + 1)
if (bestMag < meanMag * 2.0) return // no clear tone
// Absolute floor: silence or weak noise must not steer the NCO.
// A 0.6-amplitude tone in a 512-pt Hann FFT yields peak ≈ 150;
// anything below ~30 is noise/DC leakage.
if (bestMag < 30.0) return
val peakFreq = bestBin * sampleRate.toDouble() / TUNE_FFT_SIZE
tunePeakFreq = peakFreq
tuneHasPeak = true
_estimatedPitch.value = peakFreq.toFloat()
// Lock fast: a strong peak on the very first frame is reliable enough
// (CW tones are narrow and dominate the band). Steer immediately so
// the first character still decodes. Do NOT reset the RX state on the
// first lock — the AGC adapts in a few frames and a reset would wipe
// the element that triggered the tune.
if (!tuneLocked) {
tuneFreq = peakFreq
tuneLocked = true
_estimatedPitch.value = peakFreq.toFloat()
} else {
// Smooth tracking; retune instantly on big jumps (signal switched freq)
val diff = peakFreq - tuneFreq
if (kotlin.math.abs(diff) > 120.0) {
tuneFreq = peakFreq
resetRxState()
} else {
tuneFreq += diff * 0.2
}
}
}
/** Reset only the fldigi RX state machine (keep decoded text). */
private fun resetRxState() {
cw_receive_state = CwRxState.IDLE
old_cw_receive_state = CwRxState.IDLE
smpl_ctr = 0
cw_ptr = 0
rx_rep_buf.clear()
last_element = 0
space_sent = true
FFTphase = 0.0
phaseacc = 0.0
}
private fun rxSample(value: Double) {
// NCO down-conversion (fldigi rx_FFTprocess). tuneFreq tracks the
// strongest spectral peak so off-tune signals still decode.
val z = CwComplex(
value * kotlin.math.cos(FFTphase),
value * kotlin.math.sin(FFTphase)
)
FFTphase += 2.0 * Math.PI * tuneFreq / sampleRate
if (FFTphase > 2.0 * Math.PI) FFTphase -= 2.0 * Math.PI
val out = cw_FFT_filter.run(z) ?: return
for (i in 0 until CwFldigiConstants.CW_FFT_SIZE / 2) {
++smpl_ctr
if (smpl_ctr % CwFldigiConstants.DEC_RATIO != 0) continue
FFTvalue = out[i].abs()
FFTvalue = bitfilter.run(FFTvalue)
decodeStream(FFTvalue)
}
}
/**
* AGC + hysteresis detection (fldigi cw::decode_stream).
*/
private fun decodeStream(value: Double) {
var v = value
val attack: Int
val decay: Int
when (CwFldigiConstants.CWRX_ATTACK_DEFAULT) {
0 -> attack = 400
2 -> attack = 100
else -> attack = 200
}
when (CwFldigiConstants.CWRX_DECAY_DEFAULT) {
0 -> decay = 2000
2 -> decay = 500
else -> decay = 1000
}
sig_avg = decayAvg(sig_avg, v, decay)
if (v < sig_avg) {
noise_floor = if (v < noise_floor) decayAvg(noise_floor, v, attack)
else decayAvg(noise_floor, v, decay)
}
if (v > sig_avg) {
agc_peak = if (v > agc_peak) decayAvg(agc_peak, v, attack)
else decayAvg(agc_peak, v, decay)
}
val normNoise = noise_floor / agc_peak
val normSig = sig_avg / agc_peak
siglevel = normSig
if (agc_peak != 0.0) v /= agc_peak else v = 0.0
metric = 0.8 * metric
if ((noise_floor > 1e-4) && (noise_floor < sig_avg)) {
val db = 20.0 * log10(sig_avg / noise_floor)
metric += 0.2 * clamp(2.5 * db, 0.0, 100.0)
}
val diff = normSig - normNoise
upper_threshold = normSig - 0.2 * diff
lower_threshold = normNoise + 0.7 * diff
// Squelch gate (fldigi: !progStatus.sqlonoff || metric > sldrSquelchValue).
// Default sqlonoff=true, sldrSquelchValue=5.0. Without this gate, noise
// spikes false-trigger KEYDOWN and lock the state machine in IN_TONE.
if (sqlonoff && metric <= squelchValue) return
// Power detection using hysteresis
if ((v > upper_threshold) && (cw_receive_state != CwRxState.IN_TONE)) {
handleEvent(CW_KEYDOWN)
}
if ((v < lower_threshold) && (cw_receive_state == CwRxState.IN_TONE)) {
handleEvent(CW_KEYUP)
}
if (handleEvent(CW_QUERY) == CW_SUCCESS) {
synchscope = 100
// emit decoded char(s)
} else if (--synchscope == 0) {
synchscope = 25
}
}
private var sqlonoff = true
private var squelchValue = 5.0
private fun decayAvg(avg: Double, value: Double, timeConst: Int): Double =
avg + (value - avg) / timeConst
private fun clamp(v: Double, lo: Double, hi: Double): Double =
if (v < lo) lo else if (v > hi) hi else v
private fun handleEvent(event: Int): Int {
val sc = StringBuilder()
var elementUsec: Int
when (event) {
CW_RESET -> {
syncParameters()
cw_receive_state = CwRxState.IDLE
cw_ptr = 0
smpl_ctr = 0
rx_rep_buf.clear()
}
CW_KEYDOWN -> {
if (cw_receive_state == CwRxState.IN_TONE) return CW_ERROR
if (cw_receive_state == CwRxState.IDLE) {
smpl_ctr = 0
rx_rep_buf.clear()
cw_ptr = 0
}
cw_rr_start_timestamp = smpl_ctr
old_cw_receive_state = cw_receive_state
cw_receive_state = CwRxState.IN_TONE
return CW_ERROR
}
CW_KEYUP -> {
if (cw_receive_state != CwRxState.IN_TONE) return CW_ERROR
cw_rr_end_timestamp = smpl_ctr
elementUsec = usecDiff(cw_rr_start_timestamp, cw_rr_end_timestamp)
syncParameters()
if (cw_noise_spike_threshold > 0 && elementUsec < cw_noise_spike_threshold) {
cw_receive_state = CwRxState.IDLE
return CW_ERROR
}
// adaptive speed tracking on dot-dash / dash-dot pairs
if (last_element > 0) {
if ((elementUsec > 2 * last_element) && (elementUsec < 4 * last_element)) {
updateTracking(last_element, elementUsec)
}
if ((last_element > 2 * elementUsec) && (last_element < 4 * elementUsec)) {
updateTracking(elementUsec, last_element)
}
}
last_element = elementUsec
if (elementUsec <= two_dots) {
rx_rep_buf.append(CW_DOT)
cw_buffer[cw_ptr++] = last_element.toFloat()
} else {
rx_rep_buf.append(CW_DASH)
cw_buffer[cw_ptr++] = last_element.toFloat()
}
if (rx_rep_buf.length > CwFldigiConstants.MAX_MORSE_ELEMENTS) {
cw_receive_state = CwRxState.IDLE
cw_ptr = 0
smpl_ctr = 0
return CW_ERROR
} else {
if (cw_ptr < cw_buffer.size) cw_buffer[cw_ptr] = 0.0f
}
cw_receive_state = CwRxState.AFTER_TONE
return CW_ERROR
}
CW_QUERY -> {
if (cw_receive_state == CwRxState.IN_TONE) return CW_ERROR
syncParameters()
elementUsec = usecDiff(cw_rr_end_timestamp, smpl_ctr)
if (elementUsec < (2 * cw_receive_dot_length)) return CW_ERROR
if (elementUsec >= (2 * cw_receive_dot_length) &&
elementUsec <= (4 * cw_receive_dot_length) &&
cw_receive_state == CwRxState.AFTER_TONE
) {
val pattern = rx_rep_buf.toString()
val decoded = if (useSom) {
findWinner(cw_buffer, two_dots)
} else {
MorseTable.rxLookup(pattern)
}
if (decoded.isNotEmpty()) {
appendDecoded(decoded)
}
rx_rep_buf.clear()
cw_receive_state = CwRxState.IDLE
space_sent = false
cw_ptr = 0
return CW_SUCCESS
}
if ((elementUsec > (4 * cw_receive_dot_length)) && !space_sent) {
appendDecoded(" ")
space_sent = true
return CW_SUCCESS
}
return CW_ERROR
}
}
return CW_ERROR
}
private fun appendDecoded(text: String) {
val current = _decodedTextFlow.value
_decodedTextFlow.value = if (current.length > 2000) {
current.drop(current.length - 1500) + text
} else {
current + text
}
_signalStrength.value = metric.toFloat() / 100f
_estimatedSpeed.value = (CwFldigiConstants.KWPM.toDouble() / (two_dots / 2.0)).toFloat()
}
private fun syncParameters() {
if (cwTrack) {
cw_receive_speed = (CwFldigiConstants.KWPM / (two_dots / 2)).toInt()
} else {
cw_receive_speed = cw_send_speed
two_dots = 2 * (CwFldigiConstants.KWPM / cw_send_speed).toLong()
}
if (cw_receive_speed > 0) {
cw_receive_dot_length = (CwFldigiConstants.KWPM / cw_receive_speed).toLong()
} else {
cw_receive_dot_length = (CwFldigiConstants.KWPM / 5).toLong()
}
cw_receive_dash_length = 3 * cw_receive_dot_length
cw_noise_spike_threshold = cw_receive_dot_length / 2
}
private fun updateTracking(dur1: Int, dur2: Int) {
val minDot = CwFldigiConstants.KWPM / 200
val maxDash = 3 * CwFldigiConstants.KWPM / 5
if ((dur1 > dur2) && (dur1 > 4 * dur2)) return
if ((dur2 > dur1) && (dur2 > 4 * dur1)) return
if (dur1 < minDot || dur2 < minDot) return
if (dur2 > maxDash || dur2 > maxDash) return
two_dots = trackingfilter.run(((dur1 + dur2) / 2).toDouble()).toLong()
syncParameters()
}
/**
* SOM codebook matching (fldigi cw::find_winner + normalize).
*/
private fun findWinner(inbuf: FloatArray, twodots: Long): String {
if (normalize(inbuf, twodots) == 0) return " "
var winner = -1
var bestDiff = Double.MAX_VALUE
for ((idx, entry) in SomTable.table.withIndex()) {
var difference = 0.0
for (i in 0 until CwFldigiConstants.WGT_SIZE) {
val diff = inbuf[i] - entry.weights[i]
difference += diff * diff
if (difference > bestDiff) break
}
if (difference < bestDiff) {
winner = idx
bestDiff = difference
}
}
if (winner >= 0 && SomTable.table[winner].pattern.isNotEmpty()) {
val sc = MorseTable.rxLookup(SomTable.table[winner].pattern)
return if (sc.isNotEmpty()) sc else CwFldigiConstants.DEFAULT_NOISE_CHAR.toString()
}
return CwFldigiConstants.DEFAULT_NOISE_CHAR.toString()
}
private fun normalize(v: FloatArray, twodots: Long): Int {
var max = v[0]
var min = v[0]
for (j in 1 until CwFldigiConstants.WGT_SIZE) {
if (v[j] > max) max = v[j]
else if (v[j] < min) min = v[j]
}
if (max == 0.0f) return 0
val ratio = if (max > twodots) 1.0f else 0.33f
val scale = ratio / max
for (j in 0 until CwFldigiConstants.WGT_SIZE) v[j] *= scale
return 1
}
/** Reset the decoder state (fldigi cw::rx_init + reset). */
fun resetDecoder() {
cw_receive_state = CwRxState.IDLE
smpl_ctr = 0
cw_ptr = 0
rx_rep_buf.clear()
last_element = 0
space_sent = true
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedSpeed.value = null
FFTphase = 0.0
phaseacc = 0.0
// reset auto-tune
tuneIdx = 0
tunePeakFreq = frequency
tuneHasPeak = false
tuneLocked = false
tuneFreq = frequency
}
}
@@ -0,0 +1,183 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Constants and primitives ported from fldigi (w1hkj/fldigi),
* GPL v3, cw.h / cw.cxx / filters.cxx / fftfilt.cxx.
*
* Faithful port: identifiers and formulas mirror the C++ original so
* behaviour matches the desktop decoder bit-for-bit.
*/
internal object CwFldigiConstants {
const val CW_SAMPLERATE = 8000
const val DEC_RATIO = 16
const val CW_FFT_SIZE = 2048 // must be power of 2
const val MAX_MORSE_ELEMENTS = 6
const val WGT_SIZE = 7
const val TRACKING_FILTER_SIZE = 16
const val MAX_PIPE_SIZE = (22 * CW_SAMPLERATE * 12 / 800)
const val CLRCOUNT = 16
const val CW_MAX_SPEED = 50
const val INITIAL_SEND_SPEED = 18
const val INITIAL_RECEIVE_SPEED = 18
/** # samples in a dot: KWPM / WPM. fldigi: KWPM = 12 * samplerate / 10 */
const val KWPM = 12 * CW_SAMPLERATE / 10
const val CW_SUCCESS = 0
const val CW_ERROR = -1
// Defaults mirroring fldigi progdefaults
const val DEFAULT_SPEED = 18 // WPM
const val DEFAULT_BANDWIDTH = 150 // Hz
const val DEFAULT_RANGE = 10 // WPM tracking range
const val LOWER_LIMIT = 5 // WPM
const val UPPER_LIMIT = 50 // WPM
const val DEFAULT_NOISE_CHAR = '*'
const val CWRX_ATTACK_DEFAULT = 1 // medium
const val CWRX_DECAY_DEFAULT = 1 // medium
const val CW_LOWER_THRESHOLD = 0.4
const val CW_UPPER_THRESHOLD = 0.6
}
/**
* Moving average filter, ported from fldigi Cmovavg (filters.cxx).
*/
internal class CwMovAvg(private var len: Int) {
private val buf = DoubleArray(MAX_MOVAVG)
private var out = 0.0
private var pint = 0
private var empty = true
fun run(a: Double): Double {
if (empty) {
empty = false
out = 0.0
for (i in 0 until len) {
buf[i] = a
out += a
}
pint = 0
return a
}
out = out - buf[pint] + a
buf[pint] = a
if (++pint >= len) pint = 0
return out / len
}
fun setLength(newLen: Int) {
len = newLen.coerceAtMost(MAX_MOVAVG).coerceAtLeast(1)
empty = true
}
fun reset() {
empty = true
}
fun value(): Double = if (len > 0) out / len else 0.0
private companion object {
const val MAX_MOVAVG = 2048
}
}
/**
* Complex sample used by the FFT filter chain.
*/
internal data class CwComplex(var re: Double = 0.0, var im: Double = 0.0) {
operator fun timesAssign(other: CwComplex) {
val r = re * other.re - im * other.im
val i = re * other.im + im * other.re
re = r; im = i
}
fun abs(): Double = kotlin.math.sqrt(re * re + im * im)
}
/**
* In-place radix-2 complex FFT, ported from fldigi gfft.h semantics
* (ComplexFFT / InverseComplexFFT, forward = -1).
*/
internal class CwGfft(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val rev = IntArray(n)
init {
val log2n = Integer.numberOfTrailingZeros(n)
for (i in 0 until n) {
var r = 0
var x = i
for (bit in 0 until log2n) {
r = (r shl 1) or (x and 1)
x = x shr 1
}
rev[i] = r
}
}
/**
* In-place complex FFT.
* @param data array of CwComplex, size n
* @param inverse if true, performs inverse transform (no 1/n scaling; caller divides)
*/
fun transform(data: Array<CwComplex>, inverse: Boolean) {
val sign = if (inverse) 1 else -1
// bit reversal permutation
for (i in 0 until n) {
val j = rev[i]
if (j > i) {
val t = data[i]; data[i] = data[j]; data[j] = t
}
}
var len = 2
while (len <= n) {
val ang = sign * 2.0 * Math.PI / len
val wRe = Math.cos(ang)
val wIm = Math.sin(ang)
var i = 0
while (i < n) {
var curRe = 1.0
var curIm = 0.0
for (j in 0 until len / 2) {
val u = data[i + j]
val v = data[i + j + len / 2]
val tRe = curRe * v.re - curIm * v.im
val tIm = curRe * v.im + curIm * v.re
data[i + j] = CwComplex(u.re + tRe, u.im + tIm)
data[i + j + len / 2] = CwComplex(u.re - tRe, u.im - tIm)
val nRe = curRe * wRe - curIm * wIm
val nIm = curRe * wIm + curIm * wRe
curRe = nRe; curIm = nIm
}
i += len
}
len = len shl 1
}
}
fun forward(data: Array<CwComplex>) = transform(data, false)
fun inverse(data: Array<CwComplex>) = transform(data, true)
}
@@ -0,0 +1,140 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Morse code lookup table, ported from fldigi morse.cxx (cMorse::cw_table).
* Each entry: [enabled, displayChar, prosignDisplay, dotDashPattern].
*/
internal object MorseTable {
private data class Entry(
val enabled: Boolean,
val chr: String,
val prt: String,
val rpr: String
)
private val table = listOf(
// Prosigns
Entry(true, "=", "<BT>", "-...-"),
Entry(false, "~", "<AA>", ".-.-"),
Entry(true, "<", "<AS>", ".-..."),
Entry(true, ">", "<AR>", ".-.-."),
Entry(true, "%", "<SK>", "...-.-"),
Entry(true, "+", "<KN>", "-.--."),
Entry(true, "&", "<INT>", "..-.-"),
Entry(true, "{", "<HM>", "....--"),
Entry(true, "}", "<VE>", "...-."),
// ASCII 7-bit letters
Entry(true, "A", "A", ".-"),
Entry(true, "B", "B", "-..."),
Entry(true, "C", "C", "-.-."),
Entry(true, "D", "D", "-.."),
Entry(true, "E", "E", "."),
Entry(true, "F", "F", "..-."),
Entry(true, "G", "G", "--."),
Entry(true, "H", "H", "...."),
Entry(true, "I", "I", ".."),
Entry(true, "J", "J", ".---"),
Entry(true, "K", "K", "-.-"),
Entry(true, "L", "L", ".-.."),
Entry(true, "M", "M", "--"),
Entry(true, "N", "N", "-."),
Entry(true, "O", "O", "---"),
Entry(true, "P", "P", ".--."),
Entry(true, "Q", "Q", "--.-"),
Entry(true, "R", "R", ".-."),
Entry(true, "S", "S", "..."),
Entry(true, "T", "T", "-"),
Entry(true, "U", "U", "..-"),
Entry(true, "V", "V", "...-"),
Entry(true, "W", "W", ".--"),
Entry(true, "X", "X", "-..-"),
Entry(true, "Y", "Y", "-.--"),
Entry(true, "Z", "Z", "--.."),
// lowercase map to uppercase
Entry(true, "a", "A", ".-"), Entry(true, "b", "B", "-..."),
Entry(true, "c", "C", "-.-."), Entry(true, "d", "D", "-.."),
Entry(true, "e", "E", "."), Entry(true, "f", "F", "..-."),
Entry(true, "g", "G", "--."), Entry(true, "h", "H", "...."),
Entry(true, "i", "I", ".."), Entry(true, "j", "J", ".---"),
Entry(true, "k", "K", "-.-"), Entry(true, "l", "L", ".-.."),
Entry(true, "m", "M", "--"), Entry(true, "n", "N", "-."),
Entry(true, "o", "O", "---"), Entry(true, "p", "P", ".--."),
Entry(true, "q", "Q", "--.-"), Entry(true, "r", "R", ".-."),
Entry(true, "s", "S", "..."), Entry(true, "t", "T", "-"),
Entry(true, "u", "U", "..-"), Entry(true, "v", "V", "...-"),
Entry(true, "w", "W", ".--"), Entry(true, "x", "X", "-..-"),
Entry(true, "y", "Y", "-.--"), Entry(true, "z", "Z", "--.."),
// Numerals
Entry(true, "0", "0", "-----"),
Entry(true, "1", "1", ".----"),
Entry(true, "2", "2", "..---"),
Entry(true, "3", "3", "...--"),
Entry(true, "4", "4", "....-"),
Entry(true, "5", "5", "....."),
Entry(true, "6", "6", "-...."),
Entry(true, "7", "7", "--..."),
Entry(true, "8", "8", "---.."),
Entry(true, "9", "9", "----."),
// Punctuation
Entry(true, "\\", "\\", ".-..-."),
Entry(true, "'", "'", ".----."),
Entry(true, "$", "$", "...-..-"),
Entry(true, "(", "(", "-.--."),
Entry(true, ")", ")", "-.--.-"),
Entry(true, ",", ",", "--..--"),
Entry(true, "-", "-", "-....-"),
Entry(true, ".", ".", ".-.-.-"),
Entry(true, "/", "/", "-..-."),
Entry(true, ":", ":", "---..."),
Entry(true, ";", ";", "-.-.-."),
Entry(true, "?", "?", "..--.."),
Entry(true, "_", "_", "..--.-"),
Entry(true, "@", "@", ".--.-."),
Entry(true, "!", "!", "-.-.--"),
// Accented (disabled by default in fldigi)
Entry(false, "Ä", "Ä", ".-.-"), Entry(false, "ä", "Ä", ".-.-"),
Entry(false, "Æ", "Æ", ".-.-"), Entry(false, "æ", "Æ", ".-.-"),
Entry(false, "Å", "Å", ".--.-"), Entry(false, "å", "Å", ".--.-"),
Entry(false, "Ç", "Ç", "-.-.."), Entry(false, "ç", "Ç", "-.-.."),
Entry(false, "È", "È", ".-..-"), Entry(false, "è", "È", ".-..-"),
Entry(false, "É", "É", "..-.."), Entry(false, "é", "É", "..-.."),
Entry(false, "Ó", "Ó", "---."), Entry(false, "ó", "Ó", "---."),
Entry(false, "Ö", "Ö", "---."), Entry(false, "ö", "Ö", "---."),
Entry(false, "Ø", "Ø", "---."), Entry(false, "ø", "Ø", "---."),
Entry(false, "Ñ", "Ñ", "--.--"), Entry(false, "ñ", "Ñ", "--.--"),
Entry(false, "Ü", "Ü", "..--"), Entry(false, "ü", "Ü", "..--"),
Entry(false, "Û", "Û", "..--"), Entry(false, "û", "Û", "..--")
)
/**
* Look up the display representation of a dot-dash pattern.
* Returns "" when the pattern is not in the table or is disabled.
* Mirror of cMorse::rx_lookup.
*/
fun rxLookup(pattern: String): String {
for (e in table) {
if (pattern == e.rpr) {
if (e.enabled) return e.prt
}
}
return ""
}
}
@@ -0,0 +1,104 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Self-Organizing Map codebook for CW character matching, ported from
* fldigi cw.cxx (cw::som_table). Each row is a dot/dash weight vector
* (0 = element absent, 0.33 = dot, 1.0 = dash).
*/
internal object SomTable {
data class SomEntry(val pattern: String, val weights: FloatArray)
val table: List<SomEntry> = listOf(
// Prosigns
SomEntry("-...-", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f)),
SomEntry(".-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)),
SomEntry(".-...", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
SomEntry(".-.-.", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
SomEntry("...-.-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 1.0f, 0f)),
SomEntry("-.--.", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
SomEntry("..-.-", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f)),
SomEntry("....--", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 1.0f, 0f)),
SomEntry("...-.", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
// ASCII 7-bit letters
SomEntry(".-", floatArrayOf(0.33f, 1.0f, 0f, 0f, 0f, 0f, 0f)),
SomEntry("-...", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f, 0f)),
SomEntry("-.-.", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 0f, 0f, 0f)),
SomEntry("-..", floatArrayOf(1.0f, 0.33f, 0.33f, 0f, 0f, 0f, 0f)),
SomEntry(".", floatArrayOf(0.33f, 0f, 0f, 0f, 0f, 0f, 0f)),
SomEntry("..-.", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f, 0f)),
SomEntry("--.", floatArrayOf(1.0f, 1.0f, 0.33f, 0f, 0f, 0f, 0f)),
SomEntry("....", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f, 0f)),
SomEntry("..", floatArrayOf(0.33f, 0.33f, 0f, 0f, 0f, 0f, 0f)),
SomEntry(".---", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 0f, 0f, 0f)),
SomEntry("-.-", floatArrayOf(1.0f, 0.33f, 1.0f, 0f, 0f, 0f, 0f)),
SomEntry(".-..", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f, 0f)),
SomEntry("--", floatArrayOf(1.0f, 1.0f, 0f, 0f, 0f, 0f, 0f)),
SomEntry("-.", floatArrayOf(1.0f, 0.33f, 0f, 0f, 0f, 0f, 0f)),
SomEntry("---", floatArrayOf(1.0f, 1.0f, 1.0f, 0f, 0f, 0f, 0f)),
SomEntry(".--.", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f, 0f)),
SomEntry("--.-", floatArrayOf(1.0f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)),
SomEntry(".-.", floatArrayOf(0.33f, 1.0f, 0.33f, 0f, 0f, 0f, 0f)),
SomEntry("...", floatArrayOf(0.33f, 0.33f, 0.33f, 0f, 0f, 0f, 0f)),
SomEntry("-", floatArrayOf(1.0f, 0f, 0f, 0f, 0f, 0f, 0f)),
SomEntry("..-", floatArrayOf(0.33f, 0.33f, 1.0f, 0f, 0f, 0f, 0f)),
SomEntry("...-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f, 0f)),
SomEntry(".--", floatArrayOf(0.33f, 1.0f, 1.0f, 0f, 0f, 0f, 0f)),
SomEntry("-..-", floatArrayOf(1.0f, 0.33f, 0.33f, 1.0f, 0f, 0f, 0f)),
SomEntry("-.--", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0f, 0f, 0f)),
SomEntry("--..", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 0f, 0f, 0f)),
// Numerals
SomEntry("-----", floatArrayOf(1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
SomEntry(".----", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
SomEntry("..---", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
SomEntry("...--", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 1.0f, 0f, 0f)),
SomEntry("....-", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f)),
SomEntry(".....", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
SomEntry("-....", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
SomEntry("--...", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
SomEntry("---..", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0.33f, 0f, 0f)),
SomEntry("----.", floatArrayOf(1.0f, 1.0f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
// Punctuation
SomEntry(".-..-.", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 1.0f, 0.33f, 0f)),
SomEntry(".----.", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 1.0f, 0.33f, 0f)),
SomEntry("...-..-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 0.33f, 1.0f)),
SomEntry("-.---.", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
SomEntry("-.--.-", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f)),
SomEntry("--..--", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 1.0f, 1.0f, 0f)),
SomEntry("-....-", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 0f)),
SomEntry(".-.-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 0f)),
SomEntry("-..-.", floatArrayOf(1.0f, 0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
SomEntry("---...", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0.33f, 0.33f, 0f)),
SomEntry("-.-.-.", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 0f)),
SomEntry("..--..", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0.33f, 0.33f, 0f)),
SomEntry("..--.-", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f)),
SomEntry(".--.-.", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0.33f, 0f)),
SomEntry("-.-.--", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 1.0f, 0f)),
// Accented
SomEntry(".-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)), // A umlaut, A aelig
SomEntry(".--.-", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f, 0f)), // A ring
SomEntry("-.-..", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f)), // C cedilla
SomEntry(".-..-", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 1.0f, 0f, 0f)), // E grave
SomEntry("..-..", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f)), // E acute
SomEntry("---.", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0f, 0f, 0f)), // O acute, O umlaut, O slash
SomEntry("--.--", floatArrayOf(1.0f, 1.0f, 0.33f, 1.0f, 1.0f, 0f, 0f)), // N tilde
SomEntry("..--", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0f, 0f, 0f)) // U umlaut, U circ
)
}
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 6 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -62,7 +62,18 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0
val highElevation: Double = 45.0,
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
val hiddenScreens: List<String> = emptyList(),
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
val screenOrder: List<String> = emptyList(),
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
val subMenuOrder: List<String> = emptyList(),
// WaveLog logging (4.5.2): server config
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
val wavelogAutoUpload: Boolean = false
)
data class DataSourcesSettings(
@@ -0,0 +1,71 @@
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object QrzGridClient {
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
if (text.startsWith("[")) {
return try {
val arr = org.json.JSONArray(text)
val parts = mutableListOf<String>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val name = o.optString("name")
val value = o.optString("value")
if (name.isNotBlank()) parts.add("$name=$value")
}
parts.joinToString("; ")
} catch (_: Exception) { text }
}
return text
}
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Detail table Grid Square row (verified format)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) { null }
}
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -30,6 +30,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -42,6 +43,11 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
// WaveLog logging (4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
}
data class MutualPassData(
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -45,7 +45,8 @@ interface ISettingsRepo {
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
suspend fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
@@ -22,4 +22,7 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
/** Fetch AMSAT status page HTML (with UA; null = failure) */
suspend fun getStatusHtml(): String?
}
@@ -18,6 +18,10 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast {
operator fun invoke(message: String)
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
}
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
* with an offset applied to the downlink (in Hz).
* Returns null if the transponder is not a linear passband type.
*
* The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink.
*/
fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long,
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
offsetHz: Long
): Long? {
if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
return orbitalPos.getUplinkFreq(baseUplink)
}
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
return upLow != null && upHigh != null && downLow != null && downHigh != null
&& upLow != upHigh && downLow != downHigh
}
/**
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for the named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
}
}
@@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
* The returned position is the center of the finest cell encoded by the locator:
* - 6 char: 5' lon x 2.5' lat cell center
* - 8 char: 30" lon x 15" lat cell center
* - 10 char: 1.25" lon x 0.625" lat cell center
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6)
val trimmedQth = locator.trim().uppercase()
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
var longitude = lonFirst + lonSecond + lonThird - 180
var latitude = latFirst + latSecond + latThird - 90
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
if (trimmedQth.length >= 8) {
longitude += trimmedQth[6].toString().toInt() / 120.0
latitude += trimmedQth[7].toString().toInt() / 240.0
}
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
if (trimmedQth.length >= 10) {
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
}
// Offset to the center of the finest encoded cell
when (trimmedQth.length) {
8 -> {
longitude += 1.0 / 240.0
latitude += 1.0 / 480.0
}
10 -> {
longitude += 1.0 / 5760.0
latitude += 1.0 / 11520.0
}
else -> {
longitude += 1.0 / 24.0
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(6), longitude.round(6))
}
fun positionToQth(latitude: Double, longitude: Double): String? {
/**
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
* matching common 8-char grid square tools. Pass precision = 6 for the classic
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
val newLongitude = longitude + 180
val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude % 20) / 2).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
if (precision < 8) return qth
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
val qth8 = "$qth$lonFourth$latFourth"
if (precision < 10) return qth8
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
return "$qth8$lonFifth$latFifth"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
@@ -51,5 +98,49 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
}
/**
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
* A 4-char input is returned as-is when valid.
*/
fun qthToSquare(locator: String): String {
val upper = locator.trim().uppercase()
return when {
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
else -> "----"
}
}
/**
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH Locator app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
val result = mutableListOf<String>()
for (dLat in 1 downTo -1) { // north -> south
for (dLon in -1..1) { // west -> east
var lf = lonField
var tf = latField
var ls = lonSquare + dLon
var ts = latSquare + dLat
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
lf = (lf + 18) % 18
tf = (tf + 18) % 18
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
}
}
return result
}
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
@@ -0,0 +1,15 @@
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
*/
package com.rtbishop.look4sat.core.domain.wavelog
interface ILotwSatellitesRepo {
sealed class RefreshResult {
data class Ok(val count: Int) : RefreshResult()
data class Error(val message: String) : RefreshResult()
}
fun restore()
suspend fun refresh(): RefreshResult
}
@@ -0,0 +1,22 @@
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
*/
package com.rtbishop.look4sat.core.domain.wavelog
object LotwSatellites {
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
@Volatile
private var dynamicNames: Set<String> = emptySet()
/** Effective satellite names: static embedded set union runtime updates */
val names: Set<String>
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
fun updateNames(newNames: Set<String>) {
if (newNames.isNotEmpty()) dynamicNames = newNames
}
}
@@ -0,0 +1,328 @@
/*
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
*
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
* Strategy: try v2 first, auto-fallback to v1 on 404.
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
val id: Int,
val name: String,
val callsign: String,
val gridsquare: String
)
sealed class WavelogResult {
data class Success(val message: String) : WavelogResult()
data class Failure(val message: String) : WavelogResult()
}
object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
return u
}
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// v1 attempt without index.php
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
if (code in 200..299) {
return@withContext try {
val obj = JSONObject(resp)
val data = obj.optJSONObject("data") ?: obj
val station = WavelogStation(
id = data.optInt("id"),
name = data.optString("name"),
callsign = data.optString("callsign"),
gridsquare = data.optString("gridsquare")
)
WavelogResult.Success(JSONObject().apply {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
} catch (e: Exception) {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
}
/**
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
* that fails to parse an illegal band falls back to a default such as
* 160m. Satellite QSOs must carry the real band of the TX frequency.
*/
fun bandFromHz(freqHz: Long): String = when {
freqHz >= 1240_000_000 -> "23CM"
freqHz >= 902_000_000 -> "33CM"
freqHz >= 420_000_000 -> "70CM"
freqHz >= 222_000_000 -> "1.25M"
freqHz >= 144_000_000 -> "2M"
freqHz >= 50_000_000 -> "6M"
freqHz >= 28_000_000 -> "10M"
freqHz >= 24_890_000 -> "12M"
freqHz >= 21_000_000 -> "15M"
freqHz >= 18_068_000 -> "17M"
freqHz >= 14_000_000 -> "20M"
freqHz >= 10_000_000 -> "30M"
freqHz >= 7_000_000 -> "40M"
freqHz >= 5_102_000 -> "60M"
freqHz >= 3_500_000 -> "80M"
freqHz >= 1_800_000 -> "160M"
else -> "160M"
}
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
private fun bandLetter(freqHz: Long): String = when {
freqHz >= 1_240_000_000 -> "S"
freqHz >= 420_000_000 -> "U"
freqHz >= 144_000_000 -> "V"
else -> "V"
}
/**
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
*/
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
if (rxFreqHz <= 0) return ""
val up = bandLetter(txFreqHz)
val down = bandLetter(rxFreqHz)
return if (up == down) "" else "$up/$down"
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
val commonName = mapOf(
"ZARYA" to "ARISS",
"ARISS" to "ARISS",
"FUNCUBE-1" to "AO-73",
"DIWATA-2B" to "PO-101",
"SAUDISAT-1C" to "SO-50",
"SAUDISAT 1C" to "SO-50",
"DIWATA-2A" to "PO-101"
)
val candidate = commonName[main] ?: main
return if (candidate in LotwSatellites.names) candidate else main
}
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
url: String,
apiKey: String,
stationProfileId: String,
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
put("gridsquare", gridsquare)
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
if (satMode.isNotBlank()) put("sat_mode", satMode)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
put("type", "adif")
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
// v1 without index.php
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
}
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
return buildString {
append(field("call", qso.call))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
}
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
append(field("rst_sent", "59"))
append(field("rst_rcvd", "59"))
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
append(field("prop_mode", "SAT"))
}
append("<eor>")
}
}
/** Generic HTTP request (returns code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
}
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
} catch (_: Exception) {
body.take(120)
}
}
private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
}
@@ -0,0 +1,108 @@
/*
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
WavelogQso(
id = o.getString("id"),
timeUtcMs = o.getLong("timeUtcMs"),
call = o.optString("call"),
mode = o.optString("mode"),
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
sessionId = o.optString("sessionId"),
gridsquare = o.optString("gridsquare"),
uploaded = o.optBoolean("uploaded", false)
)
}
} catch (_: Exception) { emptyList() }
}
@Synchronized
fun add(qso: WavelogQso) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
@Synchronized
fun remove(id: String) {
save(all().filter { it.id != id })
}
@Synchronized
fun removeAll(ids: Set<String>) {
save(all().filter { it.id !in ids })
}
/** Mark as uploaded (kept in the queue; checkmark in the table) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
}
private fun save(list: List<WavelogQso>) {
val arr = JSONArray()
list.forEach { q ->
arr.put(JSONObject().apply {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
store.save(arr.toString())
}
}
@@ -0,0 +1,92 @@
/*
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
*
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
* -> POST /api/v2/qso -> success removes from the queue.
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
* retrying the batch with force=true once confirmed.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
data class Done(
val successCount: Int,
val failedCount: Int,
val message: String,
val firstError: String = ""
) : UploadOutcome()
}
class WavelogUploader(
private val settingsRepo: ISettingsRepo,
private val queue: WavelogQueue
) {
// Station grid cache (refreshed before each upload; stale value kept on failure)
private var cachedStationGrid: String? = null
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
val apiKey = settings.wavelogApiKey
val stationId = settings.wavelogStationId
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
if (!force) {
val userGrid = userQthGrid()
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
}
}
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
val entries = queue.all()
var ok = 0
var fail = 0
var firstError = ""
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
if (result is WavelogResult.Success) {
ok++
queue.markUploaded(qso.id)
} else {
fail++
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
}
}
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message, firstError)
}
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) {
return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid }
}
return cachedStationGrid
}
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
}
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L,
downHigh: Long? = 435_500_000L,
inverted: Boolean = false
inverted: Boolean = false,
info: String = "Linear Transponder",
downlinkMode: String? = "USB",
uplinkMode: String? = "LSB"
) = SatRadio(
uuid = "linear", info = "Linear Transponder", isAlive = true,
uuid = "linear", info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
)
private fun fmTransponder() = SatRadio(
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
}
@Test
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
}
@Test
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
}
@Test
fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder()
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(435_202_500L, downlink)
}
@Test
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = 435_202_500L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = 2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeOffsetRoundTrip_handlesNegativeOffset() {
val xpdr = linearTransponder()
val orbitalPos = pos(0.0)
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
uplinkHz = 145_200_000L,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(435_197_500L, downlink)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
downlinkHz = downlink!!,
transponder = xpdr,
orbitalPos = orbitalPos,
offsetHz = -2_500L
)
assertEquals(145_200_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_invertedTransponder() {
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
@@ -18,7 +18,9 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -26,21 +28,36 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
}
@Test
@@ -48,4 +65,61 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
}
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
)
positions.forEach { (lat, lon) ->
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
}
}
@@ -0,0 +1,180 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
/**
* Tests for the fldigi-ported CW decoder. Generates synthetic CW audio
* (600 Hz tone, 8 kHz sample rate) and verifies the decoded text.
*/
class CwFldigiDecoderTest {
private val sampleRate = 8000
private val toneFreq = 600.0
/** Synthesize a CW audio buffer for a dot-dash pattern at given WPM. */
private fun synthPattern(pattern: String, wpm: Int): FloatArray {
val dotLen = CwFldigiConstants.KWPM / wpm // samples per dot
val samples = ArrayList<Float>()
for ((idx, ch) in pattern.withIndex()) {
val len = if (ch == '.') dotLen else 3 * dotLen
for (i in 0 until len) {
val t = i.toDouble() / sampleRate
samples.add((0.6 * sin(2.0 * PI * toneFreq * t)).toFloat())
}
if (idx < pattern.length - 1) {
for (i in 0 until dotLen) samples.add(0f) // intra-char gap
}
}
return samples.toFloatArray()
}
/** Append an inter-character gap (3 dot lengths) of silence. */
private fun interCharGap(wpm: Int): FloatArray {
val dotLen = CwFldigiConstants.KWPM / wpm
return FloatArray(3 * dotLen)
}
private fun decodeSequence(chars: List<String>, wpm: Int, useSom: Boolean = true): String {
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq, useSom = useSom)
val dotLen = CwFldigiConstants.KWPM / wpm
for ((ci, pattern) in chars.withIndex()) {
decoder.processBuffer(synthPattern(pattern, wpm))
decoder.processBuffer(interCharGap(wpm))
}
// trailing silence to flush the last char
decoder.processBuffer(FloatArray(8 * dotLen))
return decoder.decodedTextFlow.value
}
@Test
fun decode_basicLetter_singleDot() {
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq)
decoder.processBuffer(synthPattern(".", 18))
decoder.processBuffer(FloatArray(8 * CwFldigiConstants.KWPM / 18))
assertTrue("expected E in output, got: ${decoder.decodedTextFlow.value}",
decoder.decodedTextFlow.value.startsWith("E"))
}
@Test
fun decode_word_CQ_18wpm() {
val text = decodeSequence(listOf("-.-.", "--.-"), 18)
assertTrue("expected CQ in output, got: $text", text.contains("C") && text.contains("Q"))
}
@Test
fun decode_hello_20wpm() {
val text = decodeSequence(listOf("....", ".", ".-..", ".-..", "---"), 20)
assertTrue("expected HELLO in output, got: $text", text.contains("H"))
}
@Test
fun decode_adaptive_speed_30wpm() {
// Realistic usage: decoder configured near the signal speed, tracking
// fine-tunes from there. Verify correct decode at 30 wpm.
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq, initialWpm = 30)
val dotLen = CwFldigiConstants.KWPM / 30
for (pattern in listOf(".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---")) {
decoder.processBuffer(synthPattern(pattern, 30))
decoder.processBuffer(interCharGap(30))
}
decoder.processBuffer(FloatArray(8 * dotLen))
val text = decoder.decodedTextFlow.value
// First char may be off while tracking converges; rest must be exact.
assertTrue("expected B-J at 30wpm, got: $text", text.contains("B") && text.contains("J"))
}
@Test
fun reset_clearsState() {
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq)
decoder.processBuffer(synthPattern(".", 18))
decoder.processBuffer(FloatArray(8 * CwFldigiConstants.KWPM / 18))
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun morseTable_lookup() {
assertEquals("A", MorseTable.rxLookup(".-"))
assertEquals("S", MorseTable.rxLookup("..."))
assertEquals("1", MorseTable.rxLookup(".----"))
assertEquals("?", MorseTable.rxLookup("..--.."))
assertEquals("", MorseTable.rxLookup("........"))
}
@Test
fun somTable_hasAllAsciiLetters() {
val patterns = SomTable.table.map { it.pattern }.toSet()
val letters = listOf(".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....",
"..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.",
"...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..")
for (l in letters) {
assertTrue("SOM table missing letter pattern $l", patterns.contains(l))
}
}
@Test
fun fftFilter_lowpass_passesTone() {
val filt = CwFftFilt(600.0 / 8000.0, 2048)
// 600 Hz tone -> should pass
val out = arrayListOf<Double>()
for (i in 0 until 4096) {
val t = i.toDouble() / 8000.0
val z = CwComplex(0.5 * sin(2.0 * PI * 600.0 * t), 0.0)
filt.run(z)?.let { o -> for (j in 0 until 1024) out.add(o[j].abs()) }
}
val maxVal = out.maxOrNull() ?: 0.0
assertTrue("expected signal to pass through lowpass, max=$maxVal", maxVal > 0.05)
}
@Test
fun autoTune_decodesOffFreqSignal() {
// Signal at 900 Hz but decoder initialized at 600 Hz: auto-tune must
// steer the NCO to the real tone, otherwise nothing decodes.
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = 600.0)
val dotLen = CwFldigiConstants.KWPM / 18
val synth = { ch: Char ->
val len = if (ch == '.') dotLen else 3 * dotLen
FloatArray(len) { i ->
val t = i.toDouble() / sampleRate
(0.6 * sin(2.0 * PI * 900.0 * t)).toFloat()
}
}
// "CQ" at 900 Hz: -.-. --.-
val text = StringBuilder()
val seq = listOf(listOf('-', '.', '-', '.'), listOf('-', '-', '.', '-'))
for ((ci, word) in seq.withIndex()) {
for ((i, ch) in word.withIndex()) {
decoder.processBuffer(synth(ch))
if (i < word.size - 1) decoder.processBuffer(FloatArray(dotLen))
}
if (ci < seq.size - 1) decoder.processBuffer(FloatArray(3 * dotLen))
}
decoder.processBuffer(FloatArray(6 * dotLen))
val result = decoder.decodedTextFlow.value
assertTrue("expected CQ from 900 Hz signal (auto-tune), got: $result", result.contains("C") && result.contains("Q"))
// pitch should have moved toward 900 Hz
val pitch = decoder.estimatedPitch.value ?: 0f
assertTrue("expected pitch near 900, got $pitch", pitch > 800f && pitch < 1000f)
}
}
@@ -0,0 +1,92 @@
package com.rtbishop.look4sat.core.domain.wavelog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
/**
* Verifies the WaveLog upload payload frequency/band fields.
*
* Regression: user reported QSOs landing in the 160m band. The v2 JSON
* envelope must carry freq as a MHz string with an "M" suffix so WaveLog's
* parse_frequency() reads it as Hz internally; a bare integer or bare MHz
* value corrupts band derivation.
*/
class WaveLogApiPayloadTest {
// SO-50: uplink 145.850 MHz, downlink 436.795 MHz
private val uplinkHz = 145_850_000L
private val downlinkHz = 436_795_000L
@Test
fun v2_freq_usesMhzStringWithMSuffix() {
val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0)
val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0)
assertEquals("145.850000M", freq)
assertEquals("436.795000M", freqRx)
// WaveLog parse_frequency: "145.850000M" -> 145850000 Hz
val parsedHz = parseLikeWaveLog(freq)
assertEquals(uplinkHz, parsedHz)
}
@Test
fun v1_adif_freq_isBareMhzNumber() {
// v1 ADIF <FREQ> is a bare MHz number per ADIF spec (no unit suffix)
val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0)
assertEquals("145.850000", freq)
val adifFreq = freq.toDouble() * 1_000_000
assertEquals(uplinkHz.toDouble(), adifFreq, 1.0)
}
/** Mirrors WaveLog Logbook_model::parse_frequency: int = Hz, "12.3M" suffix = MHz. */
private fun parseLikeWaveLog(raw: String): Long {
val s = raw.trim()
return if (s.endsWith("M", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000_000).toLong()
} else if (s.endsWith("k", ignoreCase = true)) {
(s.dropLast(1).toDouble() * 1_000).toLong()
} else {
s.toLong()
}
}
@Test
fun qsoFreqs_stayInSatelliteBands_afterDoppler() {
// Doppler-corrected values must remain near the base frequency.
// SPEED_OF_LIGHT = 299792458 m/s; distanceRate is km/s (x1000 -> m/s).
val dopplerRate = 7.0 // km/s approaching
val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0
assertTrue(
"corrected within +-20kHz, got ${corrected - uplinkHz} Hz",
kotlin.math.abs(corrected - uplinkHz) < 20_000
)
// band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz)
val mhz = corrected / 1_000_000.0
assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0)
}
@Test
fun band_isRealBand_notSAT() {
// SO-50: TX 145.850 MHz (VHF) -> band 2M, sat mode V/U
assertEquals("2M", WaveLogApi.bandFromHz(145_850_000))
assertEquals("V/U", WaveLogApi.satModeFrom(145_850_000, 436_795_000))
// AO-73: TX 435.150 MHz (UHF up), RX 145.950 MHz (VHF down) -> band 70CM, U/V
assertEquals("70CM", WaveLogApi.bandFromHz(435_150_000))
assertEquals("U/V", WaveLogApi.satModeFrom(435_150_000, 145_950_000))
// Same-band (e.g. simplex) -> empty sat mode
assertEquals("", WaveLogApi.satModeFrom(145_850_000, 145_950_000))
// Never 160m for satellite frequencies
assertTrue(WaveLogApi.bandFromHz(145_850_000) != "160M")
assertTrue(WaveLogApi.bandFromHz(436_795_000) != "160M")
}
@Test
fun adif_containsRealBandAndSatMode() {
// v2 payload fields (mirror postQso construction)
val band = WaveLogApi.bandFromHz(uplinkHz)
val satMode = WaveLogApi.satModeFrom(uplinkHz, downlinkHz)
assertEquals("2M", band)
assertEquals("V/U", satMode)
}
}
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---"
val mhz = frequencyHz / 1_000_000
val khz = (frequencyHz % 1_000_000) / 1_000
val hz = frequencyHz % 1_000
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
}
@@ -21,27 +21,45 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
@Serializable
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
@Serializable
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@Serializable
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
}
// UI settings: default main-menu page order (5 bottom-bar slots)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI settings: default More-menu order (pages behind "More")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
@Serializable
data object RadarDestination : NavKey
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Morse style icon: a dot above a dash -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
</vector>
@@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
</vector>
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
<path
android:fillColor="@android:color/white"
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
</vector>
@@ -0,0 +1,288 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal)
* Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses)
* WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query)
* WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan)
* WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2)
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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">Donasi</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_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/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">Kebijakan Privasi</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -0,0 +1,288 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal)
* Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses)
* WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query)
* WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan)
* WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2)
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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">Donasi</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_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/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">Kebijakan Privasi</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -10,8 +10,54 @@
<string name="nav_sat">Uydular</string>
<string name="nav_pass">Geçişler</string>
<string name="nav_radar">Radar</string>
<string name="nav_cw">CW Dekoder</string>
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
<string name="prefs_aprs_title">APRS Raporlama</string>
<string name="prefs_aprs_enable">APRS raporlamayı etkinleştir</string>
<string name="prefs_aprs_summary">Sunucu: %1$s:%2$d Çağrı: %3$s</string>
<string name="prefs_aprs_report_now">Şimdi Raporla</string>
<string name="prefs_aprs_settings_title">APRS Ayarları</string>
<string name="prefs_aprs_server">Sunucu</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Çağrı işareti</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Parola</string>
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
<string name="prefs_aprs_interval">Aralık (dakika)</string>
<string name="prefs_aprs_status">Durum metni</string>
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
<string name="prefs_aprs_symbol_code">Sembol kodu</string>
<string name="prefs_aprs_save">Kaydet</string>
<string name="prefs_aprs_cancel">İptal</string>
<string name="aprs_notif_channel">APRS hizmeti</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Raporlama çalışıyor</string>
<string name="aprs_notif_connected">APRS-IS bağlı</string>
<string name="aprs_notif_error">Bağlantı hatası</string>
<string name="aprs_notif_stop">Durdur</string>
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Uydu Durumu</string>
<string name="amsat_refresh">Yenile</string>
<string name="amsat_retry">Tekrar dene</string>
<string name="amsat_updated">Güncellendi:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/işaret</string>
<string name="amsat_not_heard">Duyulmadı</string>
<string name="amsat_conflict">Çelişkili</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
<string name="amsat_close">Kapat</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
@@ -51,11 +97,11 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
* YENİ: AMSAT uydu durumu resmi AMSAT API kullanıyor (v1 JSON, daha hızlı ve güvenilir)
* AMSAT durum sayfası tam ekran cihazlara uyumlu (yenileme düğmesi ve saat her zaman erişilebilir)
* WaveLog: QRZ karşı grid sorgulama (⚙ diyalogda Cookie yapıştır + test sorgusu)
* WaveLog: LoTW uydu listesi güncelleme düğmesi (112 uydu yerleşik)
* WaveLog: RST 59/59 sinyal raporu (v1 ve v2)
</string>
<!-- Radar screen -->
@@ -103,7 +149,7 @@
<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_title" translatable="false">Look4Sat Pro 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>
@@ -121,7 +167,7 @@
<string name="prefs_loc_title">İstasyon konumu</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
<string name="prefs_loc_input_title">Manuel giriş</string>
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
<string name="prefs_loc_qth_title">QTH</string>
@@ -137,15 +183,17 @@
<string name="prefs_data_entries">Uydular: %s</string>
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
<string name="prefs_data_update">Güncelle</string>
<string name="prefs_data_import">İçe aktar</string>
<string name="prefs_data_import">Özel URL</string>
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_net_output">Ağ</string>
@@ -180,7 +228,52 @@
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="tracking_status">İzleniyor: %s</string>
<string name="loc_lat_text">Enlem: %s°</string>
<string name="loc_lon_text">Boylam: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Bağlan</string>
<string name="disconnect_radio">Bağlantıyı Kes</string>
<string name="track_radio">Takip</string>
<string name="stop_radio">Dur</string>
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
<string name="prefs_wavelog_key_hint">API anahtarı</string>
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_update_sats">Uydu güncelle</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_select_transponder">Transponder seç</string>
<string name="wavelog_call_hint">Çağrı işareti</string>
<string name="wavelog_mode_hint">Mod</string>
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">QRZ giriş çerezlerinizi buraya yapıştırın (EditThisCookie dışa aktarımı veya "k=v; k=v" biçimi). Yükleme için karşı istasyonun grid karesini getirmekte kullanılır.</string>
<string name="prefs_wavelog_qrz_label">Çerez</string>
<string name="wavelog_copy">Kopyala</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="wavelog_col_time">Zaman</string>
<string name="wavelog_col_freq">Frekans</string>
<string name="wavelog_col_sat">Uydu</string>
<string name="wavelog_col_call">Çağrı</string>
<string name="wavelog_col_uploaded">Gönderildi</string>
<string name="wavelog_ungrouped">Gruplanmamış</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string>
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
<string name="prefs_ui_sub_title">Daha fazla menü</string>
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
@@ -192,14 +285,16 @@
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
\n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -10,8 +10,55 @@
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_roaming">漫游</string>
<string name="nav_cw">CW解码</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<string name="prefs_aprs_title">APRS 上报</string>
<string name="prefs_aprs_enable">启用 APRS 上报</string>
<string name="prefs_aprs_summary">服务器: %1$s:%2$d 呼号: %3$s</string>
<string name="prefs_aprs_report_now">立即上报</string>
<string name="prefs_aprs_settings_title">APRS 设置</string>
<string name="prefs_aprs_server">服务器</string>
<string name="prefs_aprs_port">端口</string>
<string name="prefs_aprs_callsign">呼号</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">验证码</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
<string name="prefs_aprs_interval">周期(分钟)</string>
<string name="prefs_aprs_status">状态文本</string>
<string name="prefs_aprs_symbol_table">符号表</string>
<string name="prefs_aprs_symbol_code">符号码</string>
<string name="prefs_aprs_save">保存</string>
<string name="prefs_aprs_cancel">取消</string>
<string name="aprs_notif_channel">APRS 服务</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">上报运行中</string>
<string name="aprs_notif_connected">已连接 APRS-IS</string>
<string name="aprs_notif_error">连接错误</string>
<string name="aprs_notif_stop">停止</string>
<string name="prefs_aprs_calc_passcode">计算验证码</string>
<string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string>
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">仅遥测</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">矛盾</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
<string name="amsat_no_reports">该时段无报告</string>
<string name="amsat_close">关闭</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
@@ -38,6 +85,14 @@
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 新: AMSAT 卫星状态改用官方 AMSAT API(v1 JSON,更快更稳)
* AMSAT 状态页适配全面屏(刷新按钮和更新时间始终可点)
* WaveLog: QRZ 对方网格查询(⚙ 弹窗粘贴 Cookie + 测试查询)
* WaveLog: LoTW 卫星列表更新按钮(内置 112 颗卫星)
* WaveLog: 信号报告 59/59(v1 和 v2)
</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -92,28 +147,29 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
<string name="prefs_loc_input_title">输入位置</string>
<string name="prefs_loc_input_error">输入的位置无效</string>
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_import">自定义URL</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
@@ -155,7 +211,52 @@
<string name="rc_rx_name_hint">接收电台名称</string>
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="tracking_status">跟踪: %s</string>
<string name="loc_lat_text">纬度: %s°</string>
<string name="loc_lon_text">经度: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">连接</string>
<string name="disconnect_radio">断开</string>
<string name="track_radio">跟踪</string>
<string name="stop_radio">停止</string>
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
<string name="prefs_wavelog_url_hint">服务器地址</string>
<string name="prefs_wavelog_key_hint">API 密钥</string>
<string name="prefs_wavelog_station_hint">站点 ID</string>
<string name="prefs_wavelog_auto">自动上传</string>
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_update_sats">更新卫星列表</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_saved">已存入本地日志</string>
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
<string name="wavelog_empty">暂无日志记录</string>
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">在此粘贴 QRZ 登录 Cookie(EditThisCookie 导出 或 "k=v; k=v" 格式)。用于上传时查询对方网格。</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">复制</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_col_time">时间</string>
<string name="wavelog_col_freq">频率</string>
<string name="wavelog_col_sat">卫星</string>
<string name="wavelog_col_call">呼号</string>
<string name="wavelog_col_uploaded">已传</string>
<string name="wavelog_ungrouped">未分组</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string>
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
<string name="prefs_ui_sub_title">更多菜单</string>
<string name="prefs_ui_move_out">移入更多菜单</string>
<string name="prefs_ui_move_back">移回主菜单</string>
<string name="prefs_ui_order_locked">固定(不可移动)</string>
<string name="prefs_ui_order_reset">重置顺序</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
@@ -167,7 +268,7 @@
<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">BG7NTA 与原作者感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
@@ -175,7 +276,9 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string>
\n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
@@ -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 Pro</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -11,8 +11,55 @@
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Match</string>
<string name="nav_roaming">Roaming</string>
<string name="nav_cw">CW Decoder</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="prefs_aprs_title">APRS Reporting</string>
<string name="prefs_aprs_enable">Enable APRS reporting</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Callsign: %3$s</string>
<string name="prefs_aprs_report_now">Report Now</string>
<string name="prefs_aprs_settings_title">APRS Settings</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Callsign</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Passcode</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
<string name="prefs_aprs_interval">Interval (min)</string>
<string name="prefs_aprs_status">Status text</string>
<string name="prefs_aprs_symbol_table">Symbol table</string>
<string name="prefs_aprs_symbol_code">Symbol code</string>
<string name="prefs_aprs_save">Save</string>
<string name="prefs_aprs_cancel">Cancel</string>
<string name="aprs_notif_channel">APRS service</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Reporting running</string>
<string name="aprs_notif_connected">Connected to APRS-IS</string>
<string name="aprs_notif_error">Connection error</string>
<string name="aprs_notif_stop">Stop</string>
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
<string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">failed - %1$s</string>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
@@ -52,8 +99,11 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* NEW: AMSAT satellite status now uses the official AMSAT API (v1 JSON, faster and more reliable)
* AMSAT status page fits edge-to-edge screens (refresh button and update time always reachable)
* WaveLog: QRZ counterpart grid lookup (gear dialog > paste QRZ cookies > test query)
* WaveLog: LoTW satellite list refresh button (112 satellites built in)
* WaveLog: RST 59/59 signal reports for QSOs (v1 and v2)
</string>
<!-- Radar screen -->
@@ -111,7 +161,7 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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>
@@ -129,7 +179,7 @@
<string name="prefs_loc_title">Station position</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Check your location permissions</string>
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Invalid location entered</string>
<string name="prefs_loc_qth_title">QTH</string>
@@ -145,10 +195,11 @@
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
<string name="prefs_data_update">Update</string>
<string name="prefs_data_import">Import</string>
<string name="prefs_data_import">Custom URL</string>
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_update_failed">Update failed - check your network</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
@@ -156,6 +207,7 @@
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Reset to default URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -190,7 +242,52 @@
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="tracking_status">Tracking: %s</string>
<string name="loc_lat_text">Lat: %s°</string>
<string name="loc_lon_text">Lon: %s°</string>
<string name="loc_qth_text">Qth: %s</string>
<string name="connect_radio">Connect</string>
<string name="disconnect_radio">Disconnect</string>
<string name="track_radio">Track</string>
<string name="stop_radio">Stop</string>
<string name="prefs_wavelog_title">WaveLog log server</string>
<string name="prefs_wavelog_url_hint">Server address</string>
<string name="prefs_wavelog_key_hint">API key</string>
<string name="prefs_wavelog_station_hint">Station ID</string>
<string name="prefs_wavelog_auto">Auto upload</string>
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_update_sats">Update sats</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Saved to local log</string>
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
<string name="wavelog_empty">No log entries yet</string>
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Paste your QRZ login cookies here (EditThisCookie export or "k=v; k=v" string). Used to fetch the other station grid square for uploads.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Copy</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_col_time">Time</string>
<string name="wavelog_col_freq">Freq</string>
<string name="wavelog_col_sat">Satellite</string>
<string name="wavelog_col_call">Call</string>
<string name="wavelog_col_uploaded">Sent</string>
<string name="wavelog_ungrouped">Ungrouped</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string>
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
<string name="prefs_ui_sub_title">More menu</string>
<string name="prefs_ui_move_out">Move to more menu</string>
<string name="prefs_ui_move_back">Move back to main menu</string>
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
<string name="prefs_ui_order_reset">Reset order</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_sweep">Enable radar sweep animation</string>
@@ -202,7 +299,7 @@
<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">BG7NTA &amp; the original author would like to thank</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -211,6 +308,8 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
+26
View File
@@ -0,0 +1,26 @@
import com.android.build.api.variant.CanProduceConsumerProguardFiles
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.cw"
compileOptions {
encoding = "UTF-8"
}
}
// R8 consumer rules (kept for compatibility; the fldigi port has no native code)
androidComponents {
onVariants(selector().all()) { variant ->
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
project.layout.projectDirectory.file("proguard-rules.pro")
)
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:data"))
}
+31
View File
@@ -0,0 +1,31 @@
# Look4Sat Pro — CW 解码模块(照搬 Morse Expert 1.15)混淆规则
# 所有 CW 类保持原名: pas.* 通过 RegisterNatives 按类名注册 JNI(混淆即崩);
# 其余为照搬的混淆名类(命名已无混淆意义, 保持以保逻辑完整)。
# JNI 引擎(RegisterNatives 按类名/方法名查表)
-keep class pas.** { *; }
# 照搬的解码/UI 类(全部保持)
-keep class com.ve3nea.morse_expert.** { *; }
-keep class H2.** { *; }
-keep class I2.b { *; }
-keep class J2.** { *; }
-keep class k3.** { *; }
-keep class i3.** { *; }
-keep class g3.** { *; }
-keep class j3.** { *; }
-keep class s.** { *; }
-keep class d1.AbstractC1518b { *; }
-keep class B0.b { *; }
-keep class B.RunnableC0001b { *; }
-keep class D.n { *; }
-keep class E2.g { *; }
-keep class F2.a { *; }
-keep class I0.d { *; }
-keep class K1.a { *; }
-keep class j1.C1646n { *; }
# sun.misc stub(Android 无此类, 仅编译期; 防止 R8 异常处理)
-dontwarn sun.misc.**
-keep class sun.misc.Unsafe { *; }
-keep class sun.misc.Cleaner { *; }
@@ -0,0 +1,501 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.cw.CwFldigiDecoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* CW decode page — pure Compose, fldigi decoding engine.
*
* Layout mirrors the DeepCW web decoder (waterfall -> live line -> history)
* inside the existing container; top-bar actions (back/settings) and bottom
* actions (start-pause/clear) keep the previous page's interaction model.
* The old Morse Expert (ve3nea) controller and layouts are fully removed.
*/
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val decoder = remember { CwFldigiDecoder(initialWpm = 18) }
val waterfall = remember { CwWaterfallState() }
var permissionGranted by remember {
mutableStateOf(
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var captureJob by remember { mutableStateOf<Job?>(null) }
val decodedText by decoder.decodedTextFlow.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val estimatedSpeed by decoder.estimatedSpeed.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
permanentlyDenied = !granted && (
(context as? android.app.Activity)?.shouldShowRequestPermissionRationale(
Manifest.permission.RECORD_AUDIO
) ?: false
).not()
}
fun startCapture() {
if (!permissionGranted) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return
}
captureJob?.cancel()
captureJob = scope.launch(Dispatchers.IO) {
val capture = CwMicCapture()
capture.audioFlow().collect { chunk ->
if (!isActive) return@collect
decoder.processBuffer(chunk)
waterfall.pushSamples(chunk)
}
}
isListening = true
}
fun stopCapture() {
captureJob?.cancel()
captureJob = null
isListening = false
}
DisposableEffect(Unit) {
onDispose { stopCapture() }
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
) {
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { stopCapture(); navigateUp() }) {
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_back), contentDescription = "Back")
}
Text(
text = "CW Decoder",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { showSettings = true }) {
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_cw), contentDescription = "Settings")
}
}
// Waterfall
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(8.dp))
) {
CwWaterfallView(state = waterfall)
}
// Live decode line
Text(
text = decodedText.takeLast(80),
fontSize = 18.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
)
// History
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = decodedText,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
// Status cards
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusCard("DECODER", if (isListening) "ACTIVE" else "IDLE",
if (isListening) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline,
Modifier.weight(1f))
StatusCard("SPEED", if (estimatedSpeed != null) "${estimatedSpeed?.roundToInt()} WPM" else "--",
MaterialTheme.colorScheme.primary, Modifier.weight(1f))
StatusCard("LEVEL", "${(signalStrength * 100).roundToInt()}%",
MaterialTheme.colorScheme.tertiary, Modifier.weight(1f))
}
// Bottom actions
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { if (isListening) stopCapture() else startCapture() },
modifier = Modifier.weight(1f)
) {
Icon(
painter = if (isListening) androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_close)
else androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_arrow),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.size(4.dp))
Text(if (isListening) "Pause" else "Start")
}
Button(
onClick = { decoder.resetDecoder(); waterfall.reset() },
modifier = Modifier.weight(1f)
) {
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_delete), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(4.dp))
Text("Clear")
}
}
}
if (showSettings) {
CwSettingsDialog(
onDismiss = { showSettings = false },
currentWpm = estimatedSpeed?.roundToInt() ?: 18
)
}
if (!permissionGranted) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x99000000)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Microphone permission is required for CW decoding",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(Modifier.height(12.dp))
Button(onClick = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) {
Text("Grant permission")
}
if (permanentlyDenied) {
TextButton(onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${context.packageName}")
)
)
}) {
Text("Open app settings")
}
}
}
}
}
}
@Composable
private fun StatusCard(label: String, value: String, accent: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.border(1.dp, accent.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = accent
)
}
}
/**
* Microphone capture at 8000 Hz mono PCM float (fldigi sample rate).
*/
private class CwMicCapture {
private val sampleRate = 8000
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
fun audioFlow(): kotlinx.coroutines.flow.Flow<FloatArray> =
kotlinx.coroutines.flow.flow {
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate / 5)
val recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize * 4
)
try {
recorder.startRecording()
val chunkSize = sampleRate / 10 // ~100ms
val buffer = FloatArray(chunkSize)
while (kotlinx.coroutines.currentCoroutineContext().isActive) {
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
if (read > 0) {
emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
}
} finally {
recorder.stop()
recorder.release()
}
}.flowOn(Dispatchers.IO)
}
// --- Waterfall (lightweight sliding spectrogram) ---
class CwWaterfallState {
private val columns = 160
private val rows = 64
private val data = FloatArray(columns * rows)
private var colCount = 0
private val pending = ArrayList<Float>(4096)
private val fftWindow = 512
/** Monotonic frame counter — reading this in composition triggers redraw. */
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
val version: androidx.compose.runtime.State<Int> = _version
@Synchronized
fun pushSamples(samples: FloatArray) {
pending.addAll(samples.toList())
var frames = 0
while (pending.size >= fftWindow) {
val window = FloatArray(fftWindow) { pending[it] }
repeat(fftWindow) { pending.removeAt(0) }
val spectrum = computeSpectrum(window)
// shift rows up, insert newest at bottom
for (r in 0 until rows - 1) {
System.arraycopy(data, (r + 1) * columns, data, r * columns, columns)
}
for (c in 0 until columns) {
val bin = c * spectrum.size / columns
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
}
if (colCount < rows) colCount++
frames++
}
if (frames > 0) _version.intValue++
}
/** Magnitude spectrum via radix-2 FFT on a Hann-windowed frame. */
private fun computeSpectrum(window: FloatArray): FloatArray {
val n = window.size
val re = FloatArray(n)
val im = FloatArray(n)
for (i in 0 until n) {
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (n - 1))
re[i] = window[i] * w.toFloat()
}
// iterative radix-2 FFT (n = 512)
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
val tr = re[i]; re[i] = re[j]; re[j] = tr
val ti = im[i]; im[i] = im[j]; im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * Math.PI / len
val wRe = kotlin.math.cos(ang).toFloat()
val wIm = kotlin.math.sin(ang).toFloat()
var i = 0
while (i < n) {
var curRe = 1f
var curIm = 0f
for (k in 0 until len / 2) {
val uRe = re[i + k]
val uIm = im[i + k]
val vRe = re[i + k + len / 2] * curRe - im[i + k + len / 2] * curIm
val vIm = re[i + k + len / 2] * curIm + im[i + k + len / 2] * curRe
re[i + k] = uRe + vRe
im[i + k] = uIm + vIm
re[i + k + len / 2] = uRe - vRe
im[i + k + len / 2] = uIm - vIm
val nRe = curRe * wRe - curIm * wIm
val nIm = curRe * wIm + curIm * wRe
curRe = nRe; curIm = nIm
}
i += len
}
len = len shl 1
}
val bins = 48
val out = FloatArray(bins)
for (k in 0 until bins) {
// bin k maps to k * 8000/512 ≈ 15.6 Hz steps; show 0..750 Hz region
out[k] = kotlin.math.sqrt(re[k] * re[k] + im[k] * im[k]) / n
}
return out
}
@Synchronized
fun getColumn(c: Int): FloatArray {
val col = FloatArray(rows)
for (r in 0 until rows) {
col[r] = data[r * columns + c.coerceIn(0, columns - 1)]
}
return col
}
@Synchronized
fun reset() {
data.fill(0f)
pending.clear()
colCount = 0
}
}
@Composable
private fun CwWaterfallView(state: CwWaterfallState) {
val columns = 160
val rows = 64
// Read the frame counter so the canvas redraws as new spectra arrive.
state.version.value
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0D1B2A))
.clip(RoundedCornerShape(8.dp))
) {
val cellW = size.width / columns
val cellH = size.height / rows
for (c in 0 until columns) {
val col = state.getColumn(c)
for (r in 0 until rows) {
val v = col[r]
// log-ish scaling: quiet bins stay dark, strong CW tones pop
val intensity = (kotlin.math.ln1p(v * 600f) * 55f).coerceIn(0f, 255f)
if (intensity > 4f) {
val green = (intensity * 1.25f).coerceAtMost(255f)
drawRect(
color = Color(0f, green / 255f, 0f, 1f),
topLeft = Offset(c * cellW, r * cellH),
size = Size(cellW + 0.5f, cellH + 0.5f)
)
}
}
}
}
}
@@ -0,0 +1,155 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* CW decoder settings (fldigi engine).
*
* Options ported from fldigi progdefaults: speed (WPM), SOM codebook
* decoding on/off, filter bandwidth. Persisted in SharedPreferences.
*/
@Composable
fun CwSettingsDialog(
onDismiss: () -> Unit,
currentWpm: Int,
onSpeedChange: (Int) -> Unit = {},
onSomChange: (Boolean) -> Unit = {},
onBandwidthChange: (Int) -> Unit = {}
) {
var wpm by remember { mutableStateOf(currentWpm.coerceIn(5, 50)) }
var useSom by remember { mutableStateOf(true) }
var bandwidth by remember { mutableStateOf(150) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("CW Settings") },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(top = 4.dp)
) {
Text("Speed (WPM)", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = wpm.toFloat(),
onValueChange = { wpm = it.roundToInt() },
valueRange = 5f..50f,
modifier = Modifier.weight(1f)
)
Text(
text = "$wpm",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.width(44.dp)
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Filter bandwidth (Hz)", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = bandwidth.toFloat(),
onValueChange = { bandwidth = it.roundToInt() },
valueRange = 50f..500f,
steps = 8,
modifier = Modifier.weight(1f)
)
Text(
text = "$bandwidth",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.width(44.dp)
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("SOM codebook decoding", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { useSom = true }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = useSom, onClick = { useSom = true })
Text("On (recommended, higher accuracy)")
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { useSom = false }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !useSom, onClick = { useSom = false })
Text("Off (plain table lookup)")
}
}
},
confirmButton = {
TextButton(onClick = {
onSpeedChange(wpm)
onSomChange(useSom)
onBandwidthChange(bandwidth)
onDismiss()
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Diam</string>
<string name="waterfall_display">Tampilan waterfall</string>
<string name="frequency_scale">Skala frekuensi</string>
<string name="decoded_text">Teks terdekode</string>
<string name="clear">Bersihkan</string>
<string name="pause">Jeda Dekode</string>
<string name="save_text">Simpan Teks</string>
<string name="record_signals">Rekam Sinyal</string>
<string name="settings">Pengaturan</string>
<string name="help">Bantuan Daring</string>
<string name="rate">Nilai Aplikasi Ini</string>
<string name="premium">Dapatkan Versi Premium</string>
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
<string name="message_type">Tipe Pesan</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
<string name="cw_grant_permission">Beri izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Diam</string>
<string name="waterfall_display">Tampilan waterfall</string>
<string name="frequency_scale">Skala frekuensi</string>
<string name="decoded_text">Teks terdekode</string>
<string name="clear">Bersihkan</string>
<string name="pause">Jeda Dekode</string>
<string name="save_text">Simpan Teks</string>
<string name="record_signals">Rekam Sinyal</string>
<string name="settings">Pengaturan</string>
<string name="help">Bantuan Daring</string>
<string name="rate">Nilai Aplikasi Ini</string>
<string name="premium">Dapatkan Versi Premium</string>
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
<string name="message_type">Tipe Pesan</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
<string name="cw_grant_permission">Beri izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Beklemede</string>
<string name="waterfall_display">Şelale görüntüsü</string>
<string name="frequency_scale">Frekans ölçeği</string>
<string name="decoded_text">Çözülen metin</string>
<string name="clear">Temizle</string>
<string name="pause">Çözmeyi Duraklat</string>
<string name="save_text">Metni Kaydet</string>
<string name="record_signals">Sinyalleri Kaydet</string>
<string name="settings">Ayarlar</string>
<string name="help">Çevrimiçi Yardım</string>
<string name="rate">Bu Uygulamayı Değerlendir</string>
<string name="premium">Premium Sürümü Al</string>
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
<string name="message_type">Mesaj Türü</string>
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">空闲</string>
<string name="waterfall_display">瀑布图</string>
<string name="frequency_scale">频率刻度</string>
<string name="decoded_text">解码文本</string>
<string name="clear">清除</string>
<string name="pause">暂停解码</string>
<string name="save_text">保存文本</string>
<string name="record_signals">录音信号</string>
<string name="settings">设置</string>
<string name="help">在线帮助</string>
<string name="rate">评价此应用</string>
<string name="premium">获取高级版</string>
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
<string name="message_type">消息类型</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_500">#ff6200ee</color>
<integer name="default_font_size">18</integer>
<string name="idle">Idle</string>
<string name="waterfall_display">Waterfall display</string>
<string name="frequency_scale">Frequency scale</string>
<string name="decoded_text">Decoded text</string>
<string name="clear">Clear</string>
<string name="pause">Pause Decoding</string>
<string name="save_text">Save Text</string>
<string name="record_signals">Record Signals</string>
<string name="settings">Settings</string>
<string name="help">Online Help</string>
<string name="rate">Rate this App</string>
<string name="premium">Get Premium Version</string>
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
<string name="message_type">Message Type</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
</resources>
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -57,6 +58,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
@@ -94,15 +96,20 @@ fun MutualScreen(
)
},
bottomInfo = {
if (state.mutualPasses.isNotEmpty()) {
Text(
text = "找到 ${state.mutualPasses.size} 个匹配",
text = mutualStatusText(state),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
endAction = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
@@ -111,6 +118,8 @@ fun MutualScreen(
strokeWidth = 2.dp
)
}
MutualStatusChip(state)
}
}
)
}
@@ -187,8 +196,6 @@ private fun MutualContent(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
MatchIntroCard()
if (isVertical) {
StationInputCard(
title = "你的位置",
@@ -304,25 +311,50 @@ private fun MutualContent(
}
@Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
private fun MutualStatusChip(state: MutualUiState) {
val colorScheme = MaterialTheme.colorScheme
val containerColor = when {
state.isCalculating -> colorScheme.primaryContainer
state.errorMessage != null -> colorScheme.errorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.primary
state.hasSearched -> colorScheme.surfaceVariant
else -> colorScheme.surfaceVariant
}
val contentColor = when {
state.isCalculating -> colorScheme.onPrimaryContainer
state.errorMessage != null -> colorScheme.onErrorContainer
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
else -> colorScheme.onSurfaceVariant
}
Surface(
color = containerColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.small
) {
Text(
text = "位置匹配",
style = MaterialTheme.typography.titleSmall,
text = mutualStatusChipText(state),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
maxLines = 1,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
}
private fun mutualStatusText(state: MutualUiState): String = when {
state.isCalculating -> "正在计算双方共同可见窗口…"
state.errorMessage != null -> "请修正提示后重新查询"
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
else -> "输入双方位置后点击查询"
}
private fun mutualStatusChipText(state: MutualUiState): String = when {
state.isCalculating -> "计算中"
state.errorMessage != null -> "有错误"
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
state.hasSearched -> "无匹配"
else -> "待查询"
}
@Composable
@@ -49,6 +49,7 @@ data class MutualUiState(
val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1,
val errorMessage: String? = null
)
@@ -167,7 +168,15 @@ class MutualViewModel(
return
}
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
_uiState.update {
it.copy(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
selectedPassIndex = -1
)
}
viewModelScope.launch {
val time = System.currentTimeMillis()
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@@ -162,7 +163,10 @@ class PassesViewModel(
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
}
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
@@ -171,7 +175,7 @@ class PassesViewModel(
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
@@ -180,7 +184,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
@@ -188,6 +192,16 @@ class PassesViewModel(
return result
}
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
return Calendar.getInstance(tz, Locale.getDefault()).apply {
timeInMillis = timeMillis
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = dateFormat(tz)
+3
View File
@@ -8,4 +8,7 @@ android {
dependencies {
implementation(project(":feature:mutual"))
// CW 解码面板: 复用 feature:cw 的 fldigi 解码引擎(纯 Compose 面板)
implementation(project(":feature:cw"))
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
@@ -0,0 +1,498 @@
/*
* LogTab.kt - "Log" tab of the radar page (4.5.2 WaveLog logging).
*
* Layout: transponder list (tap to select, watch frequency while typing) -> callsign input, Enter saves locally ->
* local records list below (time/freq/callsign). Records support custom swipe-to-delete:
* yellow trash icon shows while swiping; past 75% it becomes an undo key; not undone within 5 s means deleted.
*
* Enter only saves locally (WavelogQueue); upload is triggered by the 10-min cycle or manually (avoids mis-copied calls).
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.TimeZone
import java.util.UUID
import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogTab(
transceivers: List<SatRadio>,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
val scope = rememberCoroutineScope()
var selectedUuid by remember { mutableStateOf<String?>(null) }
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (!wavelogConfigured) {
Text(
text = stringResource(id = R.string.wavelog_need_config),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Transponder picker (dropdown): input area appears below once selected
// Selected item derived live from transceivers by uuid (Doppler freq updates every second, display refreshes with it)
var menuExpanded by remember { mutableStateOf(false) }
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
ExposedDropdownMenuBox(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it }
) {
OutlinedTextField(
value = selectedRadio?.info ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
transceivers.forEach { radio ->
DropdownMenuItem(
text = {
Text(
text = radio.info,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
selectedUuid = radio.uuid
menuExpanded = false
}
)
}
}
}
// After selecting a transponder: input area (live freq + callsign + mode)
selectedRadio?.let { radio ->
ExpandedLogInput(
radio = radio,
orbitalPos = orbitalPos,
satelliteName = satelliteName,
queue = queue,
showToast = showToast,
txBaseFrequencyHz = txBaseFrequencyHz,
aosTimeMs = aosTimeMs,
onSaved = { refreshTick++ }
)
}
// Local records list (time / freq / callsign)
Text(
text = stringResource(id = R.string.wavelog_saved),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
// Grouped by pass session (sessionId = satName-AOS timestamp), thick line between groups
entries.groupBy { it.sessionId.ifBlank { "un" } }.forEach { (sessionId, groupEntries) ->
// Group title + divider
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.primary)
Text(
text = if (sessionId == "un") stringResource(id = R.string.wavelog_ungrouped)
else sessionId.substringBeforeLast('-'),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
groupEntries.forEach { entry ->
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
refreshTick++
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = formatLocalTime(entry.timeUtcMs),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(64.dp)
)
Text(
text = formatFrequency(entry.freqTxHz),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(76.dp)
)
Text(
text = entry.call,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
}
/** Expanded transponder input: live freq + callsign (Enter saves locally) + mode override */
@Composable
private fun ExpandedLogInput(
radio: SatRadio,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
showToast: (String) -> Unit,
txBaseFrequencyHz: Long? = null,
aosTimeMs: Long = 0L,
onSaved: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val scope = rememberCoroutineScope()
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
val savedMsg = stringResource(id = R.string.wavelog_saved)
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val qsoId = UUID.randomUUID().toString()
queue.add(
WavelogQso(
id = qsoId,
timeUtcMs = System.currentTimeMillis(),
call = call,
mode = mode.trim().ifBlank { "FM" }.uppercase(),
freqTxHz = tx,
freqRxHz = rx,
satName = satelliteName,
sessionId = buildSessionId(satelliteName, aosTimeMs)
)
)
callsign = ""
onSaved()
showToast(savedMsg)
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
scope.launch {
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
val rawCookie = prefs.getString("cookie", "") ?: ""
if (rawCookie.isNotBlank()) {
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
)
if (grid != null) queue.updateGridsquare(qsoId, grid)
}
}
}
// Freq display matches the transponder bar: TX row + RX row (radio from per-second Doppler recompute, live)
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val upLow = radio.uplinkLow
val upHigh = radio.uplinkHigh
val downLow = radio.downlinkLow
val downHigh = radio.downlinkHigh
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
// TX row
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "TX: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${formatFrequency(txForDisplay)} MHz",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (isLinearRange) {
Text(
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// RX row (same as transponder bar RX)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "RX: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${formatFrequency(rxForDisplay)} MHz",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (downLow != null && downHigh != null && downLow != downHigh) {
Text(
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
OutlinedTextField(
value = callsign,
onValueChange = { callsign = it.take(12) },
label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mode,
onValueChange = { mode = it.take(8) },
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* Custom swipe-to-delete row (user-requested interaction):
* swipe left -> yellow trash on the right; past 75% -> becomes undo key (pending delete, not immediate);
* tap undo within the 5 s countdown to restore; otherwise auto-deleted.
*/
@Composable
internal fun SwipeDeleteRow(
onDelete: () -> Unit,
content: @Composable () -> Unit
) {
var offsetX by remember { mutableStateOf(0f) }
var pending by remember { mutableStateOf(false) }
var countdown by remember { mutableIntStateOf(5) }
var rowWidth by remember { mutableIntStateOf(0) }
val threshold = rowWidth * 0.75f
// Smooth spring back / slide in (animation)
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
label = "swipeOffset"
)
// Countdown: auto-delete 5 s after entering pending state
LaunchedEffect(pending) {
if (pending) {
countdown = 5
while (countdown > 0) {
delay(1000)
countdown--
}
if (pending) {
onDelete()
pending = false
offsetX = 0f
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowWidth = it.width }
) {
// Background layer (right-side icons: trash / undo + countdown)
Box(
modifier = Modifier
.matchParentSize()
.background(WaveLogYellow.copy(alpha = 0.25f)),
contentAlignment = Alignment.CenterEnd
) {
if (pending) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$countdown",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow
)
Text(
text = stringResource(id = R.string.wavelog_delete_undo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(WaveLogYellow.copy(alpha = 0.15f))
.clickable {
pending = false
offsetX = 0f
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
} else {
// Trash icon (always visible while swiping, yellow)
Text(
text = "🗑",
fontSize = 20.sp,
modifier = Modifier.padding(end = 16.dp)
)
}
}
// Content layer (follows finger): background sits inside the offset (moves with content)!
// Normally covers the trash; swiping moves content+background away -> yellow trash / undo countdown revealed
Box(
modifier = Modifier
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.background(MaterialTheme.colorScheme.surfaceContainer)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { if (pending) { pending = false } },
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
},
onDragEnd = {
if (offsetX <= -threshold) {
pending = true
countdown = 5
} else {
offsetX = 0f
}
},
onDragCancel = { offsetX = 0f }
)
}
) {
content()
}
}
}
private fun formatLocalTime(utcMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
cal.timeInMillis = utcMs
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
}
/** Pass session ID: satName-AOS timestamp (UTC yyyyMMdd-HHmm); falls back to Enter time without AOS */
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
val stamp = "%04d%02d%02d-%02d%02d".format(
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
return "$satName-$stamp"
}
@@ -61,6 +61,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +81,8 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Log("Log"),
Calculator("Calculator"),
Sstv("SSTV")
}
@@ -119,9 +124,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
val wavelogUploader = remember { container.provideWavelogUploader() }
LaunchedEffect(Unit) {
while (true) {
delay(10 * 60 * 1000L)
val s = container.settingsRepo.otherSettings.value
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
val outcome = wavelogUploader.uploadQueue()
if (outcome is UploadOutcome.NeedConfirm) {
// Skip this run; the user can upload and confirm manually in settings
}
}
}
}
RadarScreen(
uiState,
viewModel::onAction,
navigateUpAndClearMutual,
mutualData,
{ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
wavelogQueue = container.wavelogQueue,
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
},
showToast = { msg -> container.provideShowToast()(msg) }
)
}
@Composable
@@ -130,7 +159,10 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -184,11 +216,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
}
}
}
@@ -199,18 +231,37 @@ private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
add(RadarPage.Log)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
val lastPage = pages.lastIndex
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
}
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = pagerState.currentPage == index,
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
@@ -222,11 +273,16 @@ private fun PagerCard(
) { pageIndex ->
when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Calculator -> CalculatorPage(
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos,
cw = uiState.cw,
radioControl = uiState.radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
)
@@ -236,6 +292,16 @@ private fun PagerCard(
onAction = onAction,
requestMicPermission = requestMicPermission
)
RadarPage.Log -> LogTab(
transceivers = uiState.transceivers.transmitters,
orbitalPos = uiState.orbitalPos,
satelliteName = uiState.currentPass?.name ?: "",
queue = wavelogQueue,
wavelogConfigured = wavelogConfigured,
showToast = showToast,
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
)
}
}
}
@@ -94,14 +94,21 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
// Only create the infinite transition when the sweep is enabled — otherwise the canvas would
// be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
val sweepDegrees = if (shouldShowSweep) {
val sweepTransition = rememberInfiniteTransition(label = "sweep")
val sweepDegrees by sweepTransition.animateFloat(
val animatedSweep by sweepTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
animatedSweep
} else {
0f
}
val measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon)
@@ -112,9 +119,10 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// Station-B overlay track (dashed)
// Station-B overlay track (dashed, with the same directional arrow stamp as station A)
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
var trackBPath by remember { mutableStateOf(Path()) }
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
@@ -124,7 +132,10 @@ fun RadarViewCompose(
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath)
trackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
trackBPath = newTrackBPath
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
else PathEffect.cornerPathEffect(0f)
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius
cachedItemsRef = items
@@ -141,13 +152,14 @@ fun RadarViewCompose(
drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, primaryColor)
// Station-B overlay: full dashed track + live position dot
// (same display mode as the local station: track line + pulsing dot)
// Station-B overlay: full dashed track + directional arrows + live position dot
// (same display mode as the local station: track line/arrow + pulsing dot)
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
drawPath(
trackBPath, trackBColor,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
)
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
}
trackBPosition?.let { posB ->
if (posB.elevation > 0) {
@@ -181,7 +181,7 @@ class RadarViewModel(
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
)
}
processRadios(allRadios, pass.orbitalObject, timeNow)
processRadios(allRadios, pos)
sendPassData(pos)
}
@@ -342,8 +342,8 @@ class RadarViewModel(
}
}
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
val transmitters = satelliteRepo.getRadios(satPos, radios)
_uiState.update { state ->
val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
@@ -17,9 +17,13 @@
*/
package com.rtbishop.look4sat.feature.radar
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -39,9 +43,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -50,28 +54,45 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
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.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.cw.CwFldigiDecoder
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.presentation.CardButton
import kotlin.math.roundToInt
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -82,11 +103,8 @@ import kotlin.time.Duration.Companion.milliseconds
fun TransceiversPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
if (transceivers.isEmpty()) {
@@ -109,11 +127,8 @@ fun TransceiversPage(
TransceiverItem(
radio = radio,
isExpanded = isExpanded,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission,
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
)
}
@@ -121,6 +136,87 @@ fun TransceiversPage(
}
}
@Composable
fun CalculatorPage(
transceivers: List<SatRadio>,
selectedUuid: String?,
orbitalPos: OrbitalPos?,
cw: CwSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
modifier: Modifier = Modifier
) {
val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
?: calculatorTransceivers.firstOrNull()
}
if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier)
return
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (calculatorTransceivers.size > 1) {
item {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
calculatorTransceivers.forEach { radio ->
FilterChip(
selected = radio.uuid == selectedTransceiver.uuid,
onClick = {
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
},
label = {
Text(
text = transceiverTitle(radio),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
item {
DopplerFrequencyCalculator(
transponder = selectedTransceiver,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
}
item {
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
item {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
@@ -148,11 +244,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
private fun TransceiverItem(
radio: SatRadio,
isExpanded: Boolean,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
onToggle: () -> Unit
) {
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
@@ -193,11 +286,8 @@ private fun TransceiverItem(
)
}
// Title with mode
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
val fullTitle = "$title ($mode)"
Text(
text = fullTitle,
text = transceiverTitle(radio),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -245,11 +335,8 @@ private fun TransceiverItem(
) {
ExpandedRadioControl(
radio = radio,
orbitalPos = orbitalPos,
cw = cw,
radioControl = radioControl,
onAction = onAction,
requestMicPermission = requestMicPermission
onAction = onAction
)
}
@@ -318,11 +405,8 @@ private fun UnifiedFrequencyRow(
@Composable
private fun ExpandedRadioControl(
radio: SatRadio,
orbitalPos: OrbitalPos?,
cw: CwSubState,
radioControl: RadioControlSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit = {},
) {
Column(
modifier = Modifier
@@ -431,23 +515,6 @@ private fun ExpandedRadioControl(
}
}
// Doppler frequency calculator (linear transponders only)
DopplerFrequencyCalculator(
transponder = radio,
orbitalPos = orbitalPos,
modifier = Modifier.fillMaxWidth()
)
// CW decoder panel (linear transponders only)
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
CwDecoderPanel(
cw = cw,
onAction = onAction,
requestMicPermission = requestMicPermission,
modifier = Modifier.fillMaxWidth()
)
}
// Control buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
@@ -565,7 +632,7 @@ private fun DopplerFrequencyCalculator(
},
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth()
)
@@ -661,78 +728,111 @@ private fun CwDecoderPanel(
}
AnimatedVisibility(visible = cw.isExpanded) {
// fldigi engine: pure Compose panel (waterfall + decoded text).
val context = LocalContext.current
val decoder = remember { CwFldigiDecoder(initialWpm = 18) }
val waterfall = remember { CwPanelWaterfallState() }
var listening by remember { mutableStateOf(false) }
var captureJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val decodedText by decoder.decodedTextFlow.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
fun startPanelCapture() {
if (!cw.hasPermission) {
requestMicPermission()
return
}
captureJob?.cancel()
captureJob = scope.launch(Dispatchers.IO) {
val capture = CwPanelMicCapture()
capture.audioFlow().collect { chunk ->
if (!isActive) return@collect
decoder.processBuffer(chunk)
waterfall.pushSamples(chunk)
}
}
listening = true
}
fun stopPanelCapture() {
captureJob?.cancel()
captureJob = null
listening = false
}
DisposableEffect(Unit) {
onDispose { stopPanelCapture() }
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !listening) {
startPanelCapture()
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Control buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (cw.status == CwStatus.Idle) {
if (!listening) {
Button(
onClick = {
if (!cw.hasPermission) {
requestMicPermission()
} else {
onAction(RadarAction.CwStartListening)
}
},
onClick = { startPanelCapture() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_start))
}
} else {
Button(
onClick = { onAction(RadarAction.CwStopListening) },
onClick = { stopPanelCapture() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_stop))
}
}
OutlinedButton(
onClick = { onAction(RadarAction.CwReset) },
onClick = { decoder.resetDecoder(); waterfall.reset() },
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.radar_cw_reset))
}
}
// Signal strength indicator
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
val strengthPct = (cw.signalStrength * 100).toInt()
Text(
text = "Signal: $strengthPct%",
fontSize = 12.sp,
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Decoded text output
ElevatedCard(
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.height(90.dp)
.clip(RoundedCornerShape(8.dp))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
CwPanelWaterfallView(state = waterfall)
}
Text(
text = decodedText.takeLast(120),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Decoded:",
text = if (listening) "● ACTIVE" else "○ IDLE",
fontSize = 12.sp,
color = if (listening) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline
)
Text(
text = "LEVEL ${(signalStrength * 100).roundToInt()}%",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = cw.decodedText.ifEmpty { "Waiting for CW signal..." },
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@@ -754,5 +854,178 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
)
}
private fun transceiverTitle(radio: SatRadio): String {
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
return "$title ($mode)"
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
// --- CW panel components (fldigi engine, pure Compose) ---
/** Microphone capture at 8000 Hz mono PCM float (fldigi sample rate). */
private class CwPanelMicCapture {
private val sampleRate = 8000
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
fun audioFlow(): Flow<FloatArray> = flow {
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate / 5)
val recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize * 4
)
try {
recorder.startRecording()
val chunkSize = sampleRate / 10
val buffer = FloatArray(chunkSize)
while (currentCoroutineContext().isActive) {
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
if (read > 0) {
emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
}
}
} finally {
recorder.stop()
recorder.release()
}
}.flowOn(Dispatchers.IO)
}
/** Sliding spectrogram ring buffer for the CW waterfall. */
private class CwPanelWaterfallState {
private val columns = 120
private val rows = 48
private val data = FloatArray(columns * rows)
private val pending = ArrayList<Float>(4096)
private val fftWindow = 512
/** Monotonic frame counter — reading this in composition triggers redraw. */
private val _version = androidx.compose.runtime.mutableIntStateOf(0)
val version: androidx.compose.runtime.State<Int> = _version
@Synchronized
fun pushSamples(samples: FloatArray) {
pending.addAll(samples.toList())
var frames = 0
while (pending.size >= fftWindow) {
val window = FloatArray(fftWindow) { pending[it] }
repeat(fftWindow) { pending.removeAt(0) }
val spectrum = computeSpectrum(window)
for (r in 0 until rows - 1) {
System.arraycopy(data, (r + 1) * columns, data, r * columns, columns)
}
for (c in 0 until columns) {
val bin = c * spectrum.size / columns
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
}
frames++
}
if (frames > 0) _version.intValue++
}
private fun computeSpectrum(window: FloatArray): FloatArray {
val n = window.size
val re = FloatArray(n)
val im = FloatArray(n)
for (i in 0 until n) {
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (n - 1))
re[i] = window[i] * w.toFloat()
}
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
val tr = re[i]; re[i] = re[j]; re[j] = tr
val ti = im[i]; im[i] = im[j]; im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * Math.PI / len
val wRe = kotlin.math.cos(ang).toFloat()
val wIm = kotlin.math.sin(ang).toFloat()
var i = 0
while (i < n) {
var curRe = 1f
var curIm = 0f
for (k in 0 until len / 2) {
val uRe = re[i + k]
val uIm = im[i + k]
val vRe = re[i + k + len / 2] * curRe - im[i + k + len / 2] * curIm
val vIm = re[i + k + len / 2] * curIm + im[i + k + len / 2] * curRe
re[i + k] = uRe + vRe
im[i + k] = uIm + vIm
re[i + k + len / 2] = uRe - vRe
im[i + k + len / 2] = uIm - vIm
val nRe = curRe * wRe - curIm * wIm
val nIm = curRe * wIm + curIm * wRe
curRe = nRe; curIm = nIm
}
i += len
}
len = len shl 1
}
val bins = 48
val out = FloatArray(bins)
for (k in 0 until bins) {
out[k] = kotlin.math.sqrt(re[k] * re[k] + im[k] * im[k]) / n
}
return out
}
@Synchronized
fun getColumn(c: Int): FloatArray {
val col = FloatArray(rows)
for (r in 0 until rows) {
col[r] = data[r * columns + c.coerceIn(0, columns - 1)]
}
return col
}
@Synchronized
fun reset() {
data.fill(0f)
pending.clear()
}
}
@Composable
private fun CwPanelWaterfallView(state: CwPanelWaterfallState) {
val columns = 120
val rows = 48
// Read the frame counter so the canvas redraws as new spectra arrive.
state.version.value
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0D1B2A))
.clip(RoundedCornerShape(8.dp))
) {
val cellW = size.width / columns
val cellH = size.height / rows
for (c in 0 until columns) {
val col = state.getColumn(c)
for (r in 0 until rows) {
val v = col[r]
val intensity = (v * 4000f).coerceIn(0f, 255f)
if (intensity > 6f) {
val green = (intensity * 1.3f).coerceAtMost(255f)
drawRect(
color = Color(0f, green / 255f, 0f, 1f),
topLeft = Offset(c * cellW, r * cellH),
size = Size(cellW + 0.5f, cellH + 0.5f)
)
}
}
}
}
}
@@ -0,0 +1,236 @@
/*
* WavelogLogScreen.kt - the Log page (More menu, 4.5.2 fix).
*
* Shows locally stored logs as a table: time | freq | satellite | callsign | uploaded (✓).
* Full grid lines (header rule + row rules + column rules + outer border); rows reuse swipe-to-delete.
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import java.util.Calendar
import java.util.TimeZone
private val CheckGreen = Color(0xFF4CAF50)
private val GridLineColor = Color(0xFF3A3A3A)
@Composable
fun WavelogLogScreen(
queue: WavelogQueue,
modifier: Modifier = Modifier
) {
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
// Table outer border (left/right; top/bottom formed by header/row bottom rules)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
HeaderCell(stringResource(id = R.string.wavelog_col_time), 72.dp)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 64.dp)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
}
// Header rule
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
// Space between header and data area
Box(modifier = Modifier.height(8.dp))
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
)
} else {
// Grouped by pass session (sessionId = satName-AOS timestamp); empty sessionId goes to "Uncategorized" last
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
.toSortedMap(compareByDescending<String> { id ->
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
}.thenByDescending { it })
groups.forEach { (sessionId, groupEntries) ->
// Group title (session name + local time)
val (satPart, timePart) = parseSession(sessionId)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (sessionId == UNGROUPED) stringResource(id = R.string.wavelog_ungrouped)
else "$satPart - ${formatSessionTime(timePart)}",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
// Rule between groups (md --- effect)
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
groupEntries.forEach { entry ->
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
refreshTick++
}
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
) {
Cell(formatLocalTime(entry.timeUtcMs), 72.dp)
GridVLine()
Cell(formatFrequency(entry.freqTxHz), 64.dp)
GridVLine()
Cell(entry.satName, 0.dp, weight = 1.2f)
GridVLine()
Cell(entry.call, 0.dp, weight = 1f)
GridVLine()
Box(modifier = Modifier.width(44.dp)) {
if (entry.uploaded) {
Text(
text = "✓",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = CheckGreen
)
}
}
}
// Row divider
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
}
}
}
}
}
}
}
}
/** Column rule */
@Composable
private fun GridVLine() {
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(GridLineColor)
)
}
@Composable
private fun RowScope.HeaderCell(text: String, width: Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = mod.padding(horizontal = 6.dp)
)
}
@Composable
private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 12.sp,
maxLines = 1,
modifier = mod.padding(horizontal = 6.dp)
)
}
private const val UNGROUPED = "__ungrouped__"
/** Parse session ID: "ASRTU-1-20260804-2014" -> (sat name, "20260804-2014") */
private fun parseSession(sessionId: String): Pair<String, String> {
val parts = sessionId.split('-')
if (parts.size < 3) return sessionId to ""
val timePart = parts.takeLast(2).joinToString("-")
val satPart = parts.dropLast(2).joinToString("-")
return satPart to timePart
}
/** Session time (UTC "20260804-2014") -> local "MM-dd HH:mm" */
private fun formatSessionTime(timePart: String): String {
if (timePart.length != 13 || timePart[8] != '-') return timePart
return try {
val year = timePart.substring(0, 4).toInt()
val month = timePart.substring(4, 6).toInt()
val day = timePart.substring(6, 8).toInt()
val hour = timePart.substring(9, 11).toInt()
val minute = timePart.substring(11, 13).toInt()
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.set(year, month - 1, day, hour, minute, 0)
cal.timeInMillis = cal.timeInMillis + TimeZone.getDefault().getOffset(cal.timeInMillis)
"%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
} catch (_: Exception) {
timePart
}
}
private fun formatLocalTime(utcMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
}
+11
View File
@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.roaming"
}
dependencies {
implementation(project(":core:data"))
}
@@ -0,0 +1,791 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Roaming page - ported verbatim from QTH Locator 2.0 (com.us1pm.gridsquarelocator).
* UI: res/layout/main.xml (structure/size/colors copied item by item, no changes)
* Logic: MainActivity.showLocation() / checkEnabled() / live GPS listening
* Hard rule: UI and logic unchanged, pixel-aligned with the original screenshots.
*/
@Composable
fun RoamingScreen() {
val context = LocalContext.current
var state by remember { mutableStateOf(RoamingState()) }
// Ported from onCreate: fixed date text + hour prefix (minutes appended on locate)
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
var hourPrefix by remember { mutableStateOf("") }
// Ported from onCreate: initial checkEnabled() -> red dot + settings button, date hidden
var hasPermission by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { }
LaunchedEffect(Unit) {
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
hasPermission = fine || coarse
if (!hasPermission) {
permissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
)
} else {
// Ported from onResume: green dot + progress + hint + date shown, red dot/button hidden
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
}
// Ported from onResume/onPause: GPS + network listeners, 10 s / 10 m
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
DisposableEffect(locationManager, hasPermission) {
if (hasPermission) {
@SuppressLint("MissingPermission")
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// Ported from showLocation start: accept only gps / network providers
if (location.provider != "gps" && location.provider != "network") return
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
.copy(date = dateText)
}
override fun onProviderDisabled(provider: String) {
// Ported from checkEnabled: red dot + button, date hidden
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
}
override fun onProviderEnabled(provider: String) {
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
@Deprecated("Deprecated in Java")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
}
try {
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
} catch (_: SecurityException) {
}
onDispose { locationManager.removeUpdates(listener) }
} else {
onDispose { }
}
}
// Ported from res/layout/main.xml: light page, blue info area, 43sp locator, 3x3 grid, bottom credit
// Per user request: inset from system bars top/bottom so content is not covered
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.systemBars)
) {
// relativeLayout7: top GPS status bar, 25dp high, holo_blue_bright
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.height(25.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// tvTitleGPS: "GPS" 14sp black, left 8dp
Text(
text = "GPS",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (original main.xml)
Spacer(modifier = Modifier.width(15.dp))
// imGps: green dot (15dp, theme primary -> visible under night filter)
if (state.gpsOn) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
}
// imGpsOff: red dot overlay (15dp, theme error)
if (state.gpsOff) {
Box(
modifier = Modifier
.size(15.dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
)
}
Spacer(modifier = Modifier.weight(1f))
// tvDate: 14sp black, centered
if (state.showDate) {
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.weight(1f))
// tvTime: 14sp black, right-aligned
Text(
text = state.time,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp)
)
}
// btnLocationSettings: yellow button, centered
// btnLocationSettings: original #faf8f54a translucent yellow, 14sp black text, centered
if (state.showSettings) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.primary)
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
.padding(horizontal = 5.dp),
contentAlignment = Alignment.Center
) {
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
}
}
}
// linearLayout4: lat/lon area, holo_blue_bright
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// Lat row: tvLati 16sp black left 8dp | tvLat right-aligned 3dp
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
// Lon row
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
}
// relativeLayout6: locator area, 43sp bold black centered
Box(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = state.loc,
fontSize = 43.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// progressBar: loading, centered
if (state.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
strokeWidth = 2.dp
)
}
// tvNotice: hint text, centered top
if (state.showNotice) {
Text(
text = "请稍等,加载可能需要几分钟的时间。",
fontSize = 16.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
)
}
}
// relativeLayout: 3x3 grid, fills remaining space, bottom 15dp
// Box as RelativeLayout equivalent: left cell left / middle cell centered / right cell right (original main.xml)
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
// Row 1: relativeLayout5 (top row: lat +1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// Row 2: relativeLayout4, 205dp high (middle row: center)
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
// Center column: tv22 (100x80dp centerInParent) overlaid on linearLayout3 (200x200dp)
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = 2.dp)
.size(width = 200.dp, height = 200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// iv: red dot 10x10dp, top|left, leftMargin/topMargin from lookup tables
Image(
painter = painterResource(R.drawable.roam_pnt),
contentDescription = null,
modifier = Modifier
.size(10.dp)
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
)
}
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight (white)
Text(
text = state.grids[4],
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.size(width = 100.dp, height = 80.dp)
.align(Alignment.Center)
)
}
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// Row 3: relativeLayout3 (bottom row: lat -1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
}
// tv4: bottom credit, 10sp black (raised; bottom space kept clear of nav bar)
Text(
text = "制作:US1PM 汉化:BA7LCE",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
)
}
}
// Surrounding cells: 60/200/60dp, #0BACF1, bold centered, 2dp margin (ported tv11/tv13/tv21/tv23/tv31/tv33)
@Composable
private fun GridCell(text: String, modifier: Modifier) {
Box(
modifier = modifier
.padding(2.dp)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
/**
* Ported state from MainActivity.showLocation().
* Grid / 3x3 / red-dot lookup tables match the original line by line.
*/
data class RoamingState(
val date: String = "",
val time: String = "",
val latLabel: String = "纬度",
val latValue: String = "",
val lonLabel: String = "经度",
val lonValue: String = "",
val loc: String = "",
val grids: List<String> = List(9) { "" },
/** Red dot leftMargin (dp), looked up by the 3rd char pair of longitude */
val markerLeft: Int = 0,
/** Red dot topMargin (dp), looked up by the 3rd char pair of latitude (screen Y inverted) */
val markerTop: Int = 0,
val gpsOn: Boolean = false,
val gpsOff: Boolean = true,
val showSettings: Boolean = true,
val showProgress: Boolean = false,
val showNotice: Boolean = false,
val showDate: Boolean = false
)
/**
* Ported full showLocation() computation: time / lat-lon DMS / 8-char grid / 3x3 / red-dot lookup.
*/
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
// Ported: tvTime = cached hour prefix + locate time minutes
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
val time = hourPrefix + minute
// Direction chars
val ew = if (lon >= 0.0) "E" else "W"
val ns = if (lat >= 0.0) "N" else "S"
// Ported: latitude DMS (String.valueOf, no zero padding)
val latAbs = if (lat < 0.0) -lat else lat
val latDeg = latAbs.toInt()
val latMinFull = (latAbs - latDeg) * 60.0
val latMin = latMinFull.toInt()
val latSec = ((latMinFull - latMin) * 60.0).toInt()
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
val latValue = "$lat° "
// Ported: longitude DMS
val lonAbs = if (lon < 0.0) -lon else lon
val lonDeg = lonAbs.toInt()
val lonMinFull = (lonAbs - lonDeg) * 60.0
val lonMin = lonMinFull.toInt()
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
val lonValue = "$lon° "
// Ported: 8-char grid encoding (range lookup)
val (str, str2, str3, str4) = encodeLon(lon)
val (str5, str6, str38, str9) = encodeLat(lat)
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
// Ported: 3x3 parseInt3 five-branch logic
val grids = buildGrids(str, str5, str2, str6, str3, str38)
// Ported: red-dot lookup (3rd char pair)
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
val markerTop = latMargin[str38.firstOrNull()] ?: 0
return RoamingState(
time = time,
latLabel = latLabel,
latValue = latValue,
lonLabel = lonLabel,
lonValue = lonValue,
loc = loc,
grids = grids,
markerLeft = markerLeft,
markerTop = markerTop,
gpsOn = true,
gpsOff = false,
showSettings = false,
showProgress = false,
showNotice = false,
showDate = true
)
}
// Red-dot leftMargin lookup (MainActivity lines 1286-1386): lon 3rd pair a..x -> -2..190 dp
private val lonMargin = mapOf(
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
)
// Red-dot topMargin lookup (MainActivity lines 1315-1363): lat 3rd pair a..x -> 190..-2 dp (screen Y inverted)
private val latMargin = mapOf(
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
)
// Ported lines 311-611: longitude -> (20 deg zone letter, 2 deg digit, 2' letter, 30" digit)
private fun encodeLon(inputLon: Double): List<String> {
var longitude = inputLon
var d = 0.0
val str = when {
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
else -> { longitude = 0.0; " " }
}
val str2 = when {
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
else -> { d = 0.0; " " }
}
val str3 = when {
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
else -> { longitude = 0.0; " " }
}
val str4 = when {
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
else -> " "
}
return listOf(str, str2, str3, str4)
}
// Ported lines 612-914: latitude -> (10 deg zone letter, 1 deg digit, 1' letter, 15" digit)
private fun encodeLat(inputLat: Double): List<String> {
var latitude = inputLat
var d2 = 0.0
var d3 = 0.0
val str5 = when {
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
else -> { d2 = 0.0; " " }
}
var str6 = " "
when {
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
}
val str38 = when {
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
else -> { d2 = 0.0; " " }
}
val str9 = when {
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
else -> " "
}
return listOf(str5, str6, str38, str9)
}
/**
* Ported lines 917-1283: 3x3 parseInt3 five-branch (interior / west / east / north / south edges + corners).
* Returns 9 cell texts: [top-left, top-center, top-right, mid-left, center, mid-right, bottom-left, bottom-center, bottom-right].
*/
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
val parseInt = str2.toInt()
val parseInt2 = str6.toInt()
val str39 = str + str5
val str40 = str2 + str6
val parseInt3 = str40.toInt()
val str41 = str39 + str40
val g = MutableList(9) { "" }
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// Interior: digit pairs +-1
val i7 = parseInt3 + 1
val i8 = i7 - 10
val valueOf9 = fmt2(i8)
val str11 = i7.toString()
val valueOf18 = (i7 + 10).toString()
val i9 = parseInt3 - 10
val valueOf10 = fmt2(i9)
val valueOf19 = (parseInt3 + 10).toString()
val i10 = parseInt3 - 1
val i11 = i10 - 10
val valueOf11 = fmt2(i11)
val str14 = i10.toString()
val str15 = (i10 + 10).toString()
g[0] = str39 + valueOf9
g[1] = str39 + str11
g[2] = str39 + valueOf18
g[3] = str39 + valueOf10
g[4] = str41
g[5] = str39 + valueOf19
g[6] = str39 + valueOf11
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
// West edge: lon digit 0 -> 9, lon letter -1
val str43 = (str[0] - 1).toString() + str5
val str44 = "9" + (parseInt2 + 1)
val i12 = parseInt3 + 1
val valueOf7 = fmt2(i12)
val str12 = (i12 + 10).toString()
val sb4 = "9" + parseInt2
val valueOf20 = (parseInt3 + 10).toString()
val str46 = "9" + (parseInt2 - 1)
val i13 = parseInt3 - 1
val valueOf8 = fmt2(i13)
val str15 = (i13 + 10).toString()
g[0] = str43 + str44
g[1] = str39 + valueOf7
g[2] = str39 + str12
g[3] = str43 + sb4
g[4] = str41
g[5] = str39 + valueOf20
g[6] = str43 + str46
g[7] = str39 + valueOf8
g[8] = str39 + str15
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// East edge: lon digit 9 -> 0, lon letter +1
val str47 = (str[0] + 1).toString() + str5
val i14 = parseInt3 + 1
val valueOf21 = (i14 - 10).toString()
val valueOf22 = i14.toString()
val str48 = "0" + (parseInt2 + 1)
val valueOf23 = (parseInt3 - 10).toString()
val str49 = "0" + parseInt2
val i15 = parseInt3 - 1
val valueOf24 = (i15 - 10).toString()
val valueOf25 = i15.toString()
val str15 = "0" + (parseInt2 - 1)
g[0] = str39 + valueOf21
g[1] = str39 + valueOf22
g[2] = str47 + str48
g[3] = str39 + valueOf23
g[4] = str41
g[5] = str47 + str49
g[6] = str39 + valueOf24
g[7] = str39 + valueOf25
g[8] = str47 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
// North edge: lat digit 9 -> 0, lat letter +1
val str50 = str + (str5[0] + 1).toString()
val i16 = parseInt3 - 9
val i17 = i16 - 10
val valueOf4 = fmt2(i17)
val valueOf26 = i16.toString()
val valueOf27 = (i16 + 10).toString()
val i18 = parseInt3 - 10
val valueOf5 = fmt2(i18)
val valueOf28 = (parseInt3 + 10).toString()
val i19 = parseInt3 - 1
val i20 = i19 - 10
val valueOf6 = fmt2(i20)
val str14 = i19.toString()
val str15 = (i19 + 10).toString()
g[0] = str39 + valueOf4
g[1] = str50 + valueOf26
g[2] = str39 + valueOf27
g[3] = str39 + valueOf5
g[4] = str41
g[5] = str39 + valueOf28
g[6] = str39 + valueOf6
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
// South edge: lat digit 0 -> 9, lat letter -1
val str52 = str + (str5[0] - 1).toString()
val i21 = parseInt3 + 1
val i22 = i21 - 10
val valueOf = fmt2(i22)
val valueOf29 = i21.toString()
val valueOf30 = (i21 + 10).toString()
val i23 = parseInt3 - 10
val valueOf2 = fmt2(i23)
val valueOf31 = (parseInt3 + 10).toString()
val i24 = parseInt3 - 1
val valueOf3 = fmt2(i24)
val str14 = (i24 + 10).toString()
val str15 = (i24 + 20).toString()
g[0] = str39 + valueOf
g[1] = str39 + valueOf29
g[2] = str39 + valueOf30
g[3] = str39 + valueOf2
g[4] = str41
g[5] = str39 + valueOf31
g[6] = str52 + valueOf3
g[7] = str52 + str14
g[8] = str52 + str15
} else if (parseInt3 == 0) {
// Corner 00: both digits -1 with carry
val v32 = (str[0] - 1).toString()
val v33 = (str5[0] - 1).toString()
g[0] = v32 + str5 + "91"
g[1] = str39 + "01"
g[2] = str39 + "11"
g[3] = v32 + str5 + "90"
g[4] = str41
g[5] = v32 + str5 + "10"
g[6] = v32 + v33 + "99"
g[7] = str + v33 + "09"
g[8] = str + v33 + "19"
} else if (parseInt3 == 9) {
// Corner 09: lon -1, lat +1
val v34 = (str[0] - 1).toString()
val v35 = (str5[0] + 1).toString()
g[0] = v34 + v35 + "90"
g[1] = str + v35 + "00"
g[2] = str + v35 + "10"
g[3] = v34 + str5 + "99"
g[4] = str41
g[5] = v34 + str5 + "19"
g[6] = v34 + str5 + "98"
g[7] = v34 + str5 + "08"
g[8] = v34 + str5 + "18"
} else if (parseInt3 == 99) {
// Corner 99: both digits +1 with carry
val v36 = (str[0] + 1).toString()
val v37 = (str5[0] + 1).toString()
g[0] = str + v37 + "80"
g[1] = str + v37 + "90"
g[2] = v36 + v37 + "00"
g[3] = str39 + "89"
g[4] = str41
g[5] = v36 + str5 + "09"
g[6] = str39 + "88"
g[7] = str39 + "98"
g[8] = v36 + str5 + "08"
} else if (parseInt3 == 90) {
// Corner 90: lon +1, lat -1
val v38 = (str[0] + 1).toString()
val v39 = (str5[0] - 1).toString()
g[0] = str39 + "81"
g[1] = str39 + "91"
g[2] = v38 + str5 + "01"
g[3] = str39 + "80"
g[4] = str41
g[5] = v38 + str5 + "00"
g[6] = str + v39 + "89"
g[7] = str + v39 + "99"
g[8] = v38 + v39 + "09"
}
return g
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,124 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.roaming
import org.junit.Assert.assertEquals
import org.junit.Test
class RoamingStateTest {
// User-verified coordinates (QTH Locator 2.0 screenshot): 22.314066 / 108.706575
private fun stateOf(lat: Double, lon: Double) =
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
@Test
fun `user coordinates produce OL42ih45`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("OL42ih45", state.loc)
}
@Test
fun `red marker from ih pair matches reference lookup`() {
val state = stateOf(22.314066, 108.706575)
// Original lookup: lon 3rd pair 'i' -> leftMargin 65dp, lat 3rd pair 'h' -> topMargin 137dp
assertEquals(65, state.markerLeft)
assertEquals(137, state.markerTop)
}
@Test
fun `grid nine from OL42 matches reference`() {
val state = stateOf(22.314066, 108.706575)
val expected = listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)
assertEquals(expected, state.grids)
}
@Test
fun `latitude dms formatting matches reference`() {
val state = stateOf(22.314066, 108.706575)
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
assertEquals("22.314066° ", state.latValue)
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
assertEquals("108.706575° ", state.lonValue)
}
@Test
fun `time is hour prefix plus fix minutes`() {
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
assertEquals("21:12", state.time)
}
@Test
fun `west edge borrows lon letter`() {
// lon 0.5 lat 22.314066: digit pair 02, west edge parseInt==0
val state = stateOf(22.314066, 0.5)
assertEquals(8, state.loc.length)
// Left column lon 0->9, lon letter J-1=I: IL92
assertEquals("IL92", state.grids[3])
}
@Test
fun `north edge carries lat letter`() {
// lat 29.5 lon 108.706575: digit pair 49? -> lat digit 9 -> north edge
val state = stateOf(29.5, 108.706575)
assertEquals(8, state.loc.length)
// Top row lat +1 -> 0, lat letter +1 (M)
assertEquals("OM40", state.grids[1])
}
@Test
fun `east edge carries lon letter`() {
// lon 119.5 lat 22.314066: lon digit 9 -> east edge
val state = stateOf(22.314066, 119.5)
assertEquals(8, state.loc.length)
// Top-right lon 9->0, lon letter O+1=P, lat 2+1=3: PL03
assertEquals("PL03", state.grids[2])
}
@Test
fun `south edge borrows lat letter`() {
// lat 0.5 lon 108.706575: lat digit 0 -> south edge
val state = stateOf(0.5, 108.706575)
assertEquals(8, state.loc.length)
// Bottom-center lat 0->9, lat letter J-1=I: OI49
assertEquals("OI49", state.grids[7])
}
@Test
fun `boundary values wrap like reference`() {
// (90, 180) -> RR99xx99 (original range-lookup behavior)
val state = stateOf(90.0, 180.0)
assertEquals("RR99xx99", state.loc)
}
@Test
fun `gps state after fix matches showLocation`() {
val state = stateOf(22.314066, 108.706575)
assertEquals(true, state.gpsOn)
assertEquals(false, state.gpsOff)
assertEquals(false, state.showSettings)
assertEquals(false, state.showProgress)
assertEquals(false, state.showNotice)
assertEquals(true, state.showDate)
}
}
+4
View File
@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.settings"
}
dependencies {
implementation(project(":core:data"))
}
@@ -0,0 +1,316 @@
package com.rtbishop.look4sat.feature.settings
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
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.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
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.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.setValue
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
@Composable
fun AprsCard() {
val context = LocalContext.current
var config by remember { mutableStateOf(AprsStore.loadConfig(context)) }
var showDialog by remember { mutableStateOf(false) }
var lastReport by remember { mutableStateOf(AprsStore.loadLastReport(context)) }
var reportPing by remember { mutableStateOf(0) }
val notifPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { }
fun requestNotifPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= 33 &&
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notifPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Title row: title + gear key (dialog, space-saving)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(id = R.string.prefs_aprs_title),
color = MaterialTheme.colorScheme.primary
)
IconButton(onClick = { showDialog = true }) {
Text(
text = "\u2699", // ⚙
color = MaterialTheme.colorScheme.primary,
fontSize = 18.sp
)
}
}
// Enable switch
AprsSwitchRow(R.string.prefs_aprs_enable, config.enabled) { enabled ->
config = config.copy(enabled = enabled)
AprsStore.saveConfig(context, config)
if (enabled) requestNotifPermissionIfNeeded()
val intent = Intent()
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
.setAction(if (enabled) AprsStore.ACTION_START else AprsStore.ACTION_STOP)
if (enabled) context.startForegroundService(intent) else context.startService(intent)
}
// Compact info row (not crowded)
Text(
text = stringResource(
id = R.string.prefs_aprs_summary,
config.server, config.port,
if (config.callsign.isBlank()) "-" else config.callsign
),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Last-report status row
val lr = lastReport
if (lr.time > 0L) {
val timeStr = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
.format(java.util.Date(lr.time))
Text(
text = stringResource(
id = R.string.prefs_aprs_last_report,
timeStr,
if (lr.ok) stringResource(R.string.prefs_aprs_last_ok)
else stringResource(R.string.prefs_aprs_last_fail, lr.detail)
),
fontSize = 13.sp,
color = if (lr.ok) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
}
// Manual report button
CardButton(
onClick = {
val intent = Intent()
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
.setAction(AprsStore.ACTION_REPORT_NOW)
context.startForegroundService(intent)
reportPing++
},
text = stringResource(id = R.string.prefs_aprs_report_now),
modifier = Modifier.fillMaxWidth()
)
}
}
// Refresh the status row after reporting (wait for the result to persist)
androidx.compose.runtime.LaunchedEffect(reportPing) {
if (reportPing > 0) {
kotlinx.coroutines.delay(6000)
lastReport = AprsStore.loadLastReport(context)
}
}
if (showDialog) {
AprsSettingsDialog(
config = config,
onDismiss = { showDialog = false },
onSave = { newConfig ->
config = newConfig
AprsStore.saveConfig(context, newConfig)
// Apply new config when enabled (restart the service)
if (newConfig.enabled) {
context.startService(
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
.setAction(AprsStore.ACTION_STOP)
)
context.startForegroundService(
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
.setAction(AprsStore.ACTION_START)
)
}
showDialog = false
}
)
}
}
/** APRS settings dialog (plenty of room, full config) */
@Composable
private fun AprsSettingsDialog(
config: AprsConfig,
onDismiss: () -> Unit,
onSave: (AprsConfig) -> Unit
) {
var server by remember { mutableStateOf(config.server) }
var port by remember { mutableStateOf(config.port.toString()) }
var callsign by remember { mutableStateOf(config.callsign) }
var ssid by remember { mutableStateOf(config.ssid) }
var passcode by remember { mutableStateOf(config.passcode) }
var interval by remember { mutableStateOf(config.intervalMin.toString()) }
var status by remember { mutableStateOf(config.statusText) }
var symbolTable by remember { mutableStateOf(config.symbolTable) }
var symbolCode by remember { mutableStateOf(config.symbolCode) }
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(id = R.string.prefs_aprs_settings_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = server,
onValueChange = { server = it },
label = { Text(stringResource(id = R.string.prefs_aprs_server)) },
singleLine = true,
textStyle = textStyle
)
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text(stringResource(id = R.string.prefs_aprs_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = textStyle
)
OutlinedTextField(
value = callsign,
onValueChange = { callsign = it },
label = { Text(stringResource(id = R.string.prefs_aprs_callsign)) },
singleLine = true,
textStyle = textStyle
)
// Passcode compute button (user request: let the user see the computed result)
TextButton(
onClick = {
val call = callsign.trim().uppercase()
if (call.isNotBlank()) {
passcode = AprsPacket.passcode(call).toString()
}
},
enabled = callsign.trim().isNotBlank()
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text(stringResource(id = R.string.prefs_aprs_ssid)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
)
OutlinedTextField(
value = passcode,
onValueChange = { passcode = it },
label = { Text(stringResource(id = R.string.prefs_aprs_passcode)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = interval,
onValueChange = { interval = it },
label = { Text(stringResource(id = R.string.prefs_aprs_interval)) },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
textStyle = textStyle
)
OutlinedTextField(
value = status,
onValueChange = { status = it },
label = { Text(stringResource(id = R.string.prefs_aprs_status)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = symbolTable,
onValueChange = { symbolTable = it },
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
)
OutlinedTextField(
value = symbolCode,
onValueChange = { symbolCode = it },
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = textStyle
)
}
Text(
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = {
onSave(
config.copy(
server = server.ifBlank { "euro.aprs2.net" },
port = port.toIntOrNull() ?: 14580,
callsign = callsign.trim().uppercase(),
ssid = ssid.trim(),
passcode = passcode.trim(),
intervalMin = interval.toIntOrNull() ?: 5,
statusText = status,
symbolTable = symbolTable.ifBlank { "/" },
symbolCode = symbolCode.ifBlank { ">" }
)
)
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.prefs_aprs_cancel)) }
}
)
}
/** Switch row local to this file (same as SettingsScreen's private SwitchRow) */
@Composable
private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -41,11 +43,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing
@@ -203,6 +207,17 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTle.value,
trailingIcon = {
IconButton(
onClick = { urlTle.value = Sources.defaultTleUrl },
enabled = isEnabledCustomTle.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(6.dp))
Row(
@@ -232,6 +247,17 @@ fun DataSourcesDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = isEnabledCustomTransceivers.value,
trailingIcon = {
IconButton(
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
enabled = isEnabledCustomTransceivers.value
) {
Icon(
painter = painterResource(id = R.drawable.ic_reset),
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
)
}
},
)
Spacer(modifier = Modifier.height(12.dp))
}
@@ -22,39 +22,72 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import android.content.Context
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import kotlin.math.roundToInt
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -72,10 +105,13 @@ import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.defaultScreenOrder
import com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.launch
@Composable
fun SettingsDestination() {
@@ -83,11 +119,72 @@ fun SettingsDestination() {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo())
// WaveLog grid mismatch confirmation dialog (4.5.2)
val gridConfirm = viewModel.gridConfirm
if (gridConfirm != null) {
AlertDialog(
onDismissRequest = { viewModel.resolveGridConfirm(false) },
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
text = {
Text(
text = stringResource(
id = R.string.wavelog_grid_mismatch,
gridConfirm.userGrid.take(4),
gridConfirm.stationGrid.take(4)
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
Text(text = stringResource(id = R.string.wavelog_ignore))
}
},
dismissButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
Text(text = stringResource(id = R.string.wavelog_cancel))
}
}
)
}
// WaveLog error detail dialog (one-tap copy)
val wavelogError = viewModel.wavelogError
if (wavelogError != null) {
AlertDialog(
onDismissRequest = { viewModel.wavelogError = null },
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
text = {
Text(
text = wavelogError,
fontSize = 13.sp
)
},
confirmButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError))
viewModel.wavelogError = null
}) {
Text(text = stringResource(id = R.string.wavelog_copy))
}
},
dismissButton = {
TextButton(onClick = { viewModel.wavelogError = null }) {
Text(text = stringResource(id = R.string.btn_cancel))
}
}
)
}
}
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
private fun SettingsScreen(
uiState: SettingsState,
onAction: (SettingsAction) -> Unit,
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
) {
val dialogs = rememberDialogVisibility()
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
@@ -291,6 +388,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
showDataSourcesDialog = { dialogs.dataSources = true }
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
AprsCard()
}
item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard(
onNetworkClick = permissions.launchNetwork,
@@ -299,6 +399,18 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) }
item {
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
screenOrder = uiState.otherSettings.screenOrder,
subMenuOrder = uiState.otherSettings.subMenuOrder,
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
)
}
item { CardCredits() }
}
}
@@ -337,9 +449,11 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°")
Text(text = "Qth: ${settings.stationPos.qthLocator}")
Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.loc_qth_text, settings.stationPos.qthLocator))
}
Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -484,11 +598,12 @@ private fun OtherCardPreview() = MainTheme {
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_other_title),
color = MaterialTheme.colorScheme.primary
@@ -512,8 +627,389 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
}
}
/**
* UI settings card: controls which pages appear in the bottom menu bar.
* Order is fixed (Satellites/Passes/Radar/Mutual/Roaming/Map/Settings); turning one off makes the rest close up;
* the Settings page is always shown (so the settings entry can't be lost).
*/
/** WaveLog log server settings card (4.5.2): matches the user's screenshot (dark card / label left, input right / button row) */
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
private fun WavelogCard(
settings: OtherSettings,
onAction: (SettingsAction) -> Unit,
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
) {
val qrzContext = LocalContext.current
var showQrzDialog by remember { mutableStateOf(false) }
var qrzCookie by remember {
mutableStateOf(
qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
.getString("cookie", "") ?: ""
)
}
var qrzTestResult by remember { mutableStateOf<String?>(null) }
var qrzTesting by remember { mutableStateOf(false) }
val qrzScope = rememberCoroutineScope()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { showQrzDialog = true }) {
Text("\u2699")
}
}
OutlinedTextField(
value = settings.wavelogUrl,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogApiKey,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogStationId,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CardButton(
onClick = { onAction(SettingsAction.TestWavelogConnection) },
text = stringResource(id = R.string.prefs_wavelog_test),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onAction(SettingsAction.UpdateLotwSatellites) },
text = stringResource(id = R.string.prefs_wavelog_update_sats),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onAction(SettingsAction.UploadWavelogQueue) },
text = stringResource(id = R.string.prefs_wavelog_upload),
modifier = Modifier.weight(1f)
)
}
}
}
if (showQrzDialog) {
AlertDialog(
onDismissRequest = { showQrzDialog = false },
title = { Text(text = stringResource(id = R.string.prefs_wavelog_qrz_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_qrz_hint),
fontSize = 12.sp
)
OutlinedTextField(
value = qrzCookie,
onValueChange = { qrzCookie = it },
label = { Text(stringResource(id = R.string.prefs_wavelog_qrz_label)) },
minLines = 3,
maxLines = 8,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
modifier = Modifier.fillMaxWidth()
)
if (qrzTestResult != null) {
Text(
text = qrzTestResult!!,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
TextButton(
onClick = {
qrzTesting = true
qrzTestResult = null
qrzScope.launch {
val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie)
if (header.isBlank()) {
qrzTestResult = "Cookie 为空, 请先粘贴"
qrzTesting = false
return@launch
}
val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header)
if (own == null) {
qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)"
} else {
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header)
qrzTestResult = if (grid != null) {
"登录呼号: $own 网格: $grid ✅"
} else {
"登录呼号: $own 网格: 未填写"
}
}
qrzTesting = false
}
},
enabled = !qrzTesting
) {
Text(text = if (qrzTesting) "测试中…" else "测试查询")
}
}
},
confirmButton = {
TextButton(onClick = {
qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
.edit().putString("cookie", qrzCookie.trim()).apply()
showQrzDialog = false
}) { Text(text = stringResource(id = R.string.btn_accept)) }
},
dismissButton = {
TextButton(onClick = { showQrzDialog = false }) {
Text(text = stringResource(id = R.string.wavelog_cancel))
}
}
)
}
}
@Composable
private fun UiSettingsCard(
hiddenScreens: List<String>,
screenOrder: List<String>,
subMenuOrder: List<String>,
onToggle: (String) -> Unit,
onReorder: (List<String>) -> Unit,
onResetOrder: () -> Unit,
onUpdateMenu: (List<String>, List<String>) -> Unit
) {
val screens = listOf(
R.string.nav_sat to "Satellites",
R.string.nav_pass to "Passes",
R.string.nav_radar to "Radar",
R.string.nav_mutual to "Mutual",
R.string.nav_roaming to "Roaming",
R.string.nav_cw to "CwDecode",
R.string.nav_log to "WavelogLog",
R.string.nav_amsat to "AMSAT",
R.string.nav_map to "Map",
R.string.nav_prefs to "Settings"
) // name 必须与 Screen.screenId 一致 (R8 安全)
// Main menu items: exclude sub-menu items; Settings pinned last
val mainItems = remember(screens, screenOrder, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
screens.map { it.second }
.filter { it !in sub }
.sortedBy { name ->
screenOrder.indexOf(name).let { idx ->
if (idx != -1) idx else defaultScreenOrder.indexOf(name).let {
if (it != -1) it else Int.MAX_VALUE
}
}
}
.sortedWith(compareBy { if (it == "Settings") 1 else 0 })
}
// More-menu items
val subItems = remember(screens, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
sub.filter { s -> screens.any { (_, name) -> name == s } }
}
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(id = R.string.prefs_ui_title),
color = MaterialTheme.colorScheme.primary
)
screens.forEach { (labelRes, name) ->
if (name == "Settings") {
// Settings always shown; its switch is disabled
SwitchRow(labelRes, true, null)
} else {
SwitchRow(labelRes, name !in hiddenScreens) {
onToggle(name)
}
}
}
Text(
text = stringResource(id = R.string.prefs_ui_order_title),
color = MaterialTheme.colorScheme.primary
)
// Main menu area (bottom bar, max 5 items, Settings locked last)
Text(
text = stringResource(id = R.string.prefs_ui_main_title),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
DragOrderList(
items = mainItems,
labels = screens,
locked = setOf("Settings"),
moveLabel = stringResource(id = R.string.prefs_ui_move_out),
moveEnabled = { name -> name != "Settings" },
onMove = { name ->
onUpdateMenu(
mainItems.filter { it != name },
(subMenuOrder.ifEmpty { defaultSubMenuOrder } + name).distinct()
)
},
onReorder = { main -> onUpdateMenu(main, subMenuOrder) }
)
// More-menu area (pages behind "More")
Text(
text = stringResource(id = R.string.prefs_ui_sub_title),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
DragOrderList(
items = subItems,
labels = screens,
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
// Buttons always visible; when the main menu hits 5, the last non-Settings item moves to More (swap)
moveEnabled = { true },
onMove = { name ->
val mainWithoutSettings = mainItems.filter { it != "Settings" }
val needSwap = mainWithoutSettings.size >= 5
val evicted = if (needSwap) mainWithoutSettings.last() else null
val newMain = if (needSwap) {
(mainWithoutSettings.dropLast(1) + name).distinct()
} else {
(mainWithoutSettings + name).distinct()
}
val newSub = (subItems.filter { it != name } +
if (evicted != null) listOf(evicted) else emptyList()).distinct()
onUpdateMenu(newMain, newSub)
},
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
)
TextButton(onClick = onResetOrder) {
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
}
}
}
}
/** Page-order drag list: handle on each row's right; drag up/down to reorder live; persists on release */
@Composable
private fun DragOrderList(
items: List<String>,
labels: List<Pair<Int, String>>,
locked: Set<String> = emptySet(),
moveLabel: String? = null,
moveEnabled: (String) -> Boolean = { true },
onMove: ((String) -> Unit)? = null,
onReorder: (List<String>) -> Unit
) {
val itemHeight = 48.dp
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
val listState = rememberLazyListState()
val localItems = remember(items) { mutableStateListOf<String>().apply { addAll(items) } }
var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) }
// Fixed height: nested inside LazyVerticalGrid it needs a bounded height or infinite constraints crash
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight * localItems.size)
) {
itemsIndexed(localItems, key = { _, name -> name }) { index, name ->
val isDragging = draggingIndex == index
val isLocked = name in locked
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer { translationY = if (isDragging) dragOffset else 0f }
.animateItem()
) {
Text(
text = stringResource(id = labels.first { it.second == name }.first),
modifier = Modifier.weight(1f)
)
if (moveLabel != null && onMove != null && moveEnabled(name)) {
TextButton(onClick = { onMove(name) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
Text(moveLabel, fontSize = 11.sp)
}
}
if (isLocked) {
// Locked row (Settings): no handle, pinned last
Text(
text = stringResource(id = R.string.prefs_ui_order_locked),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp)
)
} else {
Box(
modifier = Modifier
.size(48.dp)
.padding(20.dp)
.pointerInput(name) {
detectDragGestures(
onDragStart = { draggingIndex = localItems.indexOf(name) },
onDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount.y
val currentIndex = draggingIndex
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
.coerceIn(0, localItems.size - 1)
if (target != currentIndex) {
localItems.add(target, localItems.removeAt(currentIndex))
dragOffset += (currentIndex - target) * itemHeightPx
draggingIndex = target
}
},
onDragEnd = {
onReorder(localItems.toList())
draggingIndex = -1
dragOffset = 0f
},
onDragCancel = {
draggingIndex = -1
dragOffset = 0f
}
)
}
) {
// Dot handle (touch area padding 20dp centered -> 8dp dot)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
)
}
}
}
}
}
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -556,15 +1052,11 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
@Composable
private fun CardCredits(modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.height(268.dp)
modifier = modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = stringResource(id = R.string.prefs_outro_title),
@@ -573,6 +1065,8 @@ private fun CardCredits(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
)
// Keep roughly two lines of spacing between the thanks list and the license block (user request)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.prefs_outro_license),
color = MaterialTheme.colorScheme.primary
@@ -588,7 +1082,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(48.dp)
.heightIn(min = 48.dp)
.clickable { onClick() }) {
Spacer(Modifier)
Icon(
@@ -603,7 +1097,6 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee()
)
}
}
@@ -64,6 +64,26 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
// UI settings: toggle a bottom-nav page's hidden state (Screen simpleName)
data class ToggleScreen(val screenName: String) : SettingsAction
// UI settings: update page order
data class ReorderScreens(val order: List<String>) : SettingsAction
// UI settings: reset to default order
data object ResetScreenOrder : SettingsAction
// UI settings: update main + More menu order (4.5.1 foldable menu)
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
// UI settings: reset main + More menu order
data object ResetMenuOrder : SettingsAction
// WaveLog logging (4.5.2): update server config
data class UpdateWavelogSettings(
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
) : SettingsAction
// WaveLog: test connection
data object TestWavelogConnection : SettingsAction
// WaveLog: upload queue manually
data object UploadWavelogQueue : SettingsAction
// WaveLog: update LoTW satellite list
data object UpdateLotwSatellites : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -25,16 +25,24 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val showToast: IShowToast
private val showToast: IShowToast,
private val lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
) : ViewModel() {
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
@@ -121,10 +129,35 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleScreen -> settingsRepo.updateOtherSettings { current ->
val hidden = current.hiddenScreens.toMutableList()
if (action.screenName in hidden) hidden.remove(action.screenName) else hidden.add(action.screenName)
current.copy(hiddenScreens = hidden)
}
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
is SettingsAction.UpdateMenuOrder -> settingsRepo.updateOtherSettings {
it.copy(screenOrder = action.mainOrder, subMenuOrder = action.subOrder)
}
SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings {
it.copy(screenOrder = emptyList(), subMenuOrder = emptyList())
}
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// WaveLog logging (4.5.2)
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
it.copy(
wavelogUrl = action.url,
wavelogApiKey = action.apiKey,
wavelogStationId = action.stationId,
wavelogAutoUpload = action.autoUpload
)
}
SettingsAction.TestWavelogConnection -> testWavelogConnection()
SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
SettingsAction.UpdateLotwSatellites -> updateLotwSatellites()
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
@@ -133,7 +166,16 @@ class SettingsViewModel(
// region Position helpers — consolidated from 3 near-identical functions
private fun setGpsPosition() {
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
viewModelScope.launch {
_uiState.update { it.copy(positionSettings = it.positionSettings.copy(isUpdating = true)) }
val success = settingsRepo.setStationPosition()
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(
isUpdating = false,
messageResId = if (success) R.string.prefs_loc_success else R.string.prefs_loc_gps_error
))
}
}
}
private fun setGeoPosition(latitude: Double, longitude: Double) {
@@ -181,6 +223,7 @@ class SettingsViewModel(
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
}
println(exception)
showToast(R.string.prefs_data_update_failed)
}
}
@@ -197,6 +240,76 @@ class SettingsViewModel(
}
// endregion
// region WaveLog(4.5.2)
// Shared uploader injected by SettingsScreen (container-level singleton)
var pendingUploader: WavelogUploader? = null
// Grid-mismatch confirmation state (UI observes it and shows an AlertDialog)
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
// WaveLog error-detail dialog (UI observes it; one-tap copy)
var wavelogError by mutableStateOf<String?>(null)
data class GridConfirmData(val stationGrid: String, val userGrid: String)
private fun testWavelogConnection() {
viewModelScope.launch {
val s = settingsRepo.otherSettings.value
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId)
when (result) {
is WavelogResult.Success -> showToast("WaveLog: ${result.message}")
is WavelogResult.Failure -> wavelogError = result.message
}
}
}
private fun updateLotwSatellites() {
viewModelScope.launch {
when (val r = lotwRepo.refresh()) {
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
showToast("LoTW 卫星列表已更新: ${r.count} 个")
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
wavelogError = "LoTW 卫星列表更新失败: ${r.message}"
}
}
}
private fun uploadWavelogQueue() {
viewModelScope.launch {
val result = pendingUploader?.uploadQueue()
when (result) {
is UploadOutcome.Done -> {
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
wavelogError = result.firstError
} else {
showToast("WaveLog: ${result.message}")
}
}
is UploadOutcome.NeedConfirm -> {
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
}
null -> showToast("WaveLog: 上传失败")
}
}
}
/** Grid-confirm result: ignore and upload / cancel */
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
val confirm = gridConfirm ?: return
gridConfirm = null
if (!ignoreAndUpload) return
viewModelScope.launch {
val result = pendingUploader?.uploadQueue(force = true)
when (result) {
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
else -> showToast("WaveLog: 上传失败")
}
}
}
// endregion
companion object {
@@ -206,8 +319,9 @@ class SettingsViewModel(
SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
)
showToast = container.provideShowToast(),
lotwRepo = container.provideLotwSatellitesRepo()
).apply { pendingUploader = container.provideWavelogUploader() }
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
}
@@ -0,0 +1,368 @@
package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.feature.status.R as StatusR
import java.util.Calendar
// ========== Official status colors (amsat.org/status originals) ==========
val ActiveBlue = Color(0xFF648FFF)
val TlmOrange = Color(0xFFFFB000)
val NotHeardPink = Color(0xFFDC267F)
val ConflictDeepOrange = Color(0xFFFE6100)
val NoReportGray = Color(0xFFC0C0C0)
@Composable
fun SatStatusScreen(container: IMainContainer) {
val viewModel: SatStatusViewModel = viewModel(
factory = viewModelFactory {
initializer { SatStatusViewModel(container) }
}
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
LegendRow()
when {
uiState.isLoading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null && uiState.statuses.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.amsat_load_failed),
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.refresh() }) {
Text(text = stringResource(id = R.string.amsat_retry))
}
}
}
else -> {
// Header
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
// Row
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(
status = status,
onClickDay = { day -> selectedDay = status to day }
)
}
}
}
}
}
selectedDay?.let { (status, day) ->
ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
onDismiss = { selectedDay = null }
)
}
}
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(
fetchedAtUtcMs: Long,
isRefreshing: Boolean,
onRefresh: () -> Unit
) {
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
val angle by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
label = "angle"
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh),
contentAlignment = Alignment.Center
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Image(
painter = painterResource(id = StatusR.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend (official four colors) */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to ActiveBlue,
stringResource(id = R.string.amsat_tlm) to TlmOrange,
stringResource(id = R.string.amsat_not_heard) to NotHeardPink,
stringResource(id = R.string.amsat_conflict) to ConflictDeepOrange
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
legend.forEach { (label, color) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(2.dp))
.background(color)
)
Spacer(modifier = Modifier.width(3.dp))
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
/** Header: satellite name + 6 day dates */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = R.string.amsat_name),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(2f).padding(start = 4.dp)
)
dates.forEach { date ->
Text(
text = date,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(0.8f)
)
}
}
}
/** Satellite row: name + 6 day color blocks (official colors + report counts) */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = status.name,
fontSize = 11.sp,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(2f).padding(start = 4.dp)
)
status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != NoReportGray.value.toInt().toLong() } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
Box(
modifier = modifier
.height(24.dp)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (slot.count > 0) {
Text(
text = slot.count.toString(),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
/** Report detail dialog (3 levels: callsign/date/time/grid) */
@Composable
private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }
.mapNotNull { reports[it] }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "$statusName · ${day.dateLabel}") },
text = {
if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports))
} else {
LazyColumn(modifier = Modifier.height(320.dp)) {
items(dayReports) { report ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColorOf(report.statusText))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = report.statusText,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(thickness = 0.5.dp)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
}
)
}
private fun statusColorOf(statusText: String): Color = when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> ActiveBlue
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> TlmOrange
statusText.contains("Not Heard", ignoreCase = true) -> NotHeardPink
else -> ConflictDeepOrange
}
private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance()
cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format(
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
)
}
@@ -0,0 +1,84 @@
package com.rtbishop.look4sat.feature.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SatStatusUiState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L,
val error: String? = null
)
class SatStatusViewModel(
private val container: IMainContainer
) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState
init {
fetch()
}
fun fetch() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
}
}
} catch (exception: Exception) {
_uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
}
}
}
}
fun refresh() {
if (_uiState.value.isRefreshing) return
_uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch {
try {
val page = container.amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) {
_uiState.update {
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
}
} catch (exception: Exception) {
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
}
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
>
<path
android:fillColor="#FF000000"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
+7 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "443"
appVersionCode = "459"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3"
appVersionName = "4.5.6"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -11,6 +11,11 @@ minSdk = "24"
jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat"
# Fork application ID: official app uses com.rtbishop.look4sat with a different
# signing cert. Installing a fork with the same applicationId would conflict
# with the official build (signature mismatch / cannot overwrite), so the fork
# ships under its own applicationId suffix.
applicationId = "com.rtbishop.look4sat.bg7nta"
android-gradle-plugin = "9.3.1"
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more