Compare commits

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

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

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

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

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

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

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

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

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

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

Fork-specific features preserved: builtinTypesByUrl reverse lookup,
satnogs migration, map-type data source structure.
2026-08-29 16:21:59 +08:00
atsunatsu 346803fe6e fix: normalize Maidenhead grid input to uppercase in mutual pass
- onStationAGrid/onStationBGrid store the trimmed upper-cased grid instead
  of the raw input (ol63 used to stay lowercase in the UI)
- drop the local latLonToGrid/gridToLatLon copies and reuse the domain
  QthConverter, removing the drift risk between two implementations
- inject computeDispatcher (default Dispatchers.Default, no behaviour
  change) so the pass computation is unit-testable on a test scheduler
- add MutualViewModelTest (15 cases): init prefill, grid/lat-lon linkage,
  error paths, main-list reuse, fallback search, sorting, clearError
2026-08-29 10:55:15 +08:00
atsunatsu 47b87a1604 refactor: consolidate Maidenhead conversion into domain QthConverter (4/6/8-char)
- QthConverter.qthToPosition now accepts 4/6/8-char locators (previously
  6-char only: 4-char returned null, 8-char was silently truncated)
- positionToQth now emits upper-case sub-square letters (IO91VL) so all
  callers render grids consistently
- Extended QthConverterTest: 4/6/8-char parsing, odd-length and out-of-range
  rejection, lower-case input normalisation
2026-08-29 10:54:55 +08:00
159 changed files with 19566 additions and 348 deletions

No files matched your search

+7 -2
View File
@@ -6,11 +6,16 @@
## 本仓库特色功能
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
## 上游仓库
+21 -3
View File
@@ -1,18 +1,36 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Release signing credentials live in local.properties (gitignored) or
// environment variables — never hardcode passwords in VCS.
val releaseProps = Properties().apply {
val propsFile = rootProject.file("local.properties")
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
}
fun releaseCred(name: String): String =
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
android {
namespace = libs.versions.packageName.get()
defaultConfig {
applicationId = "cn.ba7opf.look4sat"
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
ndk {
abiFilters.add("armeabi-v7a")
abiFilters.add("arm64-v8a")
}
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
}
}
buildTypes {
+11
View File
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MainApplication"
@@ -45,5 +46,15 @@
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -51,7 +51,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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
@@ -94,16 +93,11 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
var openMapRequest by remember { mutableIntStateOf(0) }
LaunchedEffect(deeplink) {
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToMap: () -> Unit = {
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
openMapRequest += 1
}
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
@@ -124,9 +118,7 @@ fun NavRoot(deeplink: String? = null) {
entryProvider = entryProvider {
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar,
openMapRequest = openMapRequest,
onOpenMapRequestHandled = { openMapRequest = 0 }
navigateToRadar = navigateToRadar
)
}
entry<RadarDestination> {
@@ -134,7 +126,7 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
RadarDestination(navigateUp = navigateBack)
}
}
}
@@ -143,25 +135,16 @@ fun NavRoot(deeplink: String? = null) {
@Composable
fun MainScreen(
navigateToRadar: () -> Unit = {},
openMapRequest: Int = 0,
onOpenMapRequestHandled: () -> Unit = {}
navigateToRadar: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val navigateToMap: () -> Unit = {
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Map)
}
LaunchedEffect(openMapRequest) {
if (openMapRequest > 0) {
navigateToMap()
onOpenMapRequestHandled()
}
}
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
// Map sits at position 4 from the left, matching the upstream nav order
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
// appended before Settings.
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -185,7 +168,7 @@ fun MainScreen(
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Map -> screen is Screen.Passes
is Screen.Map -> screen is Screen.Map
is Screen.AMSAT -> screen is Screen.AMSAT
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Settings -> screen is Screen.Settings
@@ -196,7 +179,7 @@ fun MainScreen(
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
@@ -236,8 +219,7 @@ fun MainScreen(
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
},
navigateToMap = navigateToMap
}
)
}
entry<Screen.Map> {
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="." />
</paths>
@@ -60,4 +60,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios")
suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
}
@@ -19,19 +19,21 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao
}
//val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(database: SupportSQLiteDatabase) {
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
// database.execSQL("DROP TABLE entries")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
// }
//}
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
* transceivers that were imported from a file, so that remote updates leave them alone. */
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
}
}
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
val bstar: Double,
val ndot: Double = 0.0
)
@@ -32,5 +32,7 @@ data class SatRadio(
val uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -24,6 +24,7 @@ import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
@@ -35,6 +36,9 @@ import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -47,10 +51,12 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -79,6 +85,9 @@ class MainContainer(private val context: Context) : IMainContainer {
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -159,7 +168,10 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build()
val database = builder
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration(false)
.build()
return LocalSource(database.look4SatDao())
}
@@ -199,11 +199,10 @@ class AmSatRepository(
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val 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 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
formatDayLabel(utc)
}
return names.map { name ->
val slots = (0 until 36).map { slotIdx ->
@@ -228,6 +227,15 @@ class AmSatRepository(
}
}
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
@@ -58,7 +58,7 @@ class DatabaseRepo(
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -67,31 +67,54 @@ class DatabaseRepo(
override suspend fun updateFromRemote() = withContext(dispatcher) {
val settings = settingsRepo.dataSourcesSettings.value
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl).distinct()
// Keep the raw URL as the status key (matches what the settings UI
// displays) while requesting with the normalized URL.
val tleUrls = settings.satelliteUrls
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
.map { it to normalizeUrl(it) }
.distinctBy { it.second }
val radioUrls = settings.transceiversUrls
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
.map { it to normalizeUrl(it) }
.distinctBy { it.second }
val builtinTypesByUrl = Sources.satelliteDataUrls
.filterValues { it.isNotBlank() }
.mapValues { normalizeUrl(it.value) }
.entries
.associate { (type, url) -> url to type }
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
// launch all network requests concurrently
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// launch all network requests concurrently, keeping the raw url as key for status reporting
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
// report the HTTP status code of every source (200, 404, ...)
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val entries = stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
val type = builtinTypesByUrl[url] ?: customSourceType
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries
}.distinctBy { it.catnum }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
// insert parsed data into the database.
// Transceivers are a full snapshot: sources publish active entries only, so a retired
// transceiver simply disappears from the feed and has to be dropped locally as well.
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
// would bring them back if they were replaced here.
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
setUpdateSuccessful(System.currentTimeMillis())
}
@@ -0,0 +1,366 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.net.URLEncoder
import java.io.IOException
import javax.net.ssl.SSLException
/**
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
*
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
* truncated/expanded to the 4-char form used by the map overlay.
*
* ARRL rate-limits the report endpoint: one download in progress per user id, and
* frequent full-report pulls get refused. Failures are reported with an explicit
* cause (see LoTWResult) so the UI can tell the user what to do next.
*/
class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
internal fun toResult(e: Throwable): LoTWResult = when (e) {
is CredentialsException -> LoTWResult.BadCredentials
is RateLimitException -> LoTWResult.RateLimited
is TimeoutException -> LoTWResult.Timeout
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
}
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
private fun parseBoth(
body: String
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
val grids = parseConfirmedGrids(body) ?: return null
val qsos = parseConfirmedGridQsos(body) ?: return null
val roamed = parseRoamedGrids(body) ?: return null
return Triple(grids, qsos, roamed)
}
private fun fetchReportBody(callsign: String, password: String): Result<String> {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
// Without it, LoTW applies a "system supplied default" since-date and
// only returns confirmations newer than the account's last query —
// subsequent syncs would return an empty/incremental report.
val since = "2000-01-01"
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
}
return try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept-Encoding", "gzip")
connection.instanceFollowRedirects = true
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
// report pull with HTTP 503 "Page request limit".
return if (code == 503 || code == 429) Result.failure(RateLimitException())
else Result.failure(IOException("HTTP $code"))
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
connection.disconnect()
when {
// Login failure: HTTP 200 + HTML login page with the error text.
body.contains("incorrect", ignoreCase = true) &&
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
// Any other non-ADIF body: rate-limit refusal / server error page.
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
else -> Result.success(body)
}
} catch (e: SocketTimeoutException) {
Result.failure(TimeoutException(e.message ?: "timed out"))
} catch (e: SSLException) {
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
} catch (e: Exception) {
println("LoTWRepository fetch failure: $e")
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
}
}
internal class CredentialsException : Exception("bad callsign/password")
internal class RateLimitException : Exception("report refused (rate limit / server error)")
internal class TimeoutException(message: String) : Exception(message)
internal fun parseConfirmedGridQsos(
body: String
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
// One ADIF record = fields up to <EOR>. We buffer the fields we care
// about, then emit a GridQso on <EOR> when the record is a satellite
// QSO (PROP_MODE=SAT) carrying a grid.
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
var propMode: String? = null
var call = ""
var qsoDate = ""
var timeOn = ""
var satName = ""
var mode = ""
var bandUp = ""
var bandDown = ""
var dxcc: Int? = null
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
call = call, epochMs = epochMs, satName = satName,
mode = mode, bandUp = bandUp, bandDown = bandDown,
dxcc = dxcc, country = country, cqz = cqz, state = state,
myGrid = myGrid
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
}
}
fun resetRecord() {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
gridsInRecord.clear()
}
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
emitRecord()
resetRecord()
}
line.startsWith("<PROP_MODE:") ->
propMode = adifValue(line).uppercase()
line.startsWith("<CALL:") ->
call = adifValue(line).uppercase()
line.startsWith("<QSO_DATE:") ->
qsoDate = adifValue(line)
line.startsWith("<TIME_ON:") ->
timeOn = adifValue(line)
line.startsWith("<SAT_NAME:") ->
satName = adifValue(line)
line.startsWith("<MODE:") ->
mode = adifValue(line)
line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<DXCC:") ->
dxcc = adifValue(line).toIntOrNull()
line.startsWith("<COUNTRY:") ->
country = adifValue(line).ifBlank { null }
line.startsWith("<CQZ:") ->
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_GRIDSQUARE:") -> {
// Own-station grid (must not be confused with GRIDSQUARE —
// the opposite station's grid). Recorded per QSO so awards
// can be counted per operated grid.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
// several squares; split and keep every 4-char field.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) gridsInRecord.add(field.take(4))
}
}
}
}
return result
}
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
private fun adifValue(line: String): String =
line.substringAfter('>').substringBefore("E<").trim()
/**
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
* statistics match on the short CODE only (China 2-letter province pinyin,
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
* suffix here once, storing the clean code for every downstream consumer
* (persistence, AwardCalculator).
*/
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
val d = date.trim()
val t = time.trim().padEnd(6, '0').take(6)
val fmt = java.time.format.DateTimeFormatterBuilder()
.appendPattern("yyyyMMddHHmmss")
.toFormatter()
.withZone(java.time.ZoneOffset.UTC)
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
} catch (_: Exception) {
0L
}
internal fun parseConfirmedGrids(body: String): Set<String>? {
// LoTW answers with ADIF text; on bad credentials it returns a short error
// page without an <eoh> header terminator. Real reports always carry <eoh>
// (LoTW writes it lowercase). Match case-insensitively to be safe.
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
// ADIF fields of one QSO record span multiple lines and are terminated by
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
// in terrestrial contacts.
//
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
// sequential gate ("only add while propMode == SAT") silently drops every
// grid on real reports. Buffer the record and decide at <EOR> instead.
var propMode: String? = null
var pendingGrids = mutableListOf<String>()
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT") grids.addAll(pendingGrids)
propMode = null
pendingGrids = mutableListOf()
}
line.startsWith("<PROP_MODE:") -> {
propMode = adifValue(line).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated PAIR of grids
// ("EN52en,EN53fa") for contacts spanning two squares —
// split on ',' and take the 4-char field of each, or the
// second grid is silently dropped.
val value = adifValue(line)
value.split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) pendingGrids.add(field.take(4))
}
}
}
}
return grids
}
/**
* Distinct 4-char gridsquares the account itself operated from, taken from
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
* "roamed / activated" set shown as blue stripes on the map — the grids
* where the operator's own station was located during confirmed QSOs (a
* rover may log several distinct grids; the home grid is included too).
* Returns null when the body is not an ADIF report (mirrors the other parsers).
*/
internal fun parseRoamedGrids(body: String): Set<String>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
// within a record. A sequential gate ("only add while propMode == SAT")
// would silently drop every own grid on real reports; buffer the record
// and decide at <EOR> instead.
var propMode: String? = null
var myGrid: String? = null
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
propMode = null
myGrid = null
}
line.startsWith("<PROP_MODE:") -> {
propMode = adifValue(line).uppercase()
}
line.startsWith("<MY_GRIDSQUARE:") -> {
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
// opposite station's grid) — only own-station grids count.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4)
}
}
}
return grids
}
private companion object {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
}
}
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
@@ -66,8 +68,11 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
settingsRepo.stationPosition,
// Recalculate passes when the UTC display toggle changes, so an existing
// AOS time window is reapplied in the newly selected timezone.
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
@@ -175,7 +180,16 @@ class SatelliteRepo(
aosEndMinute: Int,
invertAosTimeWindow: Boolean
): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
// Follow the UTC display toggle: with UTC enabled the window is interpreted
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
// shows UTC times while the filter silently uses local time — the two
// disagree by the timezone offset (e.g. 8h for China).
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
TimeZone.getTimeZone("UTC")
} else {
TimeZone.getDefault()
}
val offsetMillis = tz.getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) {
@@ -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.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -81,6 +82,7 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -98,6 +100,8 @@ class SettingsRepo(
private val keyTransceiversUrl = "transceiversUrl"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val separatorComma = ","
private val separatorUrl = "\n"
@@ -135,7 +139,159 @@ class SettingsRepo(
}
//endregion
//region # Passes filter settings
//region # Wavelog worked-grids settings
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogToken = "wavelogToken"
private val keyWorkedGrids = "workedGrids"
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
override fun updateWavelogSettings(settings: WavelogSettings) {
preferences.edit {
putString(keyWavelogUrl, settings.url.trim())
putString(keyWavelogToken, settings.token.trim())
}
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
}
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
token = preferences.getString(keyWavelogToken, null).orEmpty()
)
override fun getWorkedGrids(): Set<String> {
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setWorkedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyWorkedGrids, array.toString()) }
}
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
private val keyWorkedGridQsos = "workedGridQsos"
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
for (grid in root.keys()) {
val array = root.optJSONArray(grid) ?: continue
val list = (0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
com.rtbishop.look4sat.core.domain.model.GridQso(
call = o.optString("c"),
epochMs = o.optLong("t"),
satName = o.optString("s"),
mode = o.optString("m"),
bandUp = o.optString("bu"),
bandDown = o.optString("bd"),
// New award fields: absent in pre-award data -> null.
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
country = o.optString("cty").ifBlank { null },
cqz = o.optInt("cq", 0).takeIf { it > 0 },
state = o.optString("st").ifBlank { null },
// Own-grid field: absent in pre-myGrid data -> null.
myGrid = o.optString("mg").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
}
result
} catch (_: Exception) {
emptyMap()
}
}
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
val root = org.json.JSONObject()
for ((grid, list) in qsos) {
val array = org.json.JSONArray()
for (q in list) {
array.put(
org.json.JSONObject()
.put("c", q.call)
.put("t", q.epochMs)
.put("s", q.satName)
.put("m", q.mode)
.put("bu", q.bandUp)
.put("bd", q.bandDown)
.put("dx", q.dxcc ?: 0)
.put("cty", q.country ?: "")
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
)
}
root.put(grid, array)
}
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
}
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
// satellite QSOs only). Stored as a JSONArray like workedGrids.
private val keyRoamedGrids = "roamedGrids"
override fun getRoamedGrids(): Set<String> {
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setRoamedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
get() = _lotwSettings
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
preferences.edit {
putString(keyLoTWCall, settings.callsign.trim().uppercase())
putString(keyLoTWPass, settings.password)
}
_lotwSettings.value = settings.copy(
callsign = settings.callsign.trim().uppercase(),
password = settings.password
)
}
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
password = preferences.getString(keyLoTWPass, null).orEmpty()
)
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -369,6 +525,7 @@ class SettingsRepo(
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -386,6 +543,7 @@ class SettingsRepo(
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -399,12 +557,21 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
// Normalize the enabled lists so they are positionally aligned with the URL lists.
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
// invariant intact even when a default empty list is used to construct the model.
val normalized = settings.copy(
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit {
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl))
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
}
_dataSourcesSettings.value = settings
_dataSourcesSettings.value = normalized
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
@@ -419,9 +586,27 @@ class SettingsRepo(
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTransceivers,
legacyUrlKey = keyTransceiversUrl
)
),
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
)
/** Read the persisted per-source enabled flags (empty when never stored). */
private fun readEnabledFlags(key: String): List<Boolean> {
return preferences.getString(key, null)
?.split(separatorComma)
?.mapNotNull { it.trim() }
?.filter { it == "true" || it == "false" }
?.map { it == "true" }
?: emptyList()
}
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
private fun getDataSourceUrls(
key: String,
defaultUrls: List<String>,
@@ -467,6 +652,15 @@ class SettingsRepo(
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
@@ -0,0 +1,106 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class UpdateRepository(
private val remoteSource: IRemoteSource
) : IUpdateRepository {
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
// Use the GitHub releases web page instead of the REST API endpoint:
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
// failures. The web endpoint redirects to the latest tag with no such limit.
// When GitHub is unreachable (common on mainland-China networks without a
// proxy), fall back to community GitHub accelerator mirrors that proxy the
// same page; each mirror resolves the identical tag and asset URLs.
for (baseUrl in LATEST_RELEASE_URLS) {
val result = remoteSource.getNetworkStream(baseUrl)
val stream = result.stream ?: continue
val parsed = try {
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
} catch (e: Exception) {
println("UpdateRepository parse failure: $e")
null
}
if (parsed != null) return@withContext parsed
}
null
}
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
if (url.isBlank()) return@withContext false
val result = remoteSource.getNetworkStream(url)
val stream = result.stream ?: return@withContext false
try {
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
true
} catch (e: Exception) {
println("UpdateRepository download failure: $e")
false
}
}
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
// The redirect target is the latest release's tag page. Extract the tag
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
// unambiguous. The <title> also holds the release name, but that is free
// text and cannot be used to build the asset URL.
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
?.groupValues?.get(1) ?: return null
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
?.groupValues?.get(1) ?: return null
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// Release notes live in the page's markdown-body section (first occurrence
// is the release description). Strip HTML tags for plain-text display.
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1) ?: ""
val body = raw.replace(Regex("<[^>]+>"), "")
.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.trim()
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
// versioned filename, no "-release" suffix). When the page was fetched
// through an accelerator mirror (https://<mirror>/https://github.com/...),
// download through the same mirror — raw github.com is unreachable on the
// networks that needed the mirror in the first place.
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
val apkUrl = if (mirrorPrefix.isEmpty()) {
"$DOWNLOAD_BASE_URL/$tag/$apkName"
} else {
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
}
return LatestRelease(
versionTag = tag,
title = title,
body = body,
apkUrl = apkUrl
)
}
private companion object {
// Web pages are used instead of api.github.com to avoid the 60 req/hour
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
// tried first; if it is unreachable (no proxy on mainland networks) the
// accelerator mirrors are tried in order. Download URLs always point at
// the mirrors too — raw github.com release downloads are equally blocked
// without a proxy, so a mirror-resolved tag must be downloaded via the
// same mirror.
val LATEST_RELEASE_URLS = listOf(
"https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
)
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
}
}
@@ -0,0 +1,97 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
* POST {base}/index.php/api/logbook_get_worked_grids
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
*
* The token field carries "<api_key>:<logbook_id>" so one credential line
* configures both values (the dialog explains this to the user).
*/
class WavelogRepository : IWavelogRepository {
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
val parts = token.trim().split(":")
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
try {
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
val body = JSONObject().apply {
put("key", apiKey)
put("logbook_id", logbookId)
}.toString().toByteArray()
connection.outputStream.use { it.write(body) }
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val response = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(response)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
for (i in 0 until array.length()) {
val grid = array.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
) {
result.add(grid)
}
}
result
} catch (_: Exception) {
null
}
}
}
@@ -51,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,20 +73,22 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios()
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.insertRadios(radios.toFrameworkRadios())
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
}
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
)
private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
)
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -20,6 +20,7 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -44,20 +45,26 @@ class RemoteSource(
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
val code = response.code
response.close()
return@withContext null
return@withContext NetworkResult(code, null)
}
// Return the body stream directly as the caller is responsible for closing it.
// Closing the stream returns the connection to OkHttp's pool.
response.body.byteStream().buffered()
val body = response.body
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
}
}
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
@@ -88,7 +89,7 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
override suspend fun getAmSatCatalog(): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -31,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -149,7 +151,8 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getNetworkStream(url: String): NetworkResult =
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
override suspend fun getAmSatCatalog(): String? = null
@@ -182,8 +185,12 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) {
insertedRadios += radios
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { !it.isCustom }
}
override suspend fun deleteRadios() {
@@ -212,7 +219,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
OtherSettings(
false, false, false, false, false, false, false,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
@@ -253,6 +263,12 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings
}
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override fun updateDataSourcesStatus(status: Map<String, Int>) {
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
@@ -262,6 +278,36 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
private val workedGrids = MutableStateFlow(emptySet<String>())
override fun setWorkedGrids(grids: Set<String>) {
workedGrids.value = grids
}
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
private val roamedGrids = MutableStateFlow(emptySet<String>())
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -0,0 +1,312 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWRepositoryTest {
private val repo = LoTWRepository()
private fun report(vararg records: String): String =
buildString {
append("<ADIF_VERS:5>3.1.4\n")
append("<PROGRAMID:8>look4sat\n")
append("<eoh>\n")
records.forEach { append(it).append("\n") }
}
@Test
fun parseRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseCollectsOnlySatelliteGrids() {
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
val satQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260820\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
val groundQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260821\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("NL47"), result)
}
@Test
fun parseSupportsVuccGridPairsAndSixCharGrids() {
val satQso = "<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso))
assertEquals(setOf("OM60", "EN52", "EN53"), result)
}
@Test
fun parsePropModeDoesNotLeakAcrossRecords() {
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
// record must not inherit the satellite flag after its own <EOR>.
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("OL62"), result)
}
@Test
fun parseHandlesAlphabeticalFieldOrder() {
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
// sequential gate (add only while propMode == SAT) dropped every grid on
// real reports — the buffered parser must collect them regardless of
// field order and filter at <EOR>.
val satQso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>NL47\n" +
"<MODE:3>FM\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
"<EOR>\n"
val groundQso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>PM95\n" +
"<QSO_DATE:8>20260821\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("NL47", "EN52", "EN53"), result)
}
@Test
fun parseReturnsEmptySetForReportWithoutGrids() {
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
}
// region parseConfirmedGridQsos
private val satQsoFull = "<CALL:5>A50QO\n" +
"<QSO_DATE:8>20260820\n" +
"<TIME_ON:6>113045\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<MODE:2>CW\n" +
"<BAND:3>70CM\n" +
"<BAND_RX:3>2M\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
@Test
fun parseQsosExtractsSatelliteDetail() {
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
val qsos = result["NL47"]!!
assertEquals(1, qsos.size)
val qso = qsos[0]
assertEquals("A50QO", qso.call)
assertEquals("FO-29", qso.satName)
assertEquals("CW", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("V/U", qso.bandLabel)
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
}
@Test
fun parseQsosExcludesGroundQsos() {
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
assertEquals(1, result["NL47"]!!.size)
}
@Test
fun parseQsosVuccPairFeedsBothGrids() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
assertEquals(setOf("EN52", "EN53"), result.keys)
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
}
@Test
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
// VUCC allows up to four grids in one contact.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
result.values.forEach { assertEquals(1, it.size) }
}
@Test
fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseQsosCapturesOwnGridPerQso() {
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
// alphabetically); the per-record buffer must still attach it to the QSO.
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
assertEquals("OL62", result["NL47"]!!.first().myGrid)
assertEquals("OL72", result["OK48"]!!.first().myGrid)
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@Test
fun parseRoamedGridsCollectsOwnStationGrids() {
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
// operated from (the "roamed/activated" set shown as blue stripes).
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
}
@Test
fun parseRoamedGridsIgnoresGroundQsos() {
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
}
@Test
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsTruncatesSixCharToFour() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
// while propMode == SAT) silently returned an EMPTY set on real reports —
// this is the exact bug that made the blue roamed stripes never appear.
val qso = "<CALL:6>BA7OPF\n" +
"<GRIDSQUARE:4>PM95\n" +
"<MY_GRIDSQUARE:6>OL72XX\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>IO-86\n" +
"<EOR>\n"
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsRejectsBodyWithoutEoh() {
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@Test
fun failureClassificationMapsEachException() {
// LoTWRepository.toResult() must keep each failure cause distinct so
// the UI can show a specific message per cause.
assertEquals(
LoTWResult.BadCredentials,
repo.toResult(LoTWRepository.CredentialsException())
)
assertEquals(
LoTWResult.RateLimited,
repo.toResult(LoTWRepository.RateLimitException())
)
assertEquals(
LoTWResult.Timeout,
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
)
val net = repo.toResult(java.io.IOException("HTTP 500"))
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
}
@Test
fun parseQsosExtractsAwardFields() {
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
// the parser must strip the name suffix and keep the bare code.
val qso = "<CALL:5>BG7XYZ\n" +
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["OL62"]!!.first()
assertEquals(318, parsed.dxcc)
assertEquals("CHINA", parsed.country)
assertEquals(24, parsed.cqz)
assertEquals("GD", parsed.state)
}
@Test
fun parseQsosStripsStateNameSuffixForAllEntities() {
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
// the 2-digit code). US states also arrive as "CODE // Name".
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
assertEquals("34", result["PM95"]!!.first().state)
assertEquals("CA", result["EM40"]!!.first().state)
}
@Test
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["PM95"]!!.first()
assertNull(parsed.dxcc)
assertNull(parsed.country)
assertNull(parsed.cqz)
assertNull(parsed.state)
}
@Test
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
assertNull(result["PM95"]!!.first().dxcc)
assertNull(result["PM95"]!!.first().cqz)
assertNull(result["PM95"]!!.first().state)
}
// endregion
}
@@ -0,0 +1,154 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -0,0 +1,114 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -0,0 +1,165 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -0,0 +1,102 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -0,0 +1,87 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -0,0 +1,48 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
*/
internal class CwFilter {
private var z1 = 0f
companion object {
private const val PI_F = 3.141592653589793f
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
@@ -0,0 +1,54 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -0,0 +1,53 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -0,0 +1,170 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -0,0 +1,194 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
/**
* Award progress computed from the confirmed-QSO store.
*
* @param type which award
* @param workedKeys matching keys for the boundary assets: each is compared
* against the region "code" field of the award's GeoJSON
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
* @param count distinct worked units (grids / entities / provinces / ...)
* @param target units required for the award
*/
data class AwardProgress(
val type: AwardType,
val workedKeys: Set<String>,
val count: Int,
val target: Int
)
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
object AwardTargets {
const val DXCC = 100
const val VUCC = 100
const val WAPC = 34
const val WAJA = 47
const val WAZ = 40
const val WAS = 50
}
/**
* Derives six-award progress from the confirmed QSO store.
*
* Award rules (ADIF / award-program semantics):
* - VUCC: distinct 4-char gridsquares.
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
* to a callsign-prefix guess when LoTW omitted it).
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
* - WAZ: distinct CQ zones.
* - WAS: distinct US states (STATE abbreviations).
*
* The ADIF STATE field's meaning depends on the DXCC entity
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
*/
object AwardCalculator {
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
val all = qsosByGrid.values.flatten()
return listOf(
AwardProgress(
AwardType.VUCC,
workedKeys = qsosByGrid.keys,
count = qsosByGrid.size,
target = AwardTargets.VUCC
),
calculateDxcc(all),
calculateWapc(all),
calculateWaja(all),
calculateWaz(all),
calculateWas(all)
)
}
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
val code = q.dxcc ?: prefixEntityCode(q.call)
if (code != null) keys.add(code.toString())
}
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
}
private fun calculateWapc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
when (entityOf(q)) {
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
Entity.HONG_KONG -> keys.add(HK_CODE)
Entity.MACAO -> keys.add(MO_CODE)
Entity.TAIWAN -> keys.add(TW_CODE)
else -> {}
}
}
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
}
private fun calculateWaja(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.JAPAN) continue
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
}
private fun calculateWaz(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
}
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
}
private fun calculateWas(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.USA) continue
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
}
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
318 -> Entity.CHINA
321 -> Entity.HONG_KONG
152 -> Entity.MACAO
386 -> Entity.TAIWAN
339 -> Entity.JAPAN
291 -> Entity.USA
else -> prefixEntity(q.call)
}
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
private fun prefixEntity(call: String): Entity {
val c = call.uppercase().substringBefore('/')
return when {
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
c.startsWith("VR") -> Entity.HONG_KONG
c.startsWith("XX9") -> Entity.MACAO
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
else -> Entity.OTHER
}
}
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
private fun prefixEntityCode(call: String): Int? {
return when (prefixEntity(call)) {
Entity.CHINA -> 318
Entity.HONG_KONG -> 321
Entity.MACAO -> 152
Entity.TAIWAN -> 386
Entity.JAPAN -> 339
Entity.USA -> 291
else -> null
}
}
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
private val JP_PREFIXES =
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
private val US_PREFIXES =
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
private val CN_STATES = setOf(
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
)
private val US_STATES = setOf(
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
"VA", "WA", "WV", "WI", "WY"
)
private const val HK_CODE = "HK"
private const val MO_CODE = "MO"
private const val TW_CODE = "TW"
}
@@ -0,0 +1,78 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
/**
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
* so the map can show, for any worked grid, which callsigns were worked there
* and when.
*
* @param call opposite station callsign (as logged by the opposite op)
* @param epochMs QSO date+time in UTC milliseconds
* @param satName satellite name (e.g. "FO-29")
* @param mode ADIF mode (FM / CW / SSB ...)
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
* LoTW did not include it
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
* <DXCC>), or null when LoTW omitted it
* @param country DXCC entity name (ADIF <COUNTRY>), or null
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
* @param state Primary administrative subdivision of the opposite station
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
* China -> province code ("GD"), Japan -> prefecture number
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
*/
data class GridQso(
val call: String,
val epochMs: Long,
val satName: String,
val mode: String,
val bandUp: String,
val bandDown: String,
val dxcc: Int? = null,
val country: String? = null,
val cqz: Int? = null,
val state: String? = null,
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
val myGrid: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
get() {
val up = bandAbbrev(bandUp)
val down = bandAbbrev(bandDown)
return when {
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
up.isNotEmpty() -> up
else -> ""
}
}
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
"70CM" -> "U"
"2M" -> "V"
"10M" -> "A"
"6M" -> "V"
"1.2G" -> "L"
"2.4G" -> "S"
"23CM" -> "L"
"13CM" -> "S"
else -> band.trim().uppercase()
}
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.model
/** Latest release info fetched from the fork's GitHub releases API. */
data class LatestRelease(
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
val title: String, // release name, may be empty
val body: String, // release notes / update description
val apkUrl: String? // first .apk asset download URL, null if absent
)
@@ -32,5 +32,7 @@ data class SatRadio(
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
@SerialName("norad_cat_id") val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -59,6 +59,7 @@ data class OtherSettings(
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -68,8 +69,27 @@ data class OtherSettings(
data class DataSourcesSettings(
val satelliteUrls: List<String>,
val transceiversUrls: List<String>
)
val transceiversUrls: List<String>,
val satelliteEnabled: List<Boolean> = emptyList(),
val transceiversEnabled: List<Boolean> = emptyList()
) {
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class WavelogSettings(
val url: String = "",
val token: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
data class LoTWSettings(
val callsign: String = "",
val password: String = ""
) {
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
}
data class RadioControlSettings(
val enabled: Boolean,
@@ -0,0 +1,38 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
interface ILoTWRepository {
/**
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
* Returns the 4-char grid set with an explicit failure cause on error.
*/
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
/**
* Same report, but keeps the per-QSO detail of every confirmed satellite
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
* gridsquare. Failure cause is kept explicit.
*/
suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): LoTWResult
}
@@ -31,6 +31,9 @@ interface IMainContainer {
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -73,6 +74,11 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -87,4 +93,22 @@ interface ISettingsRepo {
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
//region # Wavelog worked-grids settings
val wavelogSettings: StateFlow<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
//endregion
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
//endregion
}
@@ -0,0 +1,12 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.LatestRelease
import java.io.File
interface IUpdateRepository {
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
suspend fun getLatestRelease(): LatestRelease?
/** Download the release APK from [url] into [dest]; true on success. */
suspend fun downloadApk(url: String, dest: File): Boolean
}
@@ -0,0 +1,28 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
interface IWavelogRepository {
/**
* Fetch worked grids for the configured Wavelog URL/token.
* Returns the grid set, or null on any failure.
*/
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
}
@@ -0,0 +1,48 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
sealed class LoTWResult {
/** Report downloaded and parsed successfully. */
data class Success(
val grids: Set<String>,
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
/** Distinct 4-char gridsquares the account itself operated from
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
val roamedGrids: Set<String> = emptySet()
) : LoTWResult()
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
data object BadCredentials : LoTWResult()
/**
* Report endpoint refused the request without a usable error page. LoTW
* limits downloads to one in progress per user id and flags accounts that
* pull the full report too often; the server answers those with a bare
* non-ADIF body (no <eoh>).
*/
data object RateLimited : LoTWResult()
/** Connect/read timed out — typically a slow route to the ARRL servers. */
data object Timeout : LoTWResult()
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
@@ -31,6 +31,9 @@ interface ILocalSource {
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
/** Delete all non-custom transceivers, keeping manually imported ones. */
suspend fun deleteManagedRadios()
suspend fun deleteRadios()
}
@@ -19,9 +19,17 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream
/** Result of a network download: the HTTP status code and the response body stream. */
data class NetworkResult(val code: Int, val stream: InputStream?) {
companion object {
/** Code used when a request fails before any HTTP response is received. */
const val CONNECTION_ERROR = -1
}
}
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
@@ -19,6 +19,8 @@ package com.rtbishop.look4sat.core.domain.source
object Sources {
val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -46,7 +48,6 @@ object Sources {
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
@@ -19,20 +19,43 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
* square / subsquare / extended-square centre. Longer input is truncated to
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
* null.
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val trimmedQth = locator.trim().uppercase().take(8)
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
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)
// Start from the 4-char square centre, refine for 6/8-char subsquares.
var longitude = lonFirst + lonSecond + 1.0
var latitude = latFirst + latSecond + 0.5
if (trimmedQth.length >= 6) {
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
if (trimmedQth.length >= 8) {
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
longitude += lonExt + 15.0 / 3600.0
latitude += latExt + 7.5 / 3600.0
}
}
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
}
/**
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
* square and subsquare letters). Returns null for out-of-range positions.
*/
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
@@ -41,8 +64,8 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().uppercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
@@ -51,5 +74,11 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
return when (locator.length) {
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
else -> false
}
}
@@ -0,0 +1,48 @@
package com.rtbishop.look4sat.core.domain.utility
/**
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
*
* The comparison is done on the numeric version segments first; when the base versions are
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
* are compared as a sequence, so multi-level suffixes work correctly:
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
*/
object VersionComparator {
/** True when [candidate] is a newer version than [current]. */
fun isNewer(candidate: String, current: String): Boolean {
val cand = parse(candidate)
val curr = parse(current)
val maxLen = maxOf(cand.first.size, curr.first.size)
for (i in 0 until maxLen) {
val c = cand.first.getOrElse(i) { 0 }
val k = curr.first.getOrElse(i) { 0 }
if (c != k) return c > k
}
val maxBuild = maxOf(cand.second.size, curr.second.size)
for (i in 0 until maxBuild) {
val c = cand.second.getOrElse(i) { 0 }
val k = curr.second.getOrElse(i) { 0 }
if (c != k) return c > k
}
return false
}
/**
* Parses a version string into (numeric base segments, numeric suffix segments).
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
*/
fun parse(version: String): Pair<List<Int>, List<Int>> {
val cleaned = version.trim().removePrefix("v")
val dashIndex = cleaned.indexOf('-')
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
return base to build
}
}
@@ -1,20 +1,3 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -22,25 +5,34 @@ import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import org.junit.Test
class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
// 8-char locator -> centre of the extended square (higher precision)
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
// 4-char locator -> centre of the 2deg x 1deg square
result = qthToPosition("JN58")
assert(result?.latitude == 48.5 && result.longitude == 11.0)
// lowercase input is accepted and normalised
result = qthToPosition("ol63pd")
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("JN5") == null) // odd length
assert(qthToPosition("JN58Z") == null) // 5 chars
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
}
@Test
fun `Given valid POS returns correct QTH`() {
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
assert(positionToQth(23.13, 113.26) == "OL63PD")
}
@Test
@@ -0,0 +1,58 @@
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VersionComparatorTest {
@Test
fun `newer build number wins when base version equal`() {
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6"))
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5"))
}
@Test
fun `newer base version wins regardless of build number`() {
assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99"))
assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99"))
}
@Test
fun `older versions are not newer`() {
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6"))
assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1"))
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6"))
}
@Test
fun `v prefix is ignored`() {
assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6"))
assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1"))
}
@Test
fun `non numeric suffix without number treated as zero build`() {
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf"))
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1"))
}
@Test
fun `multi level suffixes compare correctly`() {
// The .9.1 hotfix case that exposed the old single-build-number logic.
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
}
@Test
fun `parse extracts base and suffix segments`() {
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
}
}
@@ -0,0 +1,301 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwBayesianDecoder.morseToChar("......."))
assertNull(CwBayesianDecoder.morseToChar(""))
}
// --- FFT ---
@Test
fun fft_magnitudeSpectrum_detectsTone() {
val fft = CwFFT(256)
val sampleRate = 8000f
val freq = 700f
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
val mag = fft.magnitudeSpectrum(buffer)
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
var maxBin = 0
var maxVal = 0f
for (i in mag.indices) {
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
}
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
}
@Test
fun fft_magnitudeSpectrum_silence_isFlat() {
val fft = CwFFT(256)
val buffer = FloatArray(256) { 0f }
val mag = fft.magnitudeSpectrum(buffer)
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
}
@Test
fun fft_rejectsWrongSize() {
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
}
// --- Spectrogram ---
@Test
fun spectrogram_addSamples_updatesEnergy() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
// Feed multiple frames to stabilize energy normalization
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val col = spec.getCurrentColumn()
val peakBin = spec.findPeakBin()
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
}
@Test
fun spectrogram_findPeakBin_returnsValidBin() {
val spec = CwSpectrogram(sampleRate = 8000)
// Add multiple frames of 700 Hz tone
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val peakBin = spec.findPeakBin()
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
}
@Test
fun spectrogram_freqToBin_roundtrip() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
val bin = spec.freqToBin(freq)
val backFreq = spec.binToFreq(bin)
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
}
@Test
fun spectrogram_getBinEnergy_returnsCorrectLength() {
val spec = CwSpectrogram(sampleRate = 8000)
val energy = spec.getBinEnergy(0, 10)
assertEquals(10, energy.size)
}
@Test
fun spectrogram_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
spec.addSamples(FloatArray(256) { 1f })
spec.reset()
assertEquals(-1, spec.findPeakBin())
}
// --- Bayesian decoder ---
@Test
fun bayesian_processTone_dit() {
val decoder = CwBayesianDecoder()
// At 20 WPM, dot = 60 ms
val result = decoder.processTone(60f)
assertEquals('0', result.symbol)
assertTrue("Dit probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_dash() {
val decoder = CwBayesianDecoder()
// Dash = 3 * dot = 180 ms
val result = decoder.processTone(180f)
assertEquals('1', result.symbol)
assertTrue("Dash probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_unknown_returnsNull() {
val decoder = CwBayesianDecoder()
// Very long tone — low probability for both dit and dash
val result = decoder.processTone(5000f)
assertNull(result.symbol)
}
@Test
fun bayesian_processGap_interChar_returnsChar() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
// 3 dots = "000" = 'S'
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
assertEquals('S', char)
}
@Test
fun bayesian_processGap_wordGap_addsSpace() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char gap
// Now word gap
val space = decoder.processGap(420f) // 7 * dot
assertEquals(' ', space)
}
@Test
fun bayesian_decodedText_accumulates() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char
assertTrue(decoder.decodedText.isNotEmpty())
}
@Test
fun bayesian_reset() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f)
decoder.reset()
assertEquals("", decoder.decodedText)
}
@Test
fun bayesian_getSpeed() {
val decoder = CwBayesianDecoder()
// Send 3 dits at 20 WPM (60 ms each)
decoder.processTone(60f)
decoder.processTone(60f)
decoder.processTone(60f)
val speed = decoder.getSpeed()
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
}
// --- Channel tracker ---
@Test
fun channelTracker_initialState() {
val spec = CwSpectrogram(sampleRate = 8000)
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("No channels should be active initially", channels.isEmpty())
}
@Test
fun channelTracker_detectsTone() {
val spec = CwSpectrogram(sampleRate = 8000)
// Feed a tone
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
}
@Test
fun channelTracker_bestChannel() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
val best = tracker.getBestChannel()
assertNotNull("Best channel should exist", best)
if (best != null) assertTrue(best.frequency in 600f..800f)
}
@Test
fun channelTracker_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
tracker.reset()
assertNull(tracker.getBestChannel())
}
// --- Full decoder ---
@Test
fun decoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_processSilence_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 0f })
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun decoder_processNoise_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_processTone_doesNotCrash() {
val decoder = CwDecoder()
for (i in 0..20) {
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_reset() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 1f })
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_withFixedPitch() {
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
assertEquals(700f, decoder.estimatedPitch.value)
}
}
@@ -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.core.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AwardCalculatorTest {
private fun qso(
call: String,
dxcc: Int? = null,
cqz: Int? = null,
state: String? = null,
grid: String = "OL62"
) = GridQso(
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
)
private fun byGrid(vararg qsos: GridQso) =
qsos.groupBy { "OL62" } // all share one grid for award-math tests
@Test
fun `vucc counts distinct grid keys`() {
val qsos = mapOf(
"OL62" to listOf(qso("BG7XYZ")),
"PM95" to listOf(qso("JH0ABC")),
"OL62" to listOf(qso("BA7AAA"))
)
// map literal dedupes keys: OL62/PM95 -> 2 grids
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
}
@Test
fun `dxcc counts distinct entities from dxcc field`() {
val all = listOf(
qso("BG7XYZ", dxcc = 318),
qso("JH0ABC", dxcc = 339),
qso("YD1XYZ", dxcc = 327),
qso("BG7ABC", dxcc = 318)
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
assertEquals(3, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
}
@Test
fun `dxcc falls back to prefix when dxcc missing`() {
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"), qso("XX9A"))
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
assertEquals(4, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321", "152")))
}
@Test
fun `wapc counts mainland provinces plus hk mo tw`() {
val all = listOf(
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
qso("VR2X", dxcc = 321), // Hong Kong
qso("XX9A", dxcc = 152), // Macao
qso("BM4X", dxcc = 386) // Taiwan
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
assertEquals(5, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
}
@Test
fun `wapc ignores non-china states and missing states`() {
val all = listOf(
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
qso("BG7XYZ", dxcc = 318, state = null), // missing province
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
assertEquals(0, progress.count)
assertTrue(progress.workedKeys.isEmpty())
}
@Test
fun `waja counts japanese prefectures only`() {
val all = listOf(
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
qso("JH0ABC", dxcc = 339, state = "10"), // dup
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
qso("JE2XYZ", dxcc = 339, state = null) // missing
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
}
@Test
fun `waz counts distinct cq zones in range`() {
val all = listOf(
qso("BG7XYZ", cqz = 24),
qso("JH0ABC", cqz = 25),
qso("YD1XYZ", cqz = 28),
qso("BG7AAA", cqz = 24),
qso("BG7BBB", cqz = null),
qso("BG7CCC", cqz = 0)
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
assertEquals(3, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
}
@Test
fun `was counts us states only`() {
val all = listOf(
qso("K1ABC", dxcc = 291, state = "CA"),
qso("W2XYZ", dxcc = 291, state = "NY"),
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
}
@Test
fun `all six awards present with correct targets`() {
val progress = AwardCalculator.calculate(emptyMap())
assertEquals(6, progress.size)
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
}
}
@@ -0,0 +1,79 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -210,12 +210,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
fun CardButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true
) {
ElevatedButton(
onClick = onClick,
enabled = isEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
modifier = modifier,
@@ -316,9 +324,21 @@ fun InfoDialog(
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
acceptText: String? = null,
acceptEnabled: Boolean = true,
content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
SharedDialog(
title = title,
onDismissRequest = onCancel,
onCancel = onCancel,
onAccept = onAccept,
acceptText = acceptText,
acceptEnabled = acceptEnabled
) { _ ->
content()
}
}
@@ -329,6 +349,8 @@ fun SharedDialog(
onDismissRequest: () -> Unit,
onCancel: (() -> Unit)? = null,
onAccept: (() -> Unit)? = null,
acceptText: String? = null,
acceptEnabled: Boolean = true,
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
@@ -356,7 +378,11 @@ fun SharedDialog(
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
)
if (onAccept != null) {
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(
onClick = onAccept,
text = acceptText ?: stringResource(R.string.btn_accept),
isEnabled = acceptEnabled
)
}
}
content(padding)
@@ -0,0 +1,142 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Format a Maidenhead locator for display. Per IARU convention and the user's
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
*/
fun formatGridDisplay(grid: String): String? {
val g = grid.trim().uppercase()
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
if (g.length == 4) return g
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
}
/**
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
* great-circle bearing to the target grid, 0 deg = north), grid locator and
* distance. Shows a dimmed placeholder when no target grid is entered.
*/
@Composable
fun GridTargetChip(
grid: String?,
distanceKm: Double?,
bearingDeg: Double?,
modifier: Modifier = Modifier
) {
val configured = grid != null && distanceKm != null && bearingDeg != null
// The app's signature accent yellow (MaterialTheme primary in both themes).
val accent = MaterialTheme.colorScheme.primary
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.height(36.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp)
) {
NavigationNeedle(
bearingDeg = bearingDeg ?: 0.0,
color = if (configured) accent else placeholder,
dimmed = !configured,
modifier = Modifier.size(22.dp)
)
Text(
text = grid?.let { formatGridDisplay(it) } ?: "----",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = if (configured) accent else placeholder,
letterSpacing = 1.sp
)
Text(
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
/**
* Map-style navigation needle: slim triangular pointer whose tip points at
* [bearingDeg] (0 = north/up, clockwise positive).
*/
@Composable
private fun NavigationNeedle(
bearingDeg: Double,
color: Color,
dimmed: Boolean,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = size.minDimension / 2f
if (dimmed) {
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
}
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
val path = Path().apply {
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
lineTo(cx - r * 0.42f, cy + r * 0.55f)
lineTo(cx, cy + r * 0.28f)
lineTo(cx + r * 0.42f, cy + r * 0.55f)
close()
}
drawPath(path, color = color)
}
}
}
/** Great-circle distance in km between two positions. */
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLat = (lat2 - lat1) * rad
val dLon = (lon2 - lon1) * rad
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
}
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLon = (lon2 - lon1) * rad
val y = sin(dLon) * cos(lat2 * rad)
val x = cos(lat1 * rad) * sin(lat2 * rad) -
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
return (Math.atan2(y, x) / rad + 360.0) % 360.0
}
@@ -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="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
</vector>
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_switch_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -149,6 +149,7 @@
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -192,6 +192,7 @@
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_switch_map_grid">Haritada Maidenhead ızgarasını göster</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -99,9 +99,16 @@
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
<string name="radar_cw_decoder">CW 解码器</string>
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -113,6 +120,14 @@
<string name="map_period">周期:% .0f min</string>
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
@@ -134,6 +149,24 @@
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
<string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_lotw_title">LoTW 已确认网格</string>
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
@@ -149,6 +182,7 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_restore">恢复默认数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -196,6 +230,7 @@
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -245,5 +280,17 @@
<string name="mutual_open_radar">查看雷达</string>
<string name="mutual_station_a">站点A</string>
<string name="mutual_station_b">站点B</string>
<string name="update_check_title">检查更新</string>
<string name="update_check_button">检查更新</string>
<string name="update_check_current">当前版本:%s</string>
<string name="update_check_latest">最新版本:%s</string>
<string name="update_check_up_to_date">已是最新版本</string>
<string name="update_check_available">发现新版本</string>
<string name="update_check_release_notes">更新内容</string>
<string name="update_check_download">下载并安装</string>
<string name="update_check_downloading">正在下载…</string>
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
<string name="update_check_download_error">下载失败,请重试。</string>
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
</resources>
@@ -122,10 +122,17 @@
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
<string name="radar_cw_decoder">CW Decoder</string>
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -138,6 +145,14 @@
<string name="map_period">Period: %.0f min</string>
<string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
@@ -169,6 +184,24 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_wavelog_title">Wavelog worked grids</string>
<string name="prefs_wavelog_configure">Configure</string>
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_lotw_title">LoTW confirmed grids</string>
<string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
<string name="prefs_lotw_sync_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</string>
<string name="prefs_lotw_sync">Sync</string>
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_data_title">Satellite data</string>
@@ -183,6 +216,7 @@
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -230,6 +264,7 @@
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -280,5 +315,17 @@
<string name="mutual_open_radar">Open Radar</string>
<string name="mutual_station_a">Station A</string>
<string name="mutual_station_b">Station B</string>
<string name="update_check_title">Check for updates</string>
<string name="update_check_button">Check for updates</string>
<string name="update_check_current">Current version: %s</string>
<string name="update_check_latest">Latest version: %s</string>
<string name="update_check_up_to_date">You are up to date</string>
<string name="update_check_available">New version available</string>
<string name="update_check_release_notes">Release notes</string>
<string name="update_check_download">Download &amp; install</string>
<string name="update_check_downloading">Downloading…</string>
<string name="update_check_error">Failed to check for updates. Check your connection.</string>
<string name="update_check_download_error">Download failed. Try again.</string>
<string name="update_check_no_apk">No APK attached to this release.</string>
</resources>
+39
View File
@@ -0,0 +1,39 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.CanProduceConsumerProguardFiles
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.cw"
compileOptions {
encoding = "UTF-8"
}
// Morse Expert 1.15 官方 Play 分包提供 ARM32/ARM64 原生解码器
// (jniLibs/armeabi-v7a + arm64-v8a), 两个 ABI 都打进 APK
defaultConfig {
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
// CW 类保持规则(JNI 按类名注册 + 照搬混淆类保逻辑), 由 app 的 R8 消费
// AGP 9: consumer 规则走 variant 级 CanProduceConsumerProguardFiles
androidComponents {
onVariants(selector().all()) { variant ->
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
project.layout.projectDirectory.file("proguard-rules.pro")
)
}
}
dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
// 照搬的 Java 源码含中文注释, 强制 UTF-8 编译(compileOptions 在部分 AGP 版本不生效)
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
+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,63 @@
package B;
import android.text.SpannableStringBuilder;
import com.ve3nea.morse_expert.DecodedTextView;
import com.ve3nea.morse_expert.MainActivity;
import H2.b;
/**
* 照搬自 Morse Expert 1.15 B.RunnableC0001b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 5 参 (Object,Object,int,int,boolean),
* 字段 = i(Object)/h(Object)/g(int)/f(int); run() 仅保留 case 4(f==2 对应 pswitch_4:
* 解码文本 UI 更新, H2.b 调用), 其余 case(权限/Intent/通知等库代码)已裁剪。
* 注: jadx 4 参构造为错误还原, 已按 smali 修正。
*/
public final class RunnableC0001b implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final int g;
/* renamed from: h, reason: collision with root package name */
public final Object h;
/* renamed from: i, reason: collision with root package name */
public final Object i;
public /* synthetic */ RunnableC0001b(Object obj, Object obj2, int i4, int i5, boolean z) {
this.i = obj;
this.h = obj2;
this.g = i4;
this.f = i5;
}
@Override // java.lang.Runnable
public final void run() {
if (this.f == 2) {
MainActivity mainActivity = ((b) this.i).f620p;
if (mainActivity != null) {
SpannableStringBuilder spannableStringBuilder = (SpannableStringBuilder) this.h;
int i5 = this.g;
DecodedTextView decodedTextView = (DecodedTextView) mainActivity.f11035D.f11888g;
if (!decodedTextView.f11032n && !decodedTextView.f11033o.get()) {
decodedTextView.beginBatchEdit();
try {
int length2 = decodedTextView.length();
int min = Math.min(length2, i5);
if (min > 0) {
decodedTextView.getEditableText().delete(length2 - min, length2);
}
decodedTextView.q(spannableStringBuilder);
decodedTextView.endBatchEdit();
return;
} catch (Throwable th) {
decodedTextView.endBatchEdit();
throw th;
}
}
return;
}
return;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package B0;
import android.widget.TextView;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.ScaleView;
import java.util.Locale;
import d1.AbstractC1518b;
/**
* 照搬自 Morse Expert 1.15 B0.b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 (H2/b, String, [I) -> f=4;
* 只保留 case 4(状态栏更新: dBFS/Hz/dB/WPM + ScaleView 频率标记, H2.b 调用),
* 其余 case(WorkManager/动画等库代码)已裁剪。
* r3 = scaleView.getHeight()(smali 证实, jadx 寄存器未展开)。
*/
public final class b implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final Object g;
/* renamed from: h, reason: collision with root package name */
public final Object h;
/* renamed from: i, reason: collision with root package name */
public final Object i;
public b(H2.b bVar, String str, int[] iArr) {
this.f = 4;
this.g = bVar;
this.h = iArr;
this.i = str;
}
@Override // java.lang.Runnable
public final void run() {
if (this.f == 4) {
MainActivity mainActivity = ((H2.b) this.g).f621q;
if (mainActivity != null) {
String str2 = (String) this.i;
int[] iArr = (int[]) this.h;
String concat;
String format = String.format(Locale.US, " %3d dBFS", Integer.valueOf(iArr[0]));
if (iArr[5] == 0) {
concat = format.concat(" Idle ");
} else {
concat = format.concat(String.format(Locale.US, " %4d Hz %2d dB %2d WPM ", Integer.valueOf(iArr[1]), Integer.valueOf(iArr[2]), Integer.valueOf(iArr[3])));
}
((TextView) mainActivity.f11035D.f11890i).setText(AbstractC1518b.e(concat, str2));
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
int i4 = iArr[1];
scaleView.f11041g = iArr[5];
// r3 = scaleView.getHeight()(smali 证实)
scaleView.f = (scaleView.getHeight() - 1) - Math.round(((Math.round(i4 * 0.128f) - H2.b.f606z) * scaleView.getHeight()) / H2.b.f603B);
scaleView.invalidate();
return;
}
return;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package D;
import android.graphics.Bitmap;
import android.opengl.GLES20;
import android.opengl.GLUtils;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
/**
* 照搬自 Morse Expert 1.15, 类名保持混淆原名。
* R8 合并 Runnable 手术: 只保留 case 1(瀑布图频谱->OpenGL 纹理, H2.b 调用),
* 其余 case(库/广告)已裁剪。
*/
public final /* synthetic */ class n implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ Object f224g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ Object f225h;
public /* synthetic */ n(Object obj, int i4, Object obj2) {
this.f = i4;
this.f224g = obj;
this.f225h = obj2;
}
@Override // java.lang.Runnable
public final void run() {
int i4 = this.f;
Object obj = this.f225h;
Object obj2 = this.f224g;
if (i4 == 1) {
float[] fArr = (float[]) obj;
J2.b bVar = ((WaterfallSurfaceView) obj2).f;
Bitmap bitmap = bVar.f733h;
int width = bVar.f730d.getWidth();
double d4 = width;
double b4 = (bVar.b() + 4.0d) % d4;
double d5 = bVar.f737l - b4;
if (d5 > d4 / 2.0d) {
d5 -= d4;
} else if (d5 < (-width) / 2.0d) {
d5 += d4;
}
if (Math.abs(d5) > 8.0d) {
bVar.f737l = (int) Math.floor(b4);
} else {
bVar.f732g = (d5 * 1.0E-5d) + bVar.f732g;
}
int height = bitmap.getHeight() - 1;
for (int i5 = 0; i5 <= height; i5++) {
J2.a aVar = bVar.f734i;
int round = Math.round((fArr[i5] * 4.0f) + 30.0f);
aVar.getClass();
bitmap.setPixel(0, height - i5, aVar.f727a[Math.max(0, Math.min(255, round))]);
}
int i6 = bVar.f737l;
GLES20.glBindTexture(3553, bVar.f728a[0]);
J2.b.a();
GLUtils.texSubImage2D(3553, 0, i6, 0, bitmap);
J2.b.a();
int i7 = bVar.f737l + 1;
bVar.f737l = i7;
bVar.f737l = i7 % width;
return;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package E2;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.ScaleView;
/**
* 照搬自 Morse Expert 1.15 (com.ve3nea.morse_expert), 类名保持混淆原名。
* R8 合并类手术: 只保留 case 1(瀑布图触摸选频率=应用逻辑),
* 其余 case(广告 P4 / appcompat 焦点 / 弹窗)为库代码, 已裁剪。
*/
public final class g implements View.OnTouchListener {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ Object f411g;
public /* synthetic */ g(Object obj, int i4) {
this.f = i4;
this.f411g = obj;
}
@Override // android.view.View.OnTouchListener
public final boolean onTouch(View view, MotionEvent motionEvent) {
int i4 = this.f;
Object obj = this.f411g;
if (i4 == 1) {
MainActivity mainActivity = (MainActivity) obj;
if (motionEvent.getActionMasked() == 0 && mainActivity.f11037F != null) {
// smali 证实: p1 先被重赋值为 f11035D.h(ScaleView)再 getHeight;
// 公式 = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
float y3 = motionEvent.getY();
int round = Math.round((H2.b.f606z + ((((scaleView.getHeight() - 1) - y3) * H2.b.f603B) / scaleView.getHeight())) / 0.128f);
mainActivity.f11037F.f622r.set(round);
Log.i("Waterfall", String.format("Touch at %d Hz`", Integer.valueOf(round)));
}
return false;
}
return false;
}
}
+53
View File
@@ -0,0 +1,53 @@
package F2;
import com.rtbishop.look4sat.feature.cw.R;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import com.ve3nea.morse_expert.DecodedTextView;
/* loaded from: classes.dex */
public final class a implements ActionMode.Callback {
/* renamed from: a, reason: collision with root package name */
public final /* synthetic */ DecodedTextView f564a;
public a(DecodedTextView decodedTextView) {
this.f564a = decodedTextView;
}
@Override // android.view.ActionMode.Callback
public final boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
int itemId = menuItem.getItemId();
DecodedTextView decodedTextView = this.f564a;
if (itemId != 73) {
if (itemId != 74) {
return false;
}
decodedTextView.s();
return true;
}
decodedTextView.r();
actionMode.finish();
return true;
}
@Override // android.view.ActionMode.Callback
public final boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
this.f564a.f11032n = true;
// 原: if (menu.findItem(R.id.shareText) == null) 检查(shareText 为原 APK 资源, 简化后始终添加)
menu.add(0, 74, 0, "Share").setIcon(R.drawable.ic_baseline_share_24);
menu.add(0, 73, 0, "Save").setIcon(R.drawable.ic_baseline_save_24);
return true;
}
@Override // android.view.ActionMode.Callback
public final void onDestroyActionMode(ActionMode actionMode) {
this.f564a.f11032n = false;
}
@Override // android.view.ActionMode.Callback
public final boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
return false;
}
}
+82
View File
@@ -0,0 +1,82 @@
package H2;
import android.media.AudioRecord;
import android.util.Log;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/* loaded from: classes.dex */
public final class a implements Runnable {
public final int f;
/* renamed from: g, reason: collision with root package name */
public final AudioRecord f596g;
/* renamed from: h, reason: collision with root package name */
public final ExecutorService f597h;
/* renamed from: i, reason: collision with root package name */
public final AtomicBoolean f598i;
/* renamed from: j, reason: collision with root package name */
public final short[] f599j;
/* renamed from: k, reason: collision with root package name */
public final float[] f600k;
/* renamed from: l, reason: collision with root package name */
public b f601l;
public a() {
int minBufferSize = AudioRecord.getMinBufferSize(8000, 16, 2);
int i4 = b.f604C;
this.f = i4;
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
this.f597h = newSingleThreadExecutor;
AtomicBoolean atomicBoolean = new AtomicBoolean(false);
this.f598i = atomicBoolean;
this.f599j = new short[i4];
this.f600k = new float[i4];
AudioRecord audioRecord = new AudioRecord(1, 8000, 16, 2, minBufferSize * 4);
this.f596g = audioRecord;
if (audioRecord.getState() == 1) {
audioRecord.startRecording();
if (audioRecord.getState() != 1) {
Log.e("AudioRecord", "cannot start");
}
atomicBoolean.set(true);
newSingleThreadExecutor.execute(this);
return;
}
Log.e("AudioInput", "Failed to initialize");
}
@Override // java.lang.Runnable
public final void run() {
while (true) {
try {
if (this.f598i.get()) {
float[] fArr = this.f600k;
AudioRecord audioRecord = this.f596g;
short[] sArr = this.f599j;
int i4 = this.f;
if (audioRecord.read(sArr, 0, i4) == i4) {
for (int i5 = 0; i5 < i4; i5++) {
fArr[i5] = sArr[i5] / 32768.0f;
}
b bVar = this.f601l;
if (bVar != null) {
bVar.a(fArr);
}
}
} else {
TimeUnit.MILLISECONDS.sleep(100L);
}
} catch (Exception e4) {
Log.e("Audio thread", "Exception: ", e4);
}
}
}
}
+582
View File
@@ -0,0 +1,582 @@
package H2;
import B.RunnableC0001b;
import D.n;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.Handler;
import android.os.Looper;
import android.text.SpannableStringBuilder;
import android.text.format.DateFormat;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
import i3.d;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import k3.s;
import pas.decoder;
import pas.nativedecoder;
/* loaded from: classes.dex */
public final class b {
/* renamed from: A, reason: collision with root package name */
public static final int f602A;
/* renamed from: B, reason: collision with root package name */
public static final int f603B;
/* renamed from: C, reason: collision with root package name */
public static final int f604C;
/* renamed from: D, reason: collision with root package name */
public static final d f605D;
/* renamed from: z, reason: collision with root package name */
public static final int f606z;
/* renamed from: a, reason: collision with root package name */
public final float[] f607a;
/* renamed from: b, reason: collision with root package name */
public final float[] f608b;
public final float[] c;
/* renamed from: d, reason: collision with root package name */
public final float[] f609d;
/* renamed from: e, reason: collision with root package name */
public final nativedecoder.TNativeDecoder f610e;
public int f;
/* renamed from: g, reason: collision with root package name */
public int f611g;
/* renamed from: h, reason: collision with root package name */
public int f612h;
/* renamed from: i, reason: collision with root package name */
public decoder.TDecoderState f613i;
/* renamed from: j, reason: collision with root package name */
public String f614j;
/* renamed from: k, reason: collision with root package name */
public final c f615k;
/* renamed from: l, reason: collision with root package name */
public int f616l;
/* renamed from: m, reason: collision with root package name */
public final I2.b f617m;
/* renamed from: n, reason: collision with root package name */
public final Handler f618n;
/* renamed from: o, reason: collision with root package name */
public WaterfallSurfaceView f619o;
/* renamed from: p, reason: collision with root package name */
public MainActivity f620p;
/* renamed from: q, reason: collision with root package name */
public MainActivity f621q;
/* renamed from: r, reason: collision with root package name */
public final AtomicInteger f622r;
/* renamed from: s, reason: collision with root package name */
public final AtomicBoolean f623s;
/* renamed from: t, reason: collision with root package name */
public final Context f624t;
/* renamed from: u, reason: collision with root package name */
public int f625u;
/* renamed from: v, reason: collision with root package name */
public final AtomicBoolean f626v;
/* renamed from: w, reason: collision with root package name */
public DataOutputStream f627w;
/* renamed from: x, reason: collision with root package name */
public int f628x;
/* renamed from: y, reason: collision with root package name */
public String f629y;
/* JADX WARN: Removed duplicated region for block: B:14:0x012d */
/* JADX WARN: Type inference failed for: r0v4, types: [i3.d, java.lang.Object] */
static {
boolean z3;
float f;
float f4;
float f5;
int round = Math.round(25.6f);
f606z = round;
int round2 = Math.round(153.6f);
f602A = round2;
f603B = round2 - round;
f604C = Math.round(85.333336f);
i3.d obj = new i3.d();
if (2048 > 1073741824) {
z3 = true;
} else {
z3 = false;
}
obj.f11709l = z3;
int i4 = (int) 1024;
obj.f11700a = i4;
obj.f11701b = 1024L;
if (!z3) {
obj.f11708k = 1;
int[] iArr = new int[((int) g3.c.a((1 << (((int) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2)) + 2)) + 2];
obj.c = iArr;
float[] fArr = new float[i4];
obj.f11703e = fArr;
int i5 = (i4 * 2) >> 2;
obj.f11704g = i5;
iArr[0] = i5;
iArr[1] = 1;
if (i5 > 2) {
int i6 = i5 >> 1;
float f6 = 0.7853982f / i6;
float f7 = 2.0f * f6;
float b4 = (float) g3.c.b(i6 * f6); // r9 = i6 (smali)
fArr[0] = 1.0f;
fArr[1] = b4;
if (i6 == 4) {
double d4 = f7;
f4 = 0.5f;
fArr[2] = (float) g3.c.b(d4);
fArr[3] = (float) g3.c.f(d4);
} else {
f4 = 0.5f;
if (i6 > 4) {
iArr[2] = 0;
iArr[3] = 16;
int i7 = i5;
int i8 = 2;
f5 = 1.0f;
while (i7 > 32) {
int i9 = i8 << 1;
int i10 = i8 << 4;
for (int i11 = i8; i11 < i9; i11++) {
int i12 = iArr[i11] << 2;
iArr[i8 + i11] = i12;
iArr[i9 + i11] = i12 + i10;
}
i7 >>= 2;
i8 = i9;
}
fArr[2] = 0.5f / ((float) g3.c.b(f7));
fArr[3] = 0.5f / ((float) g3.c.b(6.0f * f6));
for (int i13 = 4; i13 < i6; i13 += 4) {
float f8 = i13 * f6;
double d5 = f8;
fArr[i13] = (float) g3.c.b(d5);
fArr[i13 + 1] = (float) g3.c.f(d5);
double d6 = 3.0f * f8;
fArr[i13 + 2] = (float) g3.c.b(d6);
fArr[i13 + 3] = -((float) g3.c.f(d6));
}
int i14 = 0;
while (i6 > 2) {
int i15 = i14 + i6;
i6 >>= 1;
fArr[i15] = f5;
fArr[i15 + 1] = b4;
if (i6 == 4) {
float f9 = fArr[i14 + 4];
float f10 = fArr[i14 + 5];
fArr[i15 + 2] = f9;
fArr[i15 + 3] = f10;
} else if (i6 > 4) {
float f11 = fArr[i14 + 4];
float f12 = fArr[i14 + 6];
fArr[i15 + 2] = f4 / f11;
fArr[i15 + 3] = f4 / f12;
for (int i16 = 4; i16 < i6; i16 += 4) {
int i17 = (i16 * 2) + i14;
int i18 = i15 + i16;
float f13 = fArr[i17];
float f14 = fArr[i17 + 1];
float f15 = fArr[i17 + 2];
float f16 = fArr[i17 + 3];
fArr[i18] = f13;
fArr[i18 + 1] = f14;
fArr[i18 + 2] = f15;
fArr[i18 + 3] = f16;
}
}
i14 = i15;
}
}
}
f5 = 1.0f;
int i142 = 0;
while (i6 > 2) {
}
}
int i19 = i4 >> 2;
obj.f11706i = i19;
iArr[1] = i19;
if (i19 > 1) {
int i20 = i19 >> 1;
float f17 = 0.7853982f / i20;
float b5 = (float) g3.c.b(i20 * f17); // r6 = i20 (smali)
fArr[i5] = b5;
fArr[i5 + i20] = b5 * 0.5f;
for (int i21 = 1; i21 < i20; i21++) {
double d7 = i21 * f17;
fArr[i5 + i21] = ((float) g3.c.b(d7)) * 0.5f;
fArr[(i5 + i19) - i21] = ((float) g3.c.f(d7)) * 0.5f;
}
}
} else {
obj.f11708k = 1;
s sVar = new s(((long) g3.c.a((1 << ((int) (((long) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2))) + 2)) + 2, true);
obj.f11702d = sVar;
k3.d dVar = new k3.d(1024L, true);
obj.f = dVar;
obj.f11705h = 512L;
sVar.c(0L, 512L);
sVar.c(1L, 1L);
if (512 > 2) {
long j4 = 512 >> 1;
float f18 = 0.7853982f / ((float) j4);
float f19 = f18 * 2.0f;
float b6 = (float) g3.c.b(j4 * f18); // r14 = j4 (smali 同模式)
dVar.c(0L, 1.0f);
dVar.c(1L, b6);
if (j4 == 4) {
f = 0.5f;
double d8 = f19;
dVar.c(2L, (float) g3.c.b(d8));
dVar.c(3L, (float) g3.c.f(d8));
} else {
f = 0.5f;
if (j4 > 4) {
sVar.c(2L, 0L);
sVar.c(3L, 16L);
long j5 = 2;
long j6 = 512;
while (j6 > 32) {
long j7 = j5 << 1;
long j8 = j5 << 4;
long j9 = j5;
while (j9 < j7) {
long b7 = sVar.b(j9) << 2;
sVar.c(j5 + j9, b7);
sVar.c(j7 + j9, b7 + j8);
j9++;
f19 = f19;
}
j6 >>= 2;
j5 = j7;
}
dVar.c(2L, 0.5f / ((float) g3.c.b(f19)));
dVar.c(3L, 0.5f / ((float) g3.c.b(6.0f * f18)));
long j10 = 4;
while (j10 < j4) {
float f20 = ((float) j10) * f18;
double d9 = f20;
dVar.c(j10, (float) g3.c.b(d9));
dVar.c(j10 + 1, (float) g3.c.f(d9));
double d10 = 3.0f * f20;
long j11 = j10;
dVar.c(j10 + 2, (float) g3.c.b(d10));
dVar.c(j11 + 3, -((float) g3.c.f(d10)));
j10 = j11 + 4;
}
}
}
long j12 = 0;
long j13 = 2;
while (j4 > j13) {
long j14 = j12 + j4;
j4 >>= 1;
dVar.c(j14, 1.0f);
dVar.c(j14 + 1, b6);
if (j4 == 4) {
float b8 = dVar.b(j12 + 4);
float b9 = dVar.b(j12 + 5);
j13 = 2;
dVar.c(j14 + 2, b8);
dVar.c(j14 + 3, b9);
} else {
j13 = 2;
if (j4 > 4) {
float b10 = dVar.b(j12 + 4);
float b11 = dVar.b(6 + j12);
dVar.c(j14 + 2, f / b10);
dVar.c(j14 + 3, f / b11);
long j15 = 4;
while (j15 < j4) {
long j16 = (j15 * 2) + j12;
float f21 = b6;
long j17 = j14 + j15;
long j18 = j12;
float b12 = dVar.b(j16);
long j19 = j14;
float b13 = dVar.b(j16 + 1);
float b14 = dVar.b(j16 + 2);
float b15 = dVar.b(j16 + 3);
dVar.c(j17, b12);
dVar.c(j17 + 1, b13);
dVar.c(j17 + 2, b14);
dVar.c(j17 + 3, b15);
j15 += 4;
b6 = f21;
j12 = j18;
j14 = j19;
}
}
}
b6 = b6;
j12 = j14;
}
}
long j20 = 256;
obj.f11707j = 256L;
long j21 = 1;
sVar.c(1L, 256L);
if (256 > 1) {
long j22 = 256 >> 1;
float f22 = 0.7853982f / ((float) j22);
long j23 = 512;
dVar.c(512L, (float) g3.c.b(j22 * f22)); // r1 = j22 (smali)
dVar.c(512 + j22, dVar.b(512L) * 0.5f);
long j24 = 1;
while (j24 < j22) {
long j25 = j20;
long j26 = j21;
double d11 = ((float) j24) * f22;
long j27 = j23;
dVar.c(j23 + j24, ((float) g3.c.b(d11)) * 0.5f);
dVar.c((j27 + j25) - j24, ((float) g3.c.f(d11)) * 0.5f);
j24 += j26;
j20 = j25;
j21 = j26;
j23 = j27;
}
}
}
f605D = obj;
}
public b(Context context) {
float[] fArr = new float[1024];
fArr[512] = 1.0f;
for (int i4 = 1; i4 < 512; i4++) {
double d4 = (i4 * 6.283185307179586d) / 1024;
float cos = (float) ((Math.cos(d4 * 2.0d) * 0.08d) + (0.42d - (Math.cos(d4) * 0.5d)));
fArr[i4] = cos;
double d5 = 0.003125f * 6.283185307179586d * (i4 - 512);
float sin = (float) ((Math.sin(d5) / d5) * cos);
fArr[i4] = sin;
fArr[1024 - i4] = sin;
}
this.f607a = fArr;
this.f608b = new float[1024];
this.c = new float[1024];
this.f609d = new float[f603B];
this.f610e = nativedecoder.TNativeDecoder.Create();
this.f614j = "";
this.f615k = new c(4000, 0);
I2.b bVar = new I2.b();
this.f617m = bVar;
this.f618n = new Handler(Looper.getMainLooper());
this.f622r = new AtomicInteger();
this.f623s = new AtomicBoolean(false);
this.f625u = 0;
this.f626v = new AtomicBoolean(false);
this.f628x = 0;
this.f624t = context;
bVar.b(context);
}
/* JADX WARN: Removed duplicated region for block: B:57:0x01e2 */
/* JADX WARN: Removed duplicated region for block: B:63:0x020a A[LOOP:6: B:62:0x0208->B:63:0x020a, LOOP_END] */
/* JADX WARN: Removed duplicated region for block: B:66:0x0216 A[LOOP:7: B:65:0x0214->B:66:0x0216, LOOP_END] */
/* JADX WARN: Removed duplicated region for block: B:70:0x0232 */
/* JADX WARN: Removed duplicated region for block: B:73:? A[RETURN, SYNTHETIC] */
/*
Code decompiled incorrectly, please refer to instructions dump.
*/
public final void a(float[] fArr) {
c cVar;
float[] fArr2;
float[] fArr3;
b bVar;
Exception exc;
int i4;
float f;
int i5;
WaterfallSurfaceView waterfallSurfaceView;
int length = fArr.length;
int i6 = 0;
while (true) {
cVar = this.f615k;
if (i6 >= length) {
break;
}
cVar.c(Math.abs(fArr[i6]));
i6++;
}
this.f616l = Math.round(((float) Math.log10(Math.max(-0.5d, cVar.f633e))) * 20.0f);
int i7 = f604C;
int i8 = 1024 - i7;
float[] fArr4 = this.f608b;
System.arraycopy(fArr4, i7, fArr4, 0, i8);
System.arraycopy(fArr, 0, fArr4, i8, i7);
int i9 = 0;
while (true) {
fArr2 = this.c;
if (i9 >= 1024) {
break;
}
fArr2[i9] = fArr4[i9] * this.f607a[i9];
i9++;
}
f605D.k(fArr2, 0);
int i10 = f606z;
int i11 = i10;
while (true) {
int i12 = f602A;
float f4 = 0.0f;
fArr3 = this.f609d;
if (i11 < i12) {
int i13 = i11 * 2;
float f5 = fArr2[i13];
float f6 = fArr2[i13 + 1];
float f7 = ((f6 * f6) + (f5 * f5)) * 0.001f;
if (!Float.isNaN(f7)) {
f4 = f7;
}
fArr3[i11 - i10] = f4;
i11++;
} else {
// smali: 循环内无 try; 录音写入 try 在循环后(见下)
break;
}
}
DataOutputStream dataOutputStream = this.f627w;
Context context = this.f624t;
AtomicBoolean atomicBoolean = this.f626v;
try { // smali try_start_0: 录音写入整块(原 jadx 误把 try 放 break 处)
if (dataOutputStream == null && atomicBoolean.get()) {
File file = new File(context.getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + "_spectra.bin");
this.f629y = file.getAbsoluteFile().toString();
this.f627w = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file, false)));
this.f628x = 0;
this.f614j = " Recording...";
}
if (this.f627w != null) {
for (float f8 : fArr3) {
this.f627w.writeFloat(f8);
}
}
if (atomicBoolean.get()) {
int i14 = this.f628x + 1;
this.f628x = i14;
if (i14 >= 2048) {
atomicBoolean.set(false);
this.f627w.close();
this.f627w = null;
this.f614j = "";
MediaScannerConnection.scanFile(context, new String[]{this.f629y}, new String[]{"application/x-binary"}, null);
}
}
} catch (IOException e4) {
Log.e("File write", "Exception: ", e4);
}
nativedecoder.TNativeDecoder tNativeDecoder = this.f610e;
try {
this.f613i = tNativeDecoder.ProcessSpectrum(fArr3);
int i15 = this.f625u + 1;
this.f625u = i15;
Handler handler = this.f618n;
if (i15 == 15) {
try {
this.f625u = 0;
this.f = tNativeDecoder.getPitch();
this.f611g = tNativeDecoder.getSnr();
this.f612h = tNativeDecoder.getWpm();
handler.post(new B0.b(this, this.f614j, new int[]{this.f616l, this.f, this.f611g, this.f612h, tNativeDecoder.getSignalCount(), this.f613i.Value}));
AtomicInteger atomicInteger = this.f622r;
int i16 = atomicInteger.get();
if (i16 != 0) {
tNativeDecoder.LockToPitch(i16);
atomicInteger.set(0);
}
tNativeDecoder.setHamMode(this.f623s.get());
} catch (Exception e5) {
// jadx 幻觉区(空循环/空 if 为 catch 错位还原)已裁剪; 原 smali: 记录异常后跳回正常流程尾部
exc = e5;
bVar = this;
Log.e("Audio Processor", "Exception", exc);
}
}
int charCount = tNativeDecoder.getCharCount();
if (charCount > 0) {
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
int i18 = 0;
while (i18 < charCount) {
decoder.TCharInfo GetChar = tNativeDecoder.GetChar(i18);
spannableStringBuilder.append(GetChar.getChar());
ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(this.f617m.a(GetChar));
int i19 = i18 + 1;
spannableStringBuilder.setSpan(foregroundColorSpan, i18, i19, 33);
i18 = i19;
}
bVar = this;
try {
handler.post(new RunnableC0001b(bVar, spannableStringBuilder, tNativeDecoder.getDeleteCount(), 2, false));
} catch (Exception e6) {
// jadx 幻觉区已裁剪(同 catch e5); 原 smali: 记录异常后跳回正常流程尾部
exc = e6;
Log.e("Audio Processor", "Exception", exc);
}
} else {
bVar = this;
}
} catch (Exception e7) {
// jadx 幻觉(e 变量)已裁剪
bVar = this;
}
i4 = f603B;
int i1722 = i4 / 2;
float[] copyOfRange22 = Arrays.copyOfRange(fArr3, i1722 - 20, i1722 + 20);
Arrays.sort(copyOfRange22);
f = copyOfRange22[20];
if (f > 0.0f) {
for (int i20 = 0; i20 < i4; i20++) {
fArr3[i20] = fArr3[i20] / f;
}
}
float[] fArr522 = (float[]) fArr3.clone();
new c(12, 1).a(fArr522);
new c(12, 0).a(fArr522);
for (i5 = 0; i5 < i4; i5++) {
fArr3[i5] = fArr3[i5] - fArr522[i5];
}
for (int i21 = 0; i21 < i4; i21++) {
fArr3[i21] = ((float) Math.log10(Math.max(-0.5d, fArr3[i21]) + 1.0d)) * 10.0f;
}
waterfallSurfaceView = bVar.f619o;
if (waterfallSurfaceView != null) {
waterfallSurfaceView.queueEvent(new n(waterfallSurfaceView, 1, (float[]) fArr3.clone()));
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package H2;
/* loaded from: classes.dex */
public final class c {
/* renamed from: a, reason: collision with root package name */
public final int f630a;
/* renamed from: b, reason: collision with root package name */
public float[] f631b;
public int c;
/* renamed from: d, reason: collision with root package name */
public float f632d;
/* renamed from: e, reason: collision with root package name */
public float f633e;
public final /* synthetic */ int f;
public c(int i4, int i5) {
this.f = i5;
this.f630a = i4 - 1;
}
public final void a(float[] fArr) {
int i4 = this.f630a / 2;
int length = fArr.length;
if (length > i4) {
this.f631b = null;
for (int i5 = 0; i5 < i4; i5++) {
c(fArr[i5]);
}
for (int i6 = i4; i6 < length; i6++) {
fArr[i6 - i4] = c(fArr[i6]);
}
for (int i7 = length; i7 < length + i4; i7++) {
fArr[i7 - i4] = c(fArr[length - 1]);
}
return;
}
throw new IllegalArgumentException("Sliding Filter error: input too short");
}
public final float b(float f, float f4) {
switch (this.f) {
case 0:
return Math.max(f, f4);
default:
return Math.min(f, f4);
}
}
public final float c(float f) {
float[] fArr = this.f631b;
int i4 = this.f630a;
if (fArr == null) {
this.f631b = new float[i4];
for (int i5 = 0; i5 < i4; i5++) {
this.f631b[i5] = f;
}
this.f632d = f;
return f;
}
float b4 = b(this.f632d, f);
this.f632d = b4;
this.f633e = b(this.f631b[this.c], b4);
float[] fArr2 = this.f631b;
int i6 = this.c;
int i7 = i6 + 1;
this.c = i7;
fArr2[i6] = f;
if (i7 == i4) {
int length = fArr2.length;
while (true) {
this.c = length - 1;
int i8 = this.c;
if (i8 <= 0) {
break;
}
float[] fArr3 = this.f631b;
fArr3[i8 - 1] = b(fArr3[i8 - 1], fArr3[i8]);
length = this.c;
}
this.c = 0;
this.f632d = f;
}
return this.f633e;
}
}
+42
View File
@@ -0,0 +1,42 @@
package I0;
/**
* 照搬自 Morse Expert 1.15 I0.d, 类名保持混淆原名。
* R8 合并类手术: 只保留 (i3.d, int, int, float[]) FFT 任务构造;
* run() 按 smali 原样还原: R8 已将任务体优化为死代码(aget 后 throw null,
* 永不执行), 保留原语义(行为与原 APK 完全一致)。
* SystemForegroundService(work 库)case 已裁剪。
*/
public final class d implements Runnable {
public final /* synthetic */ int f = 0;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ int f653g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ int f654h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ Object f655i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ Object f656j;
public d(i3.d dVar, int i4, int i5, float[] fArr) {
this.f656j = dVar;
this.f653g = i4;
this.f654h = i5;
this.f655i = fArr;
}
@Override // java.lang.Runnable
public final void run() {
int i6 = this.f654h;
int i7 = this.f653g;
if (i7 >= i6) {
return;
}
float f = ((float[]) this.f655i)[i7 * 2];
// R8 原代码: aget 后 throw null(死代码, 永不执行) —— 照搬语义, 不执行任何计算
}
}
+73
View File
@@ -0,0 +1,73 @@
package I2;
import android.content.Context;
import android.content.SharedPreferences;
import pas.decoder;
/* loaded from: classes.dex */
public final class b {
/* renamed from: b, reason: collision with root package name */
public static final String[] f663b = {"bg_color", "text_color", "text_color_weak", "call_color", "call_color_weak", "cq_color", "cq_color_weak", "rst_color", "rst_color_weak"};
public static final String[] c = {"Background color", "Text color", "Text color, weak", "Callsign color", "Callsign color, weak", "CQ color", "CQ color, weak", "RST color", "RST color, weak"};
/* renamed from: d, reason: collision with root package name */
public static final int[] f664d = {-3084048, -16777216, -5592406, -65536, -30584, -16776961, -7829249, -65281, -30465};
/* renamed from: a, reason: collision with root package name */
public final int[] f665a = (int[]) f664d.clone();
public final int a(decoder.TCharInfo tCharInfo) {
boolean lowSnr = tCharInfo.getLowSnr();
int[] iArr = this.f665a;
if (lowSnr) {
int i4 = tCharInfo.getWordType().Value;
if (i4 != 0) {
if (i4 != 1) {
if (i4 != 2) {
if (i4 != 3) {
if (i4 != 4) {
if (i4 == 5) {
return iArr[4];
}
return -16777216;
}
return iArr[2];
}
return iArr[6];
}
return iArr[8];
}
return iArr[2];
}
return iArr[2];
}
int i5 = tCharInfo.getWordType().Value;
if (i5 != 0) {
if (i5 != 1) {
if (i5 != 2) {
if (i5 != 3) {
if (i5 != 4) {
if (i5 != 5) {
return -16777216;
}
return iArr[3];
}
return iArr[1];
}
return iArr[5];
}
return iArr[7];
}
return iArr[1];
}
return iArr[1];
}
public final void b(Context context) {
SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + "_preferences", 0); // 原 c0.x.a(context)
for (int i4 = 0; i4 < 9; i4++) {
this.f665a[i4] = sharedPreferences.getInt(f663b[i4], f664d[i4]);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package J2;
/* loaded from: classes.dex */
public final class a {
/* renamed from: b, reason: collision with root package name */
public static final int[] f726b = {16, 17749, 65535, 16776960, 16711680};
/* renamed from: a, reason: collision with root package name */
public final int[] f727a;
public a() {
int[] iArr = new int[256];
this.f727a = iArr;
int i4 = 4;
float f = 255.0f / 4;
int[] iArr2 = f726b;
int i5 = 0;
int i6 = -16777216;
iArr[0] = iArr2[0] | (-16777216);
int i7 = 1;
while (i7 <= i4) {
int round = Math.round(i7 * f);
iArr[round] = iArr2[i7] | i6;
int i8 = round - i5;
int i9 = iArr[i5];
int i10 = (i9 >> 16) & 255;
int i11 = (i9 >> 8) & 255;
int i12 = i9 & 255;
float f4 = i8;
// r10 = iArr2[i7] (下一个锚点色, smali: aget v10, v4, v8)
float f5 = (((iArr2[i7] >> 16) & 255) - i10) / f4;
float f6 = (((iArr2[i7] >> 8) & 255) - i11) / f4;
float f7 = ((iArr2[i7] & 255) - i12) / f4;
for (int i13 = 1; i13 < i8; i13++) {
float f8 = i13;
iArr[i5 + i13] = ((Math.round(f5 * f8) + i10) << 16) | (-16777216) | ((Math.round(f6 * f8) + i11) << 8) | (Math.round(f8 * f7) + i12);
}
i7++;
i5 = round;
i4 = 4;
i6 = -16777216;
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package J2;
import android.content.Context;
import android.graphics.Bitmap;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import android.opengl.GLUtils;
import android.os.SystemClock;
import android.util.Log;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
/* loaded from: classes.dex */
public final class b implements GLSurfaceView.Renderer {
/* renamed from: b, reason: collision with root package name */
public FloatBuffer f729b;
public int c;
/* renamed from: d, reason: collision with root package name */
public final Bitmap f730d;
public final Context f;
/* renamed from: g, reason: collision with root package name */
public double f732g;
/* renamed from: h, reason: collision with root package name */
public final Bitmap f733h;
/* renamed from: i, reason: collision with root package name */
public final a f734i;
/* renamed from: j, reason: collision with root package name */
public long f735j;
/* renamed from: k, reason: collision with root package name */
public double f736k;
/* renamed from: l, reason: collision with root package name */
public int f737l;
/* renamed from: a, reason: collision with root package name */
public final int[] f728a = new int[1];
/* renamed from: e, reason: collision with root package name */
public final float[] f731e = {-1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f};
public b(Context context, int i4) {
Bitmap.Config config = Bitmap.Config.ARGB_8888;
Bitmap createBitmap = Bitmap.createBitmap(2048, i4, config);
this.f730d = createBitmap;
createBitmap.eraseColor(16);
this.f732g = 0.09375d;
this.f734i = new a();
this.f735j = 0L;
this.f736k = 0.0d;
this.f737l = -1;
this.f = context;
this.f733h = Bitmap.createBitmap(1, i4, config);
}
public static void a() {
int glGetError = GLES20.glGetError();
if (glGetError != 0) {
StackTraceElement stackTraceElement = Thread.currentThread().getStackTrace()[3];
Log.e(stackTraceElement.getFileName() + ":" + stackTraceElement.getLineNumber(), ", GLES20 error: " + glGetError);
}
}
public final double b() {
long uptimeMillis = SystemClock.uptimeMillis();
double width = (((uptimeMillis - this.f735j) * this.f732g) + this.f736k) % this.f730d.getWidth();
this.f735j = uptimeMillis;
this.f736k = width;
return width;
}
public final void c(GL10 gl10) {
GLES20.glUseProgram(this.c);
a();
GLES20.glActiveTexture(33984);
a();
GLES20.glBindTexture(3553, this.f728a[0]);
a();
GLES20.glUniform1i(GLES20.glGetUniformLocation(this.c, "uTexture"), 0);
a();
int glGetAttribLocation = GLES20.glGetAttribLocation(this.c, "aPosition");
a();
int glGetAttribLocation2 = GLES20.glGetAttribLocation(this.c, "aTexPos");
a();
this.f729b.position(0);
a();
GLES20.glVertexAttribPointer(glGetAttribLocation, 2, 5126, false, 16, (Buffer) this.f729b);
a();
GLES20.glEnableVertexAttribArray(glGetAttribLocation);
a();
this.f729b.position(2);
a();
GLES20.glVertexAttribPointer(glGetAttribLocation2, 2, 5126, false, 16, (Buffer) this.f729b);
a();
GLES20.glEnableVertexAttribArray(glGetAttribLocation2);
a();
GLES20.glDrawArrays(5, 0, 4);
a();
}
public final void d(GL10 gl10, int i4, int i5) {
GLES20.glViewport(0, 0, i4, i5);
a();
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onDrawFrame(GL10 gl10) {
float b4 = (float) (b() / this.f730d.getWidth());
float f = 0.5f + b4;
float[] fArr = this.f731e;
fArr[6] = f;
fArr[2] = f;
float f4 = b4 + 1.0f;
fArr[14] = f4;
fArr[10] = f4;
this.f729b.position(0);
this.f729b.put(this.f731e);
c(gl10);
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onSurfaceChanged(GL10 gl10, int i4, int i5) {
d(gl10, i4, i5);
float width = 1.0f - (((this.f730d.getWidth() * 0.7f) / (i4 / this.f.getResources().getDisplayMetrics().density)) * 2.0f);
float[] fArr = this.f731e;
fArr[4] = width;
fArr[0] = width;
this.f729b.position(0);
this.f729b.put(this.f731e);
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onSurfaceCreated(GL10 gl10, EGLConfig eGLConfig) {
int glCreateShader = GLES20.glCreateShader(35633);
a();
GLES20.glShaderSource(glCreateShader, "attribute vec2 aPosition;\nattribute vec2 aTexPos;\nvarying vec2 vTexPos;\nvoid main() {\n vTexPos = aTexPos;\n gl_Position = vec4(aPosition.xy, 0.0, 1.0);\n}");
a();
GLES20.glCompileShader(glCreateShader);
a();
int glCreateShader2 = GLES20.glCreateShader(35632);
a();
GLES20.glShaderSource(glCreateShader2, "precision mediump float;\nuniform sampler2D uTexture;\nvarying vec2 vTexPos;\nvoid main(void)\n{\n gl_FragColor = texture2D(uTexture, vTexPos);\n}");
a();
GLES20.glCompileShader(glCreateShader2);
a();
this.c = GLES20.glCreateProgram();
a();
GLES20.glAttachShader(this.c, glCreateShader);
a();
GLES20.glAttachShader(this.c, glCreateShader2);
a();
GLES20.glLinkProgram(this.c);
a();
int[] iArr = this.f728a;
GLES20.glGenTextures(1, iArr, 0);
a();
GLES20.glBindTexture(3553, iArr[0]);
a();
GLES20.glTexParameteri(3553, 10240, 9729);
GLES20.glTexParameteri(3553, 10242, 10497);
GLES20.glTexParameteri(3553, 10241, 9729);
GLES20.glTexParameteri(3553, 10243, 10497);
a();
GLUtils.texImage2D(3553, 0, this.f730d, 0);
a();
float[] fArr = this.f731e;
ByteBuffer allocateDirect = ByteBuffer.allocateDirect(fArr.length * 4);
allocateDirect.order(ByteOrder.nativeOrder());
FloatBuffer asFloatBuffer = allocateDirect.asFloatBuffer();
this.f729b = asFloatBuffer;
asFloatBuffer.put(fArr);
}
}
+10
View File
@@ -0,0 +1,10 @@
package K1;
/**
* 照搬自 Morse Expert 1.15 K1.a 精简版: 仅保留 k3 FFT 库引用的内存统计静态字段
* (K1.a.c 长整型记账)。原 K1.a(828 行)的其余方法为状态栏/库代码, 已裁剪
* (状态栏逻辑已在 MainActivity 内联)。
*/
public class a {
public static long c = 0;
}
@@ -0,0 +1,213 @@
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
*
* 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类):
* - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/
* statusTextView/verticalLayout/waterfallView);
* - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致
* (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy);
* - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()),
* 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E);
* - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。
*
* 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为
* decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。
*/
package com.rtbishop.look4sat.feature.cw
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.view.LayoutInflater
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.widget.ConstraintLayout
import com.ve3nea.morse_expert.MainActivity
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val context = LocalContext.current
val activity = remember(context) {
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
}
// 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例
val controller = remember { MainActivity() }
// 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图
val rootView = remember(context) {
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
}
var permissionGranted by remember {
mutableStateOf(
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
// v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次
var initialized by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
// 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy
DisposableEffect(Unit) {
controller.onCreate(activity, rootView)
controller.onResume()
onDispose {
controller.onPause()
controller.onDestroy()
}
}
// 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里
LaunchedEffect(permissionGranted) {
if (permissionGranted && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
// v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音
// (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等)
controller.onResume()
initialized = true
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
permanentlyDenied = !granted &&
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
}
// 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出
BackHandler {
if (!controller.handleBackPress()) navigateUp()
}
if (showSettings) {
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
) {
// 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
IconButton(onClick = { controller.togglePause() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_pause_24),
contentDescription = stringResource(R.string.pause)
)
}
IconButton(onClick = { controller.clearDecoded() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_delete_24),
contentDescription = stringResource(R.string.clear)
)
}
IconButton(onClick = { controller.saveText() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_save_24),
contentDescription = stringResource(R.string.save_text)
)
}
// recordSignals 需要解码核心(f11037F), 未初始化时禁用
TextButton(
onClick = { if (initialized) controller.recordSignals() },
enabled = initialized
) {
Text(stringResource(R.string.record_signals))
}
TextButton(onClick = { showSettings = true }) {
Text(stringResource(R.string.settings))
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
)
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 = Modifier.height(12.dp))
Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) {
Text("Grant permission")
}
if (permanentlyDenied) {
// 勾选了"不再询问": 引导去系统设置开启
TextButton(onClick = {
activity.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${activity.packageName}")
)
)
}) {
Text("Open app settings")
}
}
}
}
}
}
}
}
@@ -0,0 +1,218 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.feature.cw
import android.content.Context
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 com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt
/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */
private const val KEY_MESSAGE_TYPE = "message_type"
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
private const val DEFAULT_MESSAGE_TYPE = "general_text"
private const val DEFAULT_TEXT_FONT_SIZE = 18
private const val MIN_FONT_SIZE = 7
private const val MAX_FONT_SIZE = 99
/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */
private val PaletteColors = listOf(
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
)
/**
* CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。
* 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色
* (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。
* 存储: SharedPreferences(getPackageName() + "_preferences")。
* 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。
*/
@Composable
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
val activity = controller.mActivity ?: return
val prefs = remember(activity) {
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
}
var messageType by remember {
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
}
var fontSize by remember {
mutableStateOf(
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
)
}
var colorValues by remember {
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("CW Settings") },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(top = 4.dp)
) {
Text("Message type", style = MaterialTheme.typography.titleSmall)
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Text font size", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = fontSize,
onValueChange = { fontSize = it },
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
modifier = Modifier.weight(1f),
)
Text(
text = "${fontSize.roundToInt()}",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.width(44.dp),
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Colors", style = MaterialTheme.typography.titleSmall)
for (i in 0 until 9) {
ColorSettingRow(
title = I2.b.c[i],
value = colorValues[i],
onSelect = { selected ->
colorValues = colorValues.copyOf().also { it[i] = selected }
},
)
}
}
},
confirmButton = {
TextButton(onClick = {
val editor = prefs.edit()
editor.putString(KEY_MESSAGE_TYPE, messageType)
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
for (i in 0 until 9) {
editor.putInt(I2.b.f663b[i], colorValues[i])
}
editor.apply()
// 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位
controller.onResume()
onDismiss()
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { onSelect(option) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Box(
Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(value)),
)
Spacer(Modifier.width(6.dp))
Text(
text = String.format("#%06X", value and 0xFFFFFF),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(6.dp))
PaletteGrid(selected = value, onSelect = onSelect)
}
}
@Composable
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
PaletteColors.chunked(3).forEach { rowColors ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rowColors.forEach { color ->
val isSelected = color == selected
Box(
modifier = Modifier
.size(36.dp)
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(6.dp),
)
.clip(RoundedCornerShape(6.dp))
.background(Color(color))
.clickable { onSelect(color) },
)
}
}
}
}
}
@@ -0,0 +1,14 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Cleaner(照搬 Morse Expert 1.15 k3 FFT 库的清理回调,
* 实际 FFT 1024 点不触发 native 路径)。
*/
public class Cleaner {
public static Cleaner create(Object referent, Runnable thunk) {
return null;
}
public void clean() {
}
}
@@ -0,0 +1,83 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Unsafe(运行时通过反射获取,失败时 k3.r.f12109a 为 null)。
* 照搬自 Morse Expert 1.15(k3 FFT 库的大数组 native 路径 >1GB 不触发,实际 FFT 走 float[] 分支)。
*/
public class Unsafe {
public long allocateMemory(long bytes) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void freeMemory(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public byte getByte(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putByte(long address, byte value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public short getShort(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putShort(long address, short value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public int getInt(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putInt(long address, int value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public long getLong(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putLong(long address, long value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public float getFloat(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putFloat(long address, float value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public double getDouble(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putDouble(long address, double value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void setMemory(long address, long bytes, byte value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void copyMemory(long src, long dst, long len) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public Object getObject(Object o, long offset) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putObject(Object o, long offset, Object value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public long objectFieldOffset(java.lang.reflect.Field field) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
}
@@ -0,0 +1,138 @@
package com.ve3nea.morse_expert;
import F2.a;
import android.content.Context;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ActionMode;
import android.widget.TextView;
import android.widget.Toast;
import java.io.File;
import java.io.PrintWriter;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 照搬自 Morse Expert 1.15 DecodedTextView, 类名保持混淆原名。
* 适配: 原 extends l.C1702e0(appcompat 混淆类) -> 直接 extends TextView
* (C1702e0 仅提供 TextView 兼容功能, 本类未使用其特有方法)。
*/
public class DecodedTextView extends TextView {
/* renamed from: m, reason: collision with root package name */
public final Context f11031m;
/* renamed from: n, reason: collision with root package name */
public boolean f11032n;
/* renamed from: o, reason: collision with root package name */
public final AtomicBoolean f11033o;
public DecodedTextView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
this.f11032n = false;
this.f11033o = new AtomicBoolean(false);
this.f11031m = context;
setCustomSelectionActionModeCallback(getActionModeCallback());
}
private ActionMode.Callback getActionModeCallback() {
return new a(this);
}
private String getDecodedText() {
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
int max = Math.max(0, Math.min(selectionStart, selectionEnd));
int max2 = Math.max(0, Math.max(selectionStart, selectionEnd));
if (max2 > max) {
return getText().subSequence(max, max2).toString();
}
return getText().toString();
}
@Override // android.widget.TextView
public final void onSelectionChanged(int i4, int i5) {
super.onSelectionChanged(i4, i5);
if (hasSelection()) {
this.f11032n = true;
}
}
public final void q(SpannableStringBuilder spannableStringBuilder) {
boolean z3;
try {
CharSequence text = getText();
Layout layout = getLayout();
int lineCount = getLineCount();
int length = text.length();
int lineHeight = getLineHeight();
int scrollY = getScrollY();
if (layout.getLineBottom(lineCount - 1) == getHeight() + scrollY) {
z3 = true;
} else {
z3 = false;
}
getSelectionStart();
getSelectionEnd();
hasSelection();
if (z3) {
Selection.setSelection((Spannable) text, length, length);
}
append(spannableStringBuilder);
int i4 = lineCount - 50;
if (i4 > 0) {
int i5 = lineHeight * i4;
getEditableText().delete(0, layout.getLineEnd(lineCount - 51));
if (scrollY >= i5) {
scrollBy(0, -i5);
}
}
} catch (Exception e4) {
Log.e("printChars()", "Exception: ", e4);
}
}
public final void r() {
String decodedText = getDecodedText();
boolean isEmpty = TextUtils.isEmpty(decodedText);
Context context = this.f11031m;
if (isEmpty) {
Toast.makeText(context, "No text to save", 0).show();
return;
}
try {
File file = new File(getContext().getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + ".txt");
String absolutePath = file.getAbsolutePath();
PrintWriter printWriter = new PrintWriter(file);
try {
printWriter.println(decodedText);
printWriter.close();
MediaScannerConnection.scanFile(context, new String[]{absolutePath}, new String[]{"text/plain"}, null);
Toast.makeText(context, "Text saved to " + absolutePath, 1).show();
} catch (Throwable th) {
printWriter.close();
throw th;
}
} catch (Exception e4) {
Log.e("File Save", "Failed", e4);
Toast.makeText(context, "Unable to save: " + e4.getMessage(), 1).show();
}
}
public final void s() {
Intent intent = new Intent("android.intent.action.SEND");
intent.setFlags(268435456);
intent.putExtra("android.intent.extra.TEXT", getDecodedText());
intent.setType("text/plain");
this.f11031m.startActivity(Intent.createChooser(intent, "Share Decoded Text"));
}
}
@@ -0,0 +1,232 @@
package com.ve3nea.morse_expert;
import android.content.Context;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.rtbishop.look4sat.feature.cw.R;
import E2.g;
import H2.a;
import H2.b;
import j1.C1646n;
/**
* Ported verbatim from Morse Expert 1.15 MainActivity (com.ve3nea.morse_expert); obfuscated
* class/field names kept as-is. Surgery notes: originally extends e.AbstractActivityC1557l
* (obfuscated AppCompat base) -> now a plain controller class, Activity injected via
* onCreate(Activity, ConstraintLayout) for Compose AndroidView integration. Ads (d1.*),
* billing (I2.a), premium/rate menu removed per user. h3.b.r (recursive findViewById),
* K1.a.M (immersive status bar), B.i (permissions) replaced with equivalent standard APIs.
* Decoder logic (H2.a audio, H2.b core, pas engine) untouched.
*/
public class MainActivity {
public static final /* synthetic */ int f11034K = 0;
/* renamed from: D, reason: collision with root package name */
public C1646n f11035D;
/* renamed from: E, reason: collision with root package name */
public a f11036E;
/* renamed from: F, reason: collision with root package name */
public b f11037F;
/* renamed from: G, reason: collision with root package name */
public long f11038G = 0;
/* renamed from: J, reason: collision with root package name */
public final g f11040J = new g(this, 1);
/** Injected Activity context (replaces the original this Activity capabilities) */
public android.app.Activity mActivity;
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout) {
onCreate(activity, constraintLayout, true);
}
/**
* applyImmersive=false 用于嵌入小容器(如转发器 CW 面板):
* 不触碰宿主窗口的系统栏标志(避免影响整个 Activity)。
*/
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout, boolean applyImmersive) {
this.mActivity = activity;
if (applyImmersive) {
// K1.a.M(getWindow(), false): immersive status bar (nav/fullscreen flags, ported as-is)
setImmersive(activity.getWindow(), false);
}
View inflate = constraintLayout;
int i4 = R.id.decodedTextView;
DecodedTextView decodedTextView = (DecodedTextView) findViewRecursive(inflate, R.id.decodedTextView);
if (decodedTextView != null) {
i4 = R.id.scaleView;
ScaleView scaleView = (ScaleView) findViewRecursive(inflate, R.id.scaleView);
if (scaleView != null) {
i4 = R.id.statusTextView;
TextView textView = (TextView) findViewRecursive(inflate, R.id.statusTextView);
if (textView != null) {
i4 = R.id.verticalLayout;
LinearLayout linearLayout = (LinearLayout) findViewRecursive(inflate, R.id.verticalLayout);
if (linearLayout != null) {
i4 = R.id.waterfallView;
WaterfallSurfaceView waterfallSurfaceView = (WaterfallSurfaceView) findViewRecursive(inflate, R.id.waterfallView);
if (waterfallSurfaceView != null) {
this.f11035D = new C1646n(constraintLayout, decodedTextView, scaleView, textView, linearLayout, waterfallSurfaceView);
((WaterfallSurfaceView) this.f11035D.f11892k).setOnTouchListener(this.f11040J);
// Originally: permission check then v(); permission flow handled by the Compose page
// Originally: ad banner mounting (removed)
return;
}
}
}
}
}
throw new NullPointerException("Missing required view with ID: " + inflate.getResources().getResourceName(i4));
}
/** Original h3.b.r: recursive findViewById (ported behavior) */
private static View findViewRecursive(View view, int id) {
if (view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
View found = viewGroup.getChildAt(i).findViewById(id);
if (found != null) {
return found;
}
}
return null;
}
return null;
}
/** Original K1.a.M(window, false): immersive (hide nav/fullscreen flags, ported as-is) */
private static void setImmersive(Window window, boolean immersive) {
if (Build.VERSION.SDK_INT >= 30) {
window.setDecorFitsSystemWindows(!immersive);
if (immersive) {
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN);
} else {
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
}
} else {
View decorView = window.getDecorView();
int systemUiVisibility = decorView.getSystemUiVisibility();
decorView.setSystemUiVisibility(immersive ? (systemUiVisibility & (-1793)) : (systemUiVisibility | 1792));
}
}
/** Original v(): audio capture + decoder core init (logic unchanged) */
public void v() {
this.f11036E = new a();
b bVar = new b(mActivity.getApplicationContext());
this.f11037F = bVar;
this.f11036E.f601l = bVar;
bVar.f619o = (WaterfallSurfaceView) this.f11035D.f11892k;
bVar.f620p = this;
bVar.f621q = this;
}
/** Original onResume: restore decoding (message type/font/background/record start/waterfall) */
public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
String messageType = prefs.getString("message_type", "ham_radio_qso");
b bVar = this.f11037F;
if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso"));
}
((DecodedTextView) this.f11035D.f11888g).setTextSize(Math.max(7, prefs.getInt("text_font_size", 18)));
b bVar2 = this.f11037F;
if (bVar2 != null) {
bVar2.f617m.b(mActivity);
((DecodedTextView) this.f11035D.f11888g).setBackgroundColor(this.f11037F.f617m.f665a[0]);
}
a aVar = this.f11036E;
if (aVar != null) {
android.media.AudioRecord audioRecord = aVar.f596g;
audioRecord.startRecording();
if (audioRecord.getState() != 1) {
android.util.Log.e("AudioRecord", "cannot start");
}
aVar.f598i.set(true);
}
((WaterfallSurfaceView) this.f11035D.f11892k).onResume();
// Originally: ad lifecycle (removed)
}
/** Original onPause: stop recording + waterfall pause */
public void onPause() {
a aVar = this.f11036E;
if (aVar != null) {
aVar.f598i.set(false);
android.media.AudioRecord audioRecord = aVar.f596g;
audioRecord.stop();
if (audioRecord.getState() != 1) {
android.util.Log.e("AudioRecord", "cannot stop");
}
}
((WaterfallSurfaceView) this.f11035D.f11892k).onPause();
}
/** Original onDestroy: release audio/decoder (billing cleanup removed) */
public void onDestroy() {
a aVar = this.f11036E;
if (aVar != null) {
aVar.f598i.set(false);
try {
aVar.f596g.stop();
aVar.f596g.release();
} catch (Exception ignored) {
}
aVar.f597h.shutdownNow();
}
this.f11036E = null;
this.f11037F = null;
}
/** Menu: clear decoded text (original clear_mnu) */
public void clearDecoded() {
((DecodedTextView) this.f11035D.f11888g).setText("");
}
/** Menu: pause/resume decoding (original pause_mnu, red/white icon toggle logic) */
public boolean togglePause() {
java.util.concurrent.atomic.AtomicBoolean atomicBoolean = ((DecodedTextView) this.f11035D.f11888g).f11033o;
atomicBoolean.set(!atomicBoolean.get());
return atomicBoolean.get();
}
/** Menu: save text (original save_mnu -> DecodedTextView.r()) */
public void saveText() {
((DecodedTextView) this.f11035D.f11888g).r();
}
/** Menu: record signals (original record_signals_mnu) */
public void recordSignals() {
this.f11037F.f626v.set(true);
}
/** Original tap_back_again_to_close hint */
public boolean handleBackPress() {
if (System.currentTimeMillis() < this.f11038G + 2000) {
return true; // double-back: exit
}
this.f11038G = System.currentTimeMillis();
Toast.makeText(mActivity, R.string.tap_back_again_to_close, 0).show();
return false;
}
/** Start decoding after permission granted (original onRequestPermissionsResult success) */
public void onPermissionGranted() {
v();
}
}
@@ -0,0 +1,76 @@
package com.ve3nea.morse_expert;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
/* loaded from: classes.dex */
public class ScaleView extends View {
public int f;
/* renamed from: g, reason: collision with root package name */
public int f11041g;
/* renamed from: h, reason: collision with root package name */
public final Paint f11042h;
/* renamed from: i, reason: collision with root package name */
public final Paint f11043i;
/* renamed from: j, reason: collision with root package name */
public final Paint f11044j;
/* renamed from: k, reason: collision with root package name */
public final Paint f11045k;
public ScaleView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
Paint paint = new Paint();
this.f11042h = paint;
paint.setColor(-16711936);
Paint paint2 = new Paint();
this.f11043i = paint2;
paint2.setColor(-16744448);
Paint paint3 = new Paint();
this.f11044j = paint3;
paint3.setColor(-256);
Paint paint4 = new Paint();
this.f11045k = paint4;
paint4.setColor(-8355840);
}
@Override // android.view.View
public final void onDraw(Canvas canvas) {
Paint paint;
super.onDraw(canvas);
int i4 = this.f11041g;
if (i4 != 1) {
if (i4 != 2) {
if (i4 != 3) {
if (i4 != 4) {
return;
} else {
paint = this.f11044j;
}
} else {
paint = this.f11045k;
}
} else {
paint = this.f11042h;
}
} else {
paint = this.f11043i;
}
int round = (int) Math.round(getWidth() * 0.7d);
Path path = new Path();
path.moveTo(0.0f, this.f);
float f = round;
path.lineTo(f, this.f - round);
path.lineTo(f, this.f + round);
path.close();
canvas.drawPath(path, paint);
}
}
@@ -0,0 +1,19 @@
package com.ve3nea.morse_expert;
import J2.b;
import android.content.Context;
import android.opengl.GLSurfaceView;
import android.util.AttributeSet;
/* loaded from: classes.dex */
public class WaterfallSurfaceView extends GLSurfaceView {
public final b f;
public WaterfallSurfaceView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
setEGLContextClientVersion(2);
b bVar = new b(context, H2.b.f603B);
this.f = bVar;
setRenderer(bVar);
}
}
@@ -0,0 +1,82 @@
package d1;
/* renamed from: d1.b, reason: case insensitive filesystem */
/* 照搬自 Morse Expert 1.15, 混淆名保持。b(Parcel)/c(y2.d) 为库工具已裁剪(L1/y2 库依赖)。 */
public abstract /* synthetic */ class AbstractC1518b {
public static float a(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f - f4);
return f5 + f6;
}
public static String d(String str, int i4) {
return str + i4;
}
public static String e(String str, String str2) {
return str + str2;
}
public static String f(String str, String str2, String str3) {
return str + str2 + str3;
}
public static String g(StringBuilder sb, int i4, String str) {
sb.append(i4);
sb.append(str);
return sb.toString();
}
public static String h(StringBuilder sb, String str, String str2) {
sb.append(str);
sb.append(str2);
return sb.toString();
}
public static /* synthetic */ boolean i(Object obj) {
return obj != null;
}
public static float j(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f + f4);
return f5 + f6;
}
public static String k(String str, String str2) {
return str + str2;
}
public static float l(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f - f4);
return f5 - f6;
}
public static float m(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f + f4);
return f5 - f6;
}
public static /* synthetic */ String n(int i4) {
switch (i4) {
case 1:
return "NONE";
case 2:
return "LEFT";
case 3:
return "TOP";
case 4:
return "RIGHT";
case 5:
return "BOTTOM";
case 6:
return "BASELINE";
case 7:
return "CENTER";
case 8:
return "CENTER_X";
case 9:
return "CENTER_Y";
default:
throw null;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package g3;
/* loaded from: classes.dex */
public final class a {
/* renamed from: a, reason: collision with root package name */
public final int f11582a;
/* renamed from: b, reason: collision with root package name */
public final double f11583b;
public final double c;
public a(double d4) {
int i4 = (int) (0.6366197723675814d * d4);
while (true) {
double d5 = -i4;
double d6 = 1.570796251296997d * d5;
double d7 = d4 + d6;
double d8 = 7.549789948768648E-8d * d5;
double d9 = d8 + d7;
double d10 = (-((d7 - d4) - d6)) + (-((d9 - d7) - d8));
double d11 = d5 * 6.123233995736766E-17d;
double d12 = d11 + d9;
double d13 = d10 + (-((d12 - d9) - d11));
if (d12 > 0.0d) {
this.f11582a = i4;
this.f11583b = d12;
this.c = d13;
return;
}
i4--;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
package g3;
/* loaded from: classes.dex */
public abstract class b {
/* renamed from: a, reason: collision with root package name */
public static final double[][] f11584a = (double[][]) d.f11591a.clone();
}
+507
View File
@@ -0,0 +1,507 @@
package g3;
/* loaded from: classes.dex */
public abstract class c {
/* renamed from: a, reason: collision with root package name */
public static final double[][] f11585a;
/* renamed from: b, reason: collision with root package name */
public static final double[] f11586b;
public static final double[] c;
/* renamed from: d, reason: collision with root package name */
public static final double[] f11587d;
/* renamed from: e, reason: collision with root package name */
public static final double[] f11588e;
public static final long[] f;
/* renamed from: g, reason: collision with root package name */
public static final long[] f11589g;
/* renamed from: h, reason: collision with root package name */
public static final double[] f11590h;
static {
StrictMath.log(Double.MAX_VALUE);
f11585a = new double[][]{new double[]{1.0d, 5.669184079525E-24d}, new double[]{-0.25d, -0.25d}, new double[]{0.3333333134651184d, 1.986821492305628E-8d}, new double[]{-0.25d, -6.663542893624021E-14d}, new double[]{0.19999998807907104d, 1.1921056801463227E-8d}, new double[]{-0.1666666567325592d, -7.800414592973399E-9d}, new double[]{0.1428571343421936d, 5.650007086920087E-9d}, new double[]{-0.12502530217170715d, -7.44321345601866E-11d}, new double[]{0.11113807559013367d, 9.219544613762692E-9d}};
f11586b = new double[]{0.0d, 0.1246747374534607d, 0.24740394949913025d, 0.366272509098053d, 0.4794255495071411d, 0.5850973129272461d, 0.6816387176513672d, 0.7675435543060303d, 0.8414709568023682d, 0.902267575263977d, 0.9489846229553223d, 0.9808930158615112d, 0.9974949359893799d, 0.9985313415527344d};
c = new double[]{0.0d, -4.068233003401932E-9d, 9.755392680573412E-9d, 1.9987994582857286E-8d, -1.0902938113007961E-8d, -3.9986783938944604E-8d, 4.23719669792332E-8d, -5.207000323380292E-8d, 2.800552834259E-8d, 1.883511811213715E-8d, -3.5997360512765566E-9d, 4.116164446561962E-8d, 5.0614674548127384E-8d, -1.0129027912496858E-9d};
f11587d = new double[]{1.0d, 0.9921976327896118d, 0.9689123630523682d, 0.9305076599121094d, 0.8775825500488281d, 0.8109631538391113d, 0.7316888570785522d, 0.6409968137741089d, 0.5403022766113281d, 0.4311765432357788d, 0.3153223395347595d, 0.19454771280288696d, 0.07073719799518585d, -0.05417713522911072d};
f11588e = new double[]{0.0d, 3.4439717236742845E-8d, 5.865827662008209E-8d, -3.7999795083850525E-8d, 1.184154459111628E-8d, -3.43338934259355E-8d, 1.1795268640216787E-8d, 4.438921624363781E-8d, 2.925681159240093E-8d, -2.6437112632041807E-8d, 2.2860509143963117E-8d, -4.813899778443457E-9d, 3.6725170580355583E-9d, 2.0217439756338078E-10d};
f = new long[]{2935890503282001226L, 9154082963658192752L, 3952090531849364496L, 9193070505571053912L, 7910884519577875640L, 113236205062349959L, 4577762542105553359L, -5034868814120038111L, 4208363204685324176L, 5648769086999809661L, 2819561105158720014L, -4035746434778044925L, -302932621132653753L, -2644281811660520851L, -3183605296591799669L, 6722166367014452318L, -3512299194304650054L, -7278142539171889152L};
f11589g = new long[]{-3958705157555305932L, -4267615245585081135L};
f11590h = new double[]{0.0d, 0.125d, 0.25d, 0.375d, 0.5d, 0.625d, 0.75d, 0.875d, 1.0d, 1.125d, 1.25d, 1.375d, 1.5d, 1.625d};
}
public static double a(double d4) {
double d5;
if (d4 != d4) {
return d4;
}
if (d4 == d4 && d4 < 4.503599627370496E15d && d4 > -4.503599627370496E15d) {
long j4 = (long) d4;
if (d4 < 0.0d && j4 != d4) {
j4--;
}
if (j4 == 0) {
d5 = j4 * d4;
} else {
d5 = j4;
}
} else {
d5 = d4;
}
if (d5 == d4) {
return d5;
}
double d6 = d5 + 1.0d;
if (d6 == 0.0d) {
return d4 * d6;
}
return d6;
}
public static double b(double d4) {
double g4;
double d5 = 0.0d;
if (d4 < 0.0d) {
d4 = -d4;
}
if (d4 != d4 || d4 == Double.POSITIVE_INFINITY) {
return Double.NaN;
}
int i4 = 0;
if (d4 > 3294198.0d) {
double[] dArr = new double[3];
e(d4, dArr);
i4 = ((int) dArr[0]) & 3;
d4 = dArr[1];
d5 = dArr[2];
} else if (d4 > 1.5707963267948966d) {
a aVar = new a(d4);
i4 = aVar.f11582a & 3;
d4 = aVar.f11583b;
d5 = aVar.c;
}
if (i4 != 0) {
if (i4 != 1) {
if (i4 != 2) {
if (i4 != 3) {
return Double.NaN;
}
return g(d4, d5);
}
g4 = c(d4, d5);
} else {
g4 = g(d4, d5);
}
return -g4;
}
return c(d4, d5);
}
public static double c(double d4, double d5) {
double d6 = 1.5707963267948966d - d4;
return g(d6, (6.123233995736766E-17d - d5) + (-((d6 - 1.5707963267948966d) + d4)));
}
public static double d(double d4) {
if (d4 != 0.0d) {
long doubleToRawLongBits = Double.doubleToRawLongBits(d4);
if (((Long.MIN_VALUE & doubleToRawLongBits) != 0 || d4 != d4) && d4 != 0.0d) {
return Double.NaN;
}
if (d4 == Double.POSITIVE_INFINITY) {
return Double.POSITIVE_INFINITY;
}
int i4 = ((int) (doubleToRawLongBits >> 52)) - 1023;
if ((9218868437227405312L & doubleToRawLongBits) == 0) {
if (d4 == 0.0d) {
return Double.NEGATIVE_INFINITY;
}
doubleToRawLongBits <<= 1;
while ((4503599627370496L & doubleToRawLongBits) == 0) {
i4--;
doubleToRawLongBits <<= 1;
}
}
if ((i4 == -1 || i4 == 0) && d4 < 1.01d && d4 > 0.99d) {
double d5 = d4 - 1.0d;
double d6 = d5 * 1.073741824E9d;
double d7 = (d5 + d6) - d6;
double d8 = d5 - d7;
double[][] dArr = f11585a;
double[] dArr2 = dArr[dArr.length - 1];
double d9 = dArr2[0];
double d10 = dArr2[1];
for (int length = dArr.length - 2; length >= 0; length--) {
double d11 = d9 * d7;
double d12 = (d10 * d8) + (d10 * d7) + (d9 * d8);
double d13 = d11 * 1.073741824E9d;
double d14 = (d11 + d13) - d13;
double d15 = (d11 - d14) + d12;
double[] dArr3 = dArr[length];
double d16 = d14 + dArr3[0];
double d17 = d15 + dArr3[1];
double d18 = d16 * 1.073741824E9d;
d9 = (d16 + d18) - d18;
d10 = (d16 - d9) + d17;
}
double d19 = d9 * d7;
double d20 = (d10 * d8) + (d7 * d10) + (d9 * d8);
double d21 = 1.073741824E9d * d19;
double d22 = (d19 + d21) - d21;
return (d19 - d22) + d20 + d22;
}
long j4 = 4499201580859392L & doubleToRawLongBits;
double[] dArr4 = b.f11584a[(int) (j4 >> 42)];
double d23 = (doubleToRawLongBits & 4398046511103L) / (j4 + 4.503599627370496E15d);
double d24 = (((((((((((-0.16624882440418567d) * d23) + 0.19999954120254515d) * d23) - 0.2499999997677497d) * d23) + 0.3333333333332802d) * d23) - 0.5d) * d23) + 1.0d) * d23;
double d25 = i4;
double d26 = 0.6931470632553101d * d25;
double d27 = dArr4[0];
double d28 = d26 + d27;
double d29 = (-((d28 - d26) - d27)) + 0.0d;
double d30 = d28 + d24;
double d31 = d29 + (-((d30 - d28) - d24));
double d32 = d25 * 1.1730463525082348E-7d;
double d33 = d30 + d32;
double d34 = d31 + (-((d33 - d30) - d32));
double d35 = dArr4[1];
double d36 = d33 + d35;
double d37 = d36 + 0.0d;
return d34 + (-((d36 - d33) - d35)) + (-((d37 - d36) - 0.0d)) + d37;
}
return Double.NEGATIVE_INFINITY;
}
public static void e(double d4, double[] dArr) {
long j4;
long j5;
long j6;
boolean z3;
boolean z4;
boolean z5;
boolean z6;
boolean z7;
boolean z8;
boolean z9;
boolean z10;
boolean z11;
boolean z12;
boolean z13;
boolean z14;
boolean z15;
boolean z16;
boolean z17;
boolean z18;
boolean z19;
boolean z20;
boolean z21;
boolean z22;
boolean z23;
long j7;
long doubleToRawLongBits = Double.doubleToRawLongBits(d4);
int i4 = ((int) ((doubleToRawLongBits >> 52) & 2047)) - 1022;
long j8 = ((doubleToRawLongBits & 4503599627370495L) | 4503599627370496L) << 11;
int i5 = i4 >> 6;
int i6 = i4 - (i5 << 6);
long[] jArr = f;
if (i6 != 0) {
if (i5 == 0) {
j7 = 0;
} else {
j7 = jArr[i5 - 1] << i6;
}
long j9 = jArr[i5];
int i7 = 64 - i6;
j4 = j7 | (j9 >>> i7);
long j10 = jArr[i5 + 1];
j5 = (j9 << i6) | (j10 >>> i7);
j6 = (jArr[i5 + 2] >>> i7) | (j10 << i6);
} else {
if (i5 == 0) {
j4 = 0;
} else {
j4 = jArr[i5 - 1];
}
j5 = jArr[i5];
j6 = jArr[i5 + 1];
}
long j11 = j8 >>> 32;
long j12 = j8 & 4294967295L;
long j13 = j5 >>> 32;
long j14 = j5 & 4294967295L;
long j15 = j11 * j13;
long j16 = j12 * j14;
long j17 = j13 * j12;
long j18 = j14 * j11;
long j19 = j16 + (j18 << 32);
long j20 = j15 + (j18 >>> 32);
if ((j16 & Long.MIN_VALUE) != 0) {
z3 = true;
} else {
z3 = false;
}
if ((j18 & 2147483648L) != 0) {
z4 = true;
} else {
z4 = false;
}
long j21 = j19 & Long.MIN_VALUE;
if (j21 != 0) {
z5 = true;
} else {
z5 = false;
}
if ((z3 && z4) || ((z3 || z4) && !z5)) {
j20++;
}
if (j21 != 0) {
z6 = true;
} else {
z6 = false;
}
if ((j17 & 2147483648L) != 0) {
z7 = true;
} else {
z7 = false;
}
long j22 = j19 + (j17 << 32);
long j23 = j20 + (j17 >>> 32);
long j24 = j22 & Long.MIN_VALUE;
if (j24 != 0) {
z8 = true;
} else {
z8 = false;
}
if ((z6 && z7) || ((z6 || z7) && !z8)) {
j23++;
}
long j25 = j6 >>> 32;
long j26 = (j11 * j25) + ((((j6 & 4294967295L) * j11) + (j25 * j12)) >>> 32);
if (j24 != 0) {
z9 = true;
} else {
z9 = false;
}
if ((j26 & Long.MIN_VALUE) != 0) {
z10 = true;
} else {
z10 = false;
}
long j27 = j22 + j26;
if ((j27 & Long.MIN_VALUE) != 0) {
z11 = true;
} else {
z11 = false;
}
if ((z9 && z10) || ((z9 || z10) && !z11)) {
j23++;
}
long j28 = j4 >>> 32;
long j29 = j4 & 4294967295L;
long j30 = (j12 * j29) + (((j11 * j29) + (j12 * j28)) << 32) + j23;
int i8 = (int) (j30 >>> 62);
long j31 = (j27 >>> 62) | (j30 << 2);
long j32 = j27 << 2;
long j33 = j31 >>> 32;
long j34 = j31 & 4294967295L;
long[] jArr2 = f11589g;
long j35 = jArr2[0];
long j36 = j35 >>> 32;
long j37 = j35 & 4294967295L;
long j38 = j34 * j37;
long j39 = j34 * j36;
long j40 = j33 * j37;
long j41 = j38 + (j40 << 32);
long j42 = (j33 * j36) + (j40 >>> 32);
if ((j38 & Long.MIN_VALUE) != 0) {
z12 = true;
} else {
z12 = false;
}
if ((j40 & 2147483648L) != 0) {
z13 = true;
} else {
z13 = false;
}
long j43 = j41 & Long.MIN_VALUE;
if (j43 != 0) {
z14 = true;
} else {
z14 = false;
}
if ((z12 && z13) || ((z12 || z13) && !z14)) {
j42++;
}
if (j43 != 0) {
z15 = true;
} else {
z15 = false;
}
if ((j39 & 2147483648L) != 0) {
z16 = true;
} else {
z16 = false;
}
long j44 = j41 + (j39 << 32);
long j45 = j42 + (j39 >>> 32);
long j46 = j44 & Long.MIN_VALUE;
if (j46 != 0) {
z17 = true;
} else {
z17 = false;
}
if ((z15 && z16) || ((z15 || z16) && !z17)) {
j45++;
}
long j47 = jArr2[1];
long j48 = j47 >>> 32;
long j49 = (j33 * j48) + (((j33 * (j47 & 4294967295L)) + (j34 * j48)) >>> 32);
if (j46 != 0) {
z18 = true;
} else {
z18 = false;
}
if ((j49 & Long.MIN_VALUE) != 0) {
z19 = true;
} else {
z19 = false;
}
long j50 = j44 + j49;
long j51 = j50 & Long.MIN_VALUE;
if (j51 != 0) {
z20 = true;
} else {
z20 = false;
}
if ((z18 && z19) || ((z18 || z19) && !z20)) {
j45++;
}
long j52 = j32 >>> 32;
long j53 = (j52 * j36) + (((j52 * j37) + ((j32 & 4294967295L) * j36)) >>> 32);
if (j51 != 0) {
z21 = true;
} else {
z21 = false;
}
if ((j53 & Long.MIN_VALUE) != 0) {
z22 = true;
} else {
z22 = false;
}
if (((j50 + j53) & Long.MIN_VALUE) != 0) {
z23 = true;
} else {
z23 = false;
}
if ((z21 && z22) || ((z21 || z22) && !z23)) {
j45++;
}
double d5 = (j45 >>> 12) / 4.503599627370496E15d;
double d6 = ((((j45 & 4095) << 40) + (f11589g[0] >>> 24)) / 4.503599627370496E15d) / 4.503599627370496E15d; // r34 = f11589g[0](smali: aget-wide v5, v2)
double d7 = d5 + d6;
dArr[0] = i8;
dArr[1] = d7 * 2.0d;
dArr[2] = (-((d7 - d5) - d6)) * 2.0d;
}
/* JADX WARN: Removed duplicated region for block: B:20:0x005f */
/* JADX WARN: Removed duplicated region for block: B:22:0x0063 */
/* JADX WARN: Removed duplicated region for block: B:32:0x007a */
/*
Code decompiled incorrectly, please refer to instructions dump.
*/
public static double f(double d4) {
boolean z3;
double d5;
double d6;
double g4;
int i4 = 0;
double d7 = 0.0d;
if (d4 < 0.0d) {
d5 = -d4;
z3 = true;
} else {
z3 = false;
d5 = d4;
}
if (d5 == 0.0d) {
if (Double.doubleToRawLongBits(d4) >= 0) {
return 0.0d;
}
return -0.0d;
}
if (d5 != d5 || d5 == Double.POSITIVE_INFINITY) {
return Double.NaN;
}
if (d5 > 3294198.0d) {
double[] dArr = new double[3];
e(d5, dArr);
i4 = ((int) dArr[0]) & 3;
d5 = dArr[1];
d6 = dArr[2];
} else {
if (d5 > 1.5707963267948966d) {
a aVar = new a(d5);
int i5 = aVar.f11582a & 3;
d5 = aVar.f11583b;
d6 = aVar.c;
i4 = i5;
}
if (z3) {
i4 ^= 2;
}
// smali 证实: f() = sin(象限 switch!):
// case 0 -> g(d5,0)(sin(0-π/2))
// case 1 -> c(d5,0)(sin(π/2-π) = cos(余角))
// case 2 -> -g(d5,0)(sin(π-3π/2) = -sin(余角))
// case 3 -> -c(d5,0)(sin(3π/2-2π) = -cos(余角))
// jadx 畸形嵌套 if 把 case 0 还原成 return NaN(小角度全 NaN!),
// 且 case 1/2/3 全走 g() —— 旋转因子表 329 个 NaN + 频谱错乱的根源!
if (i4 == 0) {
return g(d5, d7);
}
if (i4 == 1) {
return c(d5, d7);
}
if (i4 == 2) {
return -g(d5, d7);
}
return -c(d5, d7);
}
d7 = d6;
// jadx 还原不完整(smali 650 行, jadx 只还原 63 行); f() 未被任何代码调用(死代码),
// 按 smali 尾部语义补 return(cond_0: v22 = v22 + v14; return v22)
return d7;
}
public static double g(double d4, double d5) {
int i4 = (int) ((8.0d * d4) + 0.5d);
double d6 = d4 - f11590h[i4];
double d7 = f11586b[i4];
double d8 = c[i4];
double d9 = f11587d[i4];
double d10 = f11588e[i4];
double d11 = d6 * d6;
double d12 = ((((((2.7553817452272217E-6d * d11) - 1.9841269659586505E-4d) * d11) + 0.008333333333329196d) * d11) - 0.16666666666666666d) * d11 * d6;
double d13 = ((((((2.479773539153719E-5d * d11) - 0.0013888888689039883d) * d11) + 0.041666666666621166d) * d11) - 0.49999999999999994d) * d11;
double d14 = 1.073741824E9d * d6;
double d15 = (d6 + d14) - d14;
double d16 = (d6 - d15) + d12;
double d17 = d7 + 0.0d;
double d18 = d9 * d15;
double d19 = d17 + d18;
double d20 = (d10 * d16) + (d8 * d13) + (d10 * d15) + (d9 * d16) + (d7 * d13) + (-((d17 - 0.0d) - d7)) + 0.0d + (-((d19 - d17) - d18)) + d8;
if (d5 != 0.0d) {
double d21 = (((d13 + 1.0d) * (d9 + d10)) - ((d15 + d16) * (d7 + d8))) * d5;
double d22 = d19 + d21;
d20 += -((d22 - d19) - d21);
d19 = d22;
}
return d19 + d20;
}
}
+9
View File
@@ -0,0 +1,9 @@
package g3;
/* loaded from: classes.dex */
public abstract class d {
/* renamed from: a, reason: collision with root package name */
public static final double[][] f11591a = {new double[]{0.0d, 0.0d}, new double[]{9.760860120877624E-4d, -3.903230345984362E-11d}, new double[]{0.0019512202125042677d, -8.124251825289188E-11d}, new double[]{0.0029254043474793434d, -1.8374207360194882E-11d}, new double[]{0.0038986406289041042d, -2.1324678121885073E-10d}, new double[]{0.004870930686593056d, -4.5199654318611534E-10d}, new double[]{0.00584227591753006d, -2.933016992001806E-10d}, new double[]{0.006812678650021553d, -2.325147219074669E-10d}, new double[]{0.007782140746712685d, -3.046577356838847E-10d}, new double[]{0.008750664070248604d, -5.500631513861575E-10d}, new double[]{0.00971824862062931d, 8.48292035519895E-10d}, new double[]{0.010684899985790253d, 1.1422610134013436E-10d}, new double[]{0.01165061630308628d, 9.168889933128375E-10d}, new double[]{0.012615403160452843d, -5.303786078838E-10d}, new double[]{0.013579258695244789d, -5.688639355498786E-10d}, new double[]{0.01454218477010727d, 7.296670293275653E-10d}, new double[]{0.015504186972975731d, -4.370104767451421E-10d}, new double[]{0.016465261578559875d, 1.43695591408832E-9d}, new double[]{0.01742541790008545d, -1.1862263158849434E-9d}, new double[]{0.018384650349617004d, -9.482976524690715E-10d}, new double[]{0.01934296265244484d, 1.9068609515836638E-10d}, new double[]{0.020300358533859253d, 2.655990315697216E-10d}, new double[]{0.021256837993860245d, 1.0315548713040775E-9d}, new double[]{0.022212404757738113d, 5.13345647019085E-10d}, new double[]{0.02316705882549286d, 4.5604151934208014E-10d}, new double[]{0.02412080392241478d, -1.1255706987475148E-9d}, new double[]{0.025073636323213577d, 1.2289023836765196E-9d}, new double[]{0.02602556347846985d, 1.7990281828096504E-9d}, new double[]{0.026976589113473892d, -1.4152718164638451E-9d}, new double[]{0.02792670577764511d, 7.568772963781632E-10d}, new double[]{0.0288759246468544d, -1.1449998592111558E-9d}, new double[]{0.029824241995811462d, -1.6850976862319495E-9d}, new double[]{0.030771657824516296d, 8.422373919843096E-10d}, new double[]{0.0317181795835495d, 6.872350402175489E-10d}, new double[]{0.03266380727291107d, -4.541194749189272E-10d}, new double[]{0.03360854089260101d, -8.9064764856495E-10d}, new double[]{0.034552380442619324d, 1.0640404096769032E-9d}, new double[]{0.0354953333735466d, -3.5901655945224663E-10d}, new double[]{0.03643739968538284d, -3.4829517943661266E-9d}, new double[]{0.037378571927547455d, 8.149473794244232E-10d}, new double[]{0.03831886500120163d, -6.990650304449166E-10d}, new double[]{0.03925827145576477d, 1.0883076226453258E-9d}, new double[]{0.040196798741817474d, 3.845192807999274E-10d}, new double[]{0.04113444685935974d, -1.1570594692045927E-9d}, new double[]{0.04207121580839157d, -1.8877045166697178E-9d}, new double[]{0.043007105588912964d, -1.6332083257987747E-10d}, new double[]{0.04394212365150452d, -1.7950057534514933E-9d}, new double[]{0.04487626254558563d, 2.302710041648838E-9d}, new double[]{0.045809537172317505d, -1.1410233017161343E-9d}, new double[]{0.04674194008111954d, -3.0498741599744685E-9d}, new double[]{0.04767347127199173d, -1.8026348269183678E-9d}, new double[]{0.04860413819551468d, -3.233204600453039E-9d}, new double[]{0.04953393340110779d, 1.7211688427961583E-9d}, new double[]{0.05046287178993225d, -2.329967807055457E-10d}, new double[]{0.05139094591140747d, -4.191810118556531E-11d}, new double[]{0.052318163216114044d, -3.5574324788328143E-9d}, new double[]{0.053244516253471375d, -1.7346590916458485E-9d}, new double[]{0.05417001247406006d, -4.343048751383674E-10d}, new double[]{0.055094651877880096d, 1.92909364037955E-9d}, new double[]{0.056018441915512085d, -5.139745677199588E-10d}, new double[]{0.05694137513637543d, 1.2637629975129189E-9d}, new double[]{0.05786345899105072d, 1.3840561112481119E-9d}, new double[]{0.058784693479537964d, 1.414889689612056E-9d}, new double[]{0.05970507860183716d, 2.9199191907666474E-9d}, new double[]{0.0606246218085289d, 7.90594243412116E-12d}, new double[]{0.06154331564903259d, 1.6844747839686189E-9d}, new double[]{0.06246116757392883d, 2.0498074572151747E-9d}, new double[]{0.06337818503379822d, -4.800180493433863E-9d}, new double[]{0.06429435312747955d, -2.4220822960064277E-9d}, new double[]{0.06520968675613403d, -4.179048566709334E-9d}, new double[]{0.06612417101860046d, 6.363872957010456E-9d}, new double[]{0.06703783571720123d, 9.339468680056365E-10d}, new double[]{0.06795066595077515d, -4.04226739708981E-9d}, new double[]{0.0688626617193222d, -7.043545052852817E-9d}, new double[]{0.06977382302284241d, -6.552819560439773E-9d}, new double[]{0.07068414986133575d, -1.0571674860370546E-9d}, new double[]{0.07159365713596344d, -3.948954622015801E-9d}, new double[]{0.07250232994556427d, 1.1776625988228244E-9d}, new double[]{0.07341018319129944d, 9.221072639606492E-10d}, new double[]{0.07431721687316895d, -3.219119568928366E-9d}, new double[]{0.0752234160900116d, 5.147575929018918E-9d}, new double[]{0.07612881064414978d, -2.291749683541979E-9d}, new doubleLine truncated
new double[]{0.6813591718673706d, 5.294053246347931E-8d}, new double[]{0.681853175163269d, -1.2220826223585654E-9d}, new double[]{0.6823468208312988d, 5.8377876767612725E-8d}, new double[]{0.6828403472900391d, -6.437492120743254E-9d}, new double[]{0.6833335161209106d, 4.2990710043633113E-8d}, new double[]{0.6838265657424927d, -3.1516131027023284E-8d}, new double[]{0.684319257736206d, 8.70017386744679E-9d}, new double[]{0.6848117113113403d, 4.466959125843237E-8d}, new double[]{0.6853040456771851d, -4.25782656420497E-8d}, new double[]{0.6857960224151611d, -1.4386267593671393E-8d}, new double[]{0.6862877607345581d, 1.0274494061148778E-8d}, new double[]{0.686779260635376d, 3.164186629229597E-8d}, new double[]{0.6872705221176147d, 4.995334552140326E-8d}, new double[]{0.687761664390564d, -5.3763211240398744E-8d}, new double[]{0.6882524490356445d, -4.0852427502515625E-8d}, new double[]{0.688742995262146d, -3.0287143914420064E-8d}, new double[]{0.6892333030700684d, -2.183125937905008E-8d}, new double[]{0.6897233724594116d, -1.524901992178814E-8d}, new double[]{0.6902132034301758d, -1.0305018010328949E-8d}, new double[]{0.6907027959823608d, -6.764191876212205E-9d}, new double[]{0.6911921501159668d, -4.391824838015402E-9d}, new double[]{0.6916812658309937d, -2.9535446262017846E-9d}, new double[]{0.6921701431274414d, -2.2153227096187463E-9d}, new double[]{0.6926587820053101d, -1.943473623641502E-9d}};
}
+41
View File
@@ -0,0 +1,41 @@
package i3;
/* loaded from: classes.dex */
public final class a implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ int f11686g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ int f11687h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ float[] f11688i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ float[] f11689j;
/* renamed from: k, reason: collision with root package name */
public final /* synthetic */ d f11690k;
public a(d dVar, int i4, int i5, int i6, float[] fArr, float[] fArr2) {
this.f11690k = dVar;
this.f = i4;
this.f11686g = i5;
this.f11687h = i6;
this.f11688i = fArr;
this.f11689j = fArr2;
}
@Override // java.lang.Runnable
public final void run() {
int i4 = this.f11686g;
int i5 = this.f;
if (i5 >= i4) {
return;
}
float f = this.f11689j[this.f11687h + i5];
throw null;
}
}
+41
View File
@@ -0,0 +1,41 @@
package i3;
/* loaded from: classes.dex */
public final class b implements Runnable {
public final /* synthetic */ long f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ long f11691g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ long f11692h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ k3.d f11693i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ k3.d f11694j;
/* renamed from: k, reason: collision with root package name */
public final /* synthetic */ d f11695k;
public b(d dVar, long j4, long j5, long j6, k3.d dVar2, k3.d dVar3) {
this.f11695k = dVar;
this.f = j4;
this.f11691g = j5;
this.f11692h = j6;
this.f11693i = dVar2;
this.f11694j = dVar3;
}
@Override // java.lang.Runnable
public final void run() {
long j4 = this.f11691g;
long j5 = this.f;
if (j5 >= j4) {
return;
}
this.f11694j.b(this.f11692h + j5);
throw null;
}
}
+55
View File
@@ -0,0 +1,55 @@
package i3;
import k3.g;
import k3.s;
/* loaded from: classes.dex */
public final class c implements Runnable {
public final /* synthetic */ int f = 1;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ long f11696g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ long f11697h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ g f11698i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ Object f11699j;
public c(long j4, long j5, s sVar, s sVar2) {
this.f11696g = j4;
this.f11697h = j5;
this.f11698i = sVar;
this.f11699j = sVar2;
}
@Override // java.lang.Runnable
public final void run() {
switch (this.f) {
case 0:
k3.d dVar = (k3.d) this.f11698i;
long j4 = this.f11697h;
long j5 = this.f11696g;
if (j5 >= j4) {
return;
}
dVar.b(j5 * 2);
throw null;
default:
for (long j6 = this.f11696g; j6 < this.f11697h; j6++) {
((s) this.f11698i).c(j6, ((s) this.f11699j).b(j6));
}
return;
}
}
public c(d dVar, long j4, long j5, k3.d dVar2) {
this.f11699j = dVar;
this.f11696g = j4;
this.f11697h = j5;
this.f11698i = dVar2;
}
}
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
package j1;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.ve3nea.morse_expert.DecodedTextView;
import com.ve3nea.morse_expert.ScaleView;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
/**
* 照搬自 Morse Expert 1.15 j1.C1646n, 类名/字段名保持混淆原名。
* R8 合并类手术: 只保留视图持有器(6 参构造 + 5 个 View 字段);
* 其余 158-1278 行(广告/计费合并方法)已整段裁剪(用户要求广告不迁移)。
*/
public final class C1646n {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public Object f11888g;
/* renamed from: h, reason: collision with root package name */
public Object f11889h;
/* renamed from: i, reason: collision with root package name */
public Object f11890i;
/* renamed from: j, reason: collision with root package name */
public Object f11891j;
/* renamed from: k, reason: collision with root package name */
public Object f11892k;
public C1646n(ConstraintLayout constraintLayout, DecodedTextView decodedTextView, ScaleView scaleView, TextView textView, LinearLayout linearLayout, WaterfallSurfaceView waterfallSurfaceView) {
this.f = 1;
this.f11888g = decodedTextView;
this.f11889h = scaleView;
this.f11890i = textView;
this.f11891j = linearLayout;
this.f11892k = waterfallSurfaceView;
}
}
+100
View File
@@ -0,0 +1,100 @@
package j3;
/* loaded from: classes.dex */
public final class a implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ int f11919g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ int f11920h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ int f11921i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ float[] f11922j;
/* renamed from: k, reason: collision with root package name */
public final /* synthetic */ float[] f11923k;
/* renamed from: l, reason: collision with root package name */
public final /* synthetic */ int f11924l;
public /* synthetic */ a(int i4, int i5, int i6, int i7, int i8, float[] fArr, float[] fArr2) {
this.f = i8;
this.f11919g = i4;
this.f11920h = i5;
this.f11921i = i6;
this.f11922j = fArr;
this.f11923k = fArr2;
this.f11924l = i7;
}
@Override // java.lang.Runnable
public final void run() {
switch (this.f) {
case 0:
int i4 = this.f11919g;
int i5 = this.f11920h;
int i6 = i4 + i5;
int i7 = this.f11921i;
while (true) {
int i8 = i7;
if (i8 > 512) {
i7 = i8 >> 2;
c.w(i7, i6 - i7, this.f11924l - (i8 >> 3), this.f11922j, this.f11923k);
} else {
int i9 = this.f11924l;
float[] fArr = this.f11923k;
c.u(i8, 1, this.f11922j, i6 - i8, i9, fArr);
int i10 = i4 - i8;
int i11 = 0;
int i12 = i5 - i8;
while (i12 > 0) {
int i13 = i11 + 1;
int i14 = this.f11924l;
float[] fArr2 = this.f11923k;
int i15 = i12;
i11 = i13;
int i16 = this.f11924l;
float[] fArr3 = this.f11923k;
c.u(i8, c.E(i8, i12, i13, this.f11919g, i14, this.f11922j, fArr2), this.f11922j, i10 + i15, i16, fArr3);
i12 = i15 - i8;
}
return;
}
}
default:
int i17 = this.f11919g;
int i18 = this.f11920h;
int i19 = i17 + i18;
int i20 = this.f11921i;
int i21 = 1;
while (i20 > 512) {
i20 >>= 2;
i21 <<= 2;
c.y(i20, i19 - i20, this.f11924l - i20, this.f11922j, this.f11923k);
}
int i22 = this.f11924l;
float[] fArr4 = this.f11923k;
c.u(i20, 0, this.f11922j, i19 - i20, i22, fArr4);
int i23 = i21 >> 1;
int i24 = i17 - i20;
int i25 = i18 - i20;
while (i25 > 0) {
int i26 = i23 + 1;
int i27 = this.f11924l;
float[] fArr5 = this.f11923k;
int i28 = i25;
i23 = i26;
int i29 = this.f11924l;
float[] fArr6 = this.f11923k;
c.u(i20, c.E(i20, i25, i26, this.f11919g, i27, this.f11922j, fArr5), this.f11922j, i24 + i28, i29, fArr6);
i25 = i28 - i20;
}
return;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package j3;
import k3.d;
/* loaded from: classes.dex */
public final class b implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ long f11925g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ long f11926h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ long f11927i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ d f11928j;
/* renamed from: k, reason: collision with root package name */
public final /* synthetic */ d f11929k;
/* renamed from: l, reason: collision with root package name */
public final /* synthetic */ long f11930l;
public /* synthetic */ b(long j4, long j5, long j6, d dVar, d dVar2, long j7, int i4) {
this.f = i4;
this.f11925g = j4;
this.f11926h = j5;
this.f11927i = j6;
this.f11928j = dVar;
this.f11929k = dVar2;
this.f11930l = j7;
}
@Override // java.lang.Runnable
public final void run() {
switch (this.f) {
case 0:
long j4 = this.f11925g;
long j5 = this.f11926h;
long j6 = j4 + j5;
long j7 = this.f11927i;
while (j7 > 512) {
long j8 = j7 >> 2;
c.x(j8, j6 - j8, this.f11930l - (j7 >> 3), this.f11928j, this.f11929k);
j7 = j8;
}
c.v(j7, 1L, j6 - j7, this.f11930l, this.f11928j, this.f11929k);
long j9 = j4 - j7;
long j10 = j5 - j7;
long j11 = 0;
for (long j12 = 0; j10 > j12; j12 = 0) {
long j13 = j11 + 1;
long j14 = j10;
c.v(j7, c.F(j7, j10, j13, this.f11925g, this.f11930l, this.f11928j, this.f11929k), j9 + j14, this.f11930l, this.f11928j, this.f11929k);
j10 = j14 - j7;
j11 = j13;
j9 = j9;
}
return;
case 1:
long j15 = this.f11925g;
long j16 = this.f11926h;
long j17 = j15 + j16;
long j18 = this.f11927i;
long j19 = 1;
while (j18 > 512) {
long j20 = j18 >> 2;
j19 <<= 2;
c.z(j20, j17 - j20, this.f11930l - j20, this.f11928j, this.f11929k);
j18 = j20;
}
c.v(j18, 0L, j17 - j18, this.f11930l, this.f11928j, this.f11929k);
long j21 = j19 >> 1;
long j22 = j15 - j18;
long j23 = j16 - j18;
while (j23 > 0) {
long j24 = j21 + 1;
long j25 = j23;
j21 = j24;
c.v(j18, c.F(j18, j23, j24, this.f11925g, this.f11930l, this.f11928j, this.f11929k), j22 + j25, this.f11930l, this.f11928j, this.f11929k);
j23 = j25 - j18;
}
return;
default:
for (long j26 = this.f11925g; j26 < this.f11926h; j26++) {
this.f11928j.c(this.f11927i + j26, this.f11929k.b(this.f11930l + j26));
}
return;
}
}
public b(long j4, long j5, long j6, long j7, d dVar, d dVar2) {
this.f = 2;
this.f11925g = j4;
this.f11926h = j5;
this.f11928j = dVar;
this.f11927i = j6;
this.f11929k = dVar2;
this.f11930l = j7;
}
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
package k3;
import java.lang.Thread;
/* loaded from: classes.dex */
public final class a implements Thread.UncaughtExceptionHandler {
@Override // java.lang.Thread.UncaughtExceptionHandler
public final void uncaughtException(Thread thread, Throwable th) {
th.printStackTrace();
}
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more