Compare commits

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

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

- DataParser.parseAmSatLivePage: table rows to satellite names,
  TEVEL2-1 thru TEVEL2-9 range expansion, bracket-alias matching
- SettingsRepo: AMSAT FM/Linear catnum sets persisted in prefs
- DatabaseRepo: fetch AMSAT pages in updateFromRemote, best-effort
  (failure keeps previous list, never blocks the update)
- getIdsWithModes: AND isAlive = 1
- MutualScreen: FilterChip row inside MatchSearchCard
- Tests: parser (4) + viewmodel (3) + repo fakes
2026-09-23 23:25:37 +08:00
atsunatsu c4c4dd682d build: bump to 4.4.7-ba7opf.14.2 2026-09-23 15:54:59 +08:00
atsunatsu 62ad995aeb feat(map): multiple marked stations per grid with pin-to-top
- MarkedStation storage upgraded to an ordered list per grid
  (Map<String, List<MarkedStation>>); JSON array shape with legacy
  v14.1 single-object migration on read
- MapAction: SetMarkedStation appends (duplicate callsign ignored),
  RemoveMarkedStation deletes by callsign, PinMarkedStation moves a
  mark to the top of the list
- MaidenheadGridOverlay: red fill for any marked grid unchanged;
  first-call label mode shows the FIRST (pinned) marked callsign
- MapScreen: one row per mark (pale-yellow callsign), pin icon
  (冖 + stroke ↑, drawn in-app) left of the Marked label — shown only
  when the grid has 2+ marks; pinned row's icon grey, others pale
  yellow; delete icon per row
- New ic_pin.xml vector drawable; strings grid_mark_pin
2026-09-23 15:47:17 +08:00
atsunatsu c8fdd2e93e build: bump to 4.4.7-ba7opf.14.1 2026-09-23 15:15:47 +08:00
atsunatsu dc013f920d feat(map,mutual): uppercase callsign input; mutual match honors historical pass window
- MapScreen: mark-a-station callsign input uppercases as you type
- MutualViewModel: read passesSettings.hoursBefore, shift the mutual
  match query start to now-hoursBefore so historical passes are
  included when the main Passes page has a lookback window set;
  endTime stays at now+hoursAhead
2026-09-23 15:08:46 +08:00
atsunatsu f36a426855 build: bump to 4.4.7-ba7opf.14 2026-09-23 13:26:00 +08:00
atsunatsu 4da59bdd17 feat(map): mark a station you want to contact in unworked grids
- New MarkedStation model + ISettingsRepo/SettingsRepo persistence
  (single JSON key markedGridStations, org.json only)
- MapState/MapViewModel: markedGrids state, Set/RemoveMarkedStation
  actions; marks auto-clear once the grid becomes worked
- MaidenheadGridOverlay: red fill (alpha 90, same as green worked
  fill) for marked grids at both zoom levels; first-call label mode
  shows the marked callsign in pale yellow on red cells
- MapScreen: unworked-grid dialog gains a 'mark a station you want
  to contact' button + callsign input dialog; marked row shows red
  callsign, 'Marked' in the count column, marking date, delete icon
- i18n: values/ + values-zh/ (grid_mark_*)
2026-09-23 13:14:38 +08:00
atsunatsu b8138c948b build: bump to 4.4.7-ba7opf.13 2026-09-22 16:41:59 +08:00
atsunatsu 0e28231c4a feat(map): run the Match query before navigating to the mutual page
Tapping the grid-QSO dialog "Match" button now starts the pass query on
the map page and only navigates to the match page once it finishes, so the
page's first frame already contains the results — the time-range card at
the top and the pass curves right below, with no scroll flicker. The dialog
button shows a spinner and is disabled while calculating. A uiState
collector in MainScreen performs the actual navigation (still a push, so
the back gesture returns to the map). Failures/exceptions still navigate so
the user is never stuck on the map page.
2026-09-22 16:41:55 +08:00
atsunatsu d6bdf5ecb6 fix(mutual): keep scroll position when re-querying from the match page
queryMutualPasses() reset listScrollIndex to 0 on every query, so tapping
"query" on the match page jumped the list back to the top. Now the default
keeps the current position; the stale results stay on screen while the new
query runs (clearing them shrank the list below a screen and clamped the
position away) and are replaced when the new results arrive. New VM test
covers position + stale-results behaviour through a settled re-query.
2026-09-22 16:41:51 +08:00
atsunatsu 6962a4bfa1 fix(mutual): retry prefill scroll until it really lands
On a device the first frame of the match page only contains the station
cards and the time-range card, which can be shorter than the viewport —
scrollToItem(1) then has no scroll range and silently does nothing, and
by the time the async query results grow the list past one screen the
one-shot flag is already consumed. Retry every 100ms (up to 2s) until
firstVisibleItemIndex reaches the time-range card. Verified with a new
Robolectric test that grows the content after a delay. Bump 12.6.
2026-09-22 15:50:31 +08:00
atsunatsu 94a1a4f7b7 diagnostics(mutual): log prefill scroll path under tag Look4SatMutual
Temporary Log.d probes at prefill, list-layout gate, scroll effect entry,
scrollToItem outcome and consume, to locate why the map Match prefill
scroll does not land on the device (Robolectric covers it). Bump 12.5.
2026-09-22 15:13:47 +08:00
atsunatsu dbf2229774 chore: bump version to 4.4.7-ba7opf.12.4 (versionCode 490) 2026-09-22 14:49:32 +08:00
atsunatsu f2fc1b7b28 test(mutual): portrait-size Robolectric + NavDisplay entry scenario
Run the scroll tests at 411x891dp portrait (device-like layout) and add
a NavDisplay entryProvider scenario that composes MutualScreen the way
the app does, so the prefill scroll is exercised end-to-end.
2026-09-22 14:49:04 +08:00
atsunatsu d600836e19 fix(mutual): gate prefill scroll on real list layout (onGloballyPositioned)
On a device the Mutual entry is composed inside the NavDisplay transition;
LaunchedEffect could run before the LazyColumn is measurable and the
scrollToItem then never lands, leaving the page at the top. Only scroll
once onGloballyPositioned confirms the list has actually been laid out.
2026-09-22 14:49:00 +08:00
atsunatsu 94bffe80b0 chore: bump version to 4.4.7-ba7opf.12.3 (versionCode 489) 2026-09-22 13:13:11 +08:00
atsunatsu 4260fbb096 fix(nav): map Match prefill pushes Mutual onto the stack
The grid-QSO dialog Match button now adds Mutual on top of the Map entry
instead of clearing the stack, so the system back gesture pops back to
the map page (the page the user came from). Bottom-nav tab switches keep
their replace semantics.
2026-09-22 13:13:08 +08:00
atsunatsu 3d5b55faa3 test(mutual): Robolectric Compose UI tests for prefill scroll; bump 4.4.7-ba7opf.12.2
MutualMatchPrefillScrollTest verifies the map grid-QSO Match prefill:
time-range card lands at the top after scrollToItem, default open stays
at station inputs, and the query auto-starts with grid+24h pre-filled.
Adds Robolectric + compose ui-test-junit4 to the mutual test classpath.
2026-09-22 12:23:55 +08:00
atsunatsu 466f2f6088 fix(mutual): map Match prefill scrolls to time-range card via scrollToItem
Compose ui 1.11.x (BOM 2026.06.01) ignores the LazyListState
firstVisibleItemIndex constructor parameter - the list always starts at
item 0. Restore the scrollToItem approach (verified effective by
Robolectric Compose UI tests) and keep the auto-started query.
2026-09-22 12:23:50 +08:00
atsunatsu 14bd984327 chore: bump version to 4.4.7-ba7opf.12.1 (versionCode 487) 2026-09-22 11:05:50 +08:00
atsunatsu d5c100903b fix(mutual,passes): map Match prefill opens at time-range card; whats-new dialog scrollable
- Match prefill now positions the LazyColumn via its initial state
  (queryMutualPasses(initialScrollIndex = 1)) instead of a post-layout
  scrollToItem, so the page reliably opens with the time-range card at the
  top while the 24h query runs.
- The install-time what's new dialog text now scrolls inside a bounded box
  (max 360dp) instead of overflowing the fixed dialog frame.
2026-09-22 11:05:47 +08:00
atsunatsu 1c6388f3bc chore: bump version to 4.4.7-ba7opf.12 (versionCode 486) 2026-09-22 10:47:00 +08:00
atsunatsu 3e0874e6a8 feat(map): grid QSO dialog Match button prefills the match page
- Grid-QSO dialog gains a Match action (radio-tower icon + label) in its
  header; tapping it navigates to the match page pre-filled with the tapped
  grid as the opposite station and a 24h time range, auto-starts the query,
  and scrolls the page straight to the time-range card.
- Every grid is now tappable (previously only worked grids opened the
  dialog); unworked grids show an empty QSO list instead of a hint.
2026-09-22 10:46:56 +08:00
atsunatsu 6beaeb3e1a chore: bump version to 4.4.7-ba7opf.11.4 (versionCode 485) 2026-09-21 16:51:50 +08:00
atsunatsu 91f0cb1c56 fix(map): grid labels match first-call font size and display zoom 2026-09-21 16:51:13 +08:00
atsunatsu 3b72f8d68a fix(res): rename pass-history slider label 先前时间 to 历史过境时间 (values-zh) 2026-09-21 16:51:09 +08:00
atsunatsu 3bc2983d26 fix(passes): expire finished passes beyond history window, refresh keeps lookback 2026-09-21 16:51:06 +08:00
atsunatsu 0ec4c5dc6c chore: bump version to 4.4.7-ba7opf.11.3 (versionCode 484) 2026-09-21 11:35:33 +08:00
atsunatsu 8e8a3849df fix(map): first-call labels now appear exactly when grid lines do
The 11.2 gate only relaxed the pre-return zoom check, but the showLabels
gate below it still required LABEL_ZOOM_SUB (6.5), so first-call labels
stayed hidden in the 6.0..6.5 band where the 4-char grid lines render.
- Gate showLabels on GRID_ZOOM_SUB (6.0) when showFirstCallLabels is on.
- Relax MIN_LABEL_CELL_PX (48) to 0.6x for first-call mode so 1° cells
  (~45 px on the equator at zoom 6.0) are not filtered out.
Normal grid-code labels are unchanged (still LABEL_ZOOM_SUB).
2026-09-21 11:35:10 +08:00
atsunatsu cd65e6c077 fix(res): single-line what's new string, unformatted qso count, si typo
- pass_whatsnew_message rewritten as a single line with escaped \n bullets:
  real newlines in the value are invalid for aapt2 ('not a valid string').
- Escape the apostrophe in "map's"; mark grid_qso_calls_count
  formatted=false (two %d + '·' triggered multiple-substitutions).
- values-si: remove stray comma after map_altitude </string>.
All were latent resource errors surfaced by the forced recompile.
2026-09-21 11:11:52 +08:00
atsunatsu a3d370b1fa chore: bump version to 4.4.7-ba7opf.11.2 (versionCode 483) 2026-09-21 10:52:35 +08:00
atsunatsu bf492e3f8b feat(passes/radar): port pass history + compass calibration, cumulative what's new
- Pass history (Iu-yang1 fork): PassesSettings.hoursBefore look-back window;
  second 'Time before' slider in the pass filter; completed passes stay
  listed (progress clamped to [0,1]) in the history window.
- Compass calibration (Iu-yang1 fork): OtherSettings.compassOffsetDegrees,
  CompassAccuracy from the magnetic sensor, calibration dialog with
  figure-eight accuracy meter, corrected heading and manual offset field;
  RadarViewModel applies the offset.
- What's New dialog now shows the cumulative 4.4.6->4.4.7 changelog and
  follows the system language (values/ + values-zh/).
- pass_filter_history_hours localized in es/ru/si/tr/uk.
2026-09-21 10:42:45 +08:00
atsunatsu 3591960d55 feat(map): show first-call labels as soon as grid lines appear
Move the first-call label gate from LABEL_ZOOM_SUB (6.5) to GRID_ZOOM_SUB
(6.0): worked (green) cells are labelled with their first callsign as soon
as the 4-char grid lines render, not only at the name-label zoom.
2026-09-21 10:42:37 +08:00
atsunatsu 3f7a087938 chore: bump version to 4.4.7-ba7opf.11.1 (versionCode 482) 2026-09-21 09:44:46 +08:00
atsunatsu 4b82332133 feat(map): first-call callsign label toggle in VUCC grid mode
Add a 'first call' pill toggle below the grid-mode switch (map top-right,
visible only in grid mode + VUCC award). When on, worked (green) cells are
labelled with the first callsign worked in that grid (earliest confirmed
QSO by time) instead of the Maidenhead code; non-worked cells get no label
at all. Persisted via OtherSettings.stateOfMapFirstCall (full MVI chain).
The feature is VUCC-only: All and other award overlays never draw it.
2026-09-21 09:44:43 +08:00
atsunatsu dcd16a0196 chore: bump version to 4.4.7-ba7opf.11 (versionCode 481) 2026-09-19 04:27:55 +08:00
atsunatsu 9e88609efc feat(amsat): show consecutive same-status report count in status cells
Day-cell numbers now count the most recent consecutive reports sharing the
newest status (counting back from the newest report, stopping at the first
different status), instead of the total report count of the day. Empty gaps
do not break the streak; statuses compare by exact report text; counting
does not cross day boundaries.

- SatDay gains streakCount (computed in AmSatRepository.buildStatuses)
- DayCell renders day.streakCount instead of slot count sum
- Add real org.json to core:data unit tests (android.jar stub cannot parse)
- Add 2 streak tests: mixed-status slot breaks, all-same-status counts all
2026-09-19 04:27:19 +08:00
atsunatsu 322becdda8 feat(satellites): fuzzy name search ignoring separators
The satellite search only matched exact continuous substrings, so names with
dashes, spaces or brackets (AO-7, ISS (ZARYA), FO-29) could not be found by
queries without those separators. Now the query is normalized (lowercased,
non-alphanumeric separators stripped) and split into space-separated tokens,
and every token must appear in the normalized satellite name. This makes
'ao7' match 'AO-7 (AMSAT-OSCAR 7)' and 'iss zarya' match 'ISS (ZARYA)'.
Numeric queries still match catnum exactly. Covered by 8 new unit tests.
2026-09-18 14:49:41 +08:00
atsunatsu 7f93a10556 chore: bump version to 4.4.7-ba7opf.9 (versionCode 479) 2026-09-18 02:04:09 +08:00
atsunatsu 3cd5b49f98 feat(mutual): keep pass list scroll position across page switches
The mutual pass list LazyColumn reset to the top on every return to the page.
Scroll position is now stored in the Activity-scoped MutualViewModel and
restored on re-entry; a new query (queryGeneration bump) rebuilds the list at
the top so stale positions never leak into fresh results.
2026-09-18 02:04:04 +08:00
atsunatsu 4dbe896124 feat(map): keep grid-mode viewport across page switches
The osmdroid map was recreated on every return to the Map page, resetting
center/zoom to the default and forcing grid mode back onto the station grid.
Now the viewport (center + zoom) is saved in the Activity-scoped
MapFilterViewModel only in grid mode and restored on re-entry, so the user
keeps exactly where they left off. Satellite mode keeps its original
follow-the-satellite behavior; cold-start entries and in-page grid toggles
still center on the station grid as before.
2026-09-18 02:03:57 +08:00
atsunatsu b47bd78ecd feat(map): keep award filter selection across page switches
The award chip (VUCC/DXCC/WAJA/...) now lives in an Activity-scoped
MapFilterViewModel instead of a per-composition remember, so switching
to another page and back keeps the last selection; it resets to VUCC
only once per process (cold start). Removes the effect that forced
VUCC on every grid-mode entry.
2026-09-17 12:57:36 +08:00
atsunatsu c89e79e28f chore: bump version to 4.4.7-ba7opf.8 (versionCode 478) 2026-09-17 11:53:44 +08:00
atsunatsu b40a79e3c6 docs: credit BG5JSU with CW decoding feature 2026-09-17 11:38:19 +08:00
atsunatsu 6c23696e22 docs: add BG5JSU to acknowledgments (after BG7NTA) 2026-09-17 11:25:44 +08:00
atsunatsu f997d95cd4 chore: bump version to 4.4.7-ba7opf.7 (versionCode 477) 2026-09-17 10:41:11 +08:00
atsunatsu e0a1d279ce i18n(zh): localize update time to yyyy年M月d日 HH:mm:ss 2026-09-17 10:20:26 +08:00
atsunatsu 059c816075 feat(lotw): show last sync time on settings, precise incremental cursor
The stored sync cursor was 'yyyyMMdd', which LoTW's qso_qslsince silently
ignores (measured live: it falls back to the system-supplied default).
Write a full UTC 'yyyy-MM-dd HH:mm:ss' cursor instead — the documented
format LoTW honors — so incremental pulls are exact (second precision)
and the settings page can show the last successful sync time on the LoTW
card, formatted like the ephemeris update time (prefs_updated_title).
Legacy yyyyMMdd cursors are normalized for the API, the gate and the
display (lotwCursorApi/lotwCursorDate/lotwCursorEpochMs).
2026-09-17 10:13:52 +08:00
atsunatsu 60dfa5d600 feat(lotw): auto-sync grids on app start with separate toggle
Mirrors the ephemeris auto-update check: on every app start, if LoTW
credentials are configured, the new 'auto-sync LoTW grids' toggle is on,
at least one manual sync was done before, and today has not synced yet
(ARRL rate limit, ~once a day), pull confirmations since the last sync
and merge them. Same callsign -> incremental merge; callsign change ->
full replace. Manual dialog and automatic path share the orchestration
(mode resolution, gate, merge/persist) moved to core:domain
LoTWGridSync.kt; LoTWSyncMode moved to core:domain. 8 unit tests.
2026-09-16 20:18:22 +08:00
56 changed files with 2955 additions and 217 deletions

No files matched your search

@@ -24,6 +24,13 @@ import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
@@ -41,6 +48,8 @@ class MainApplication : Application(), IContainerProvider {
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
container.appScope.launch { checkLoTWAutoSync() }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -79,4 +88,37 @@ class MainApplication : Application(), IContainerProvider {
}
}
}
/**
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
* app start, but only pulls when due — LoTW credentials configured, the
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
* already synced today (ARRL limits report pulls, ~once a day per account).
* Incremental when the callsign is unchanged; a callsign change falls back
* to a full replace so no stale grids from another account linger.
* Failures are silent and simply retried on the next app start — the manual
* sync button still surfaces the explicit cause to the user.
*/
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
val settingsRepo = container.settingsRepo
val lotwSettings = settingsRepo.lotwSettings.value
if (!shouldAutoSyncLoTW(
isConfigured = lotwSettings.isConfigured,
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
today = lotwSyncToday(timeNow)
)
) return
val callsign = lotwSettings.callsign.trim().uppercase()
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
if (result is LoTWResult.Success) {
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
println("Periodic LoTW grid sync finished: $count worked grids")
} else {
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
}
}
}
@@ -81,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
@@ -169,6 +170,34 @@ fun MainScreen(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
// Activity-scoped so the map's award filter survives page switches;
// resets to VUCC only on cold start (fresh process).
val mapFilterViewModel: MapFilterViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MapFilterViewModel.factory()
)
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
// grid and run the query BEFORE navigating, so the page's first frame
// already contains the results (time-range card at the top, pass curves
// right below — no scroll flicker). Navigation happens once the query
// finishes (see the collector below).
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
LaunchedEffect(mutualViewModel) {
mutualViewModel.uiState.collect { state ->
if (state.pendingNavigation && !state.isCalculating) {
mutualViewModel.consumePendingNavigation()
// Push Mutual on top of the Map entry instead of replacing the
// stack (bottom-nav style): the system back gesture then pops
// back to the map page, which is the page the user came from.
if (backStack.lastOrNull() !is Screen.Mutual) {
backStack.add(Screen.Mutual)
}
}
}
}
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
@@ -237,7 +266,18 @@ fun MainScreen(
)
}
entry<Screen.Map> {
MapDestination()
MapDestination(
mapFilterViewModel = mapFilterViewModel,
matchCalculating = matchCalculating,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill
// the match page for that grid and start the
// query. Navigation to the match page is
// handled by the uiState collector above,
// once the query finishes.
mutualViewModel.prefillMatchFromGrid(grid)
}
)
}
entry<Screen.Mutual> {
MutualScreen(
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
ksp(libs.androidx.room.compiler)
implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp)
// android.jar's org.json is a stub ("not mocked") in unit tests;
// provide a real implementation so JSON parsing is testable.
testImplementation(libs.test.json)
}
}
}
@@ -45,7 +45,7 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -221,7 +221,22 @@ class AmSatRepository(
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
val dayStart = nowSec - (d + 1) * 86400L
val dayEnd = nowSec - d * 86400L
val dayReports = byName[name].orEmpty()
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
.sortedByDescending { it.reportedTimeUtcSec }
val streakCount = if (dayReports.isEmpty()) 0 else {
val newestStatus = dayReports.first().report
dayReports.takeWhile { it.report == newestStatus }.size
}
SatDay(
dateLabel = labels[d],
slots = slots.subList(d * 12, (d + 1) * 12),
streakCount = streakCount
)
}
SatStatus(name = name, days = days)
}
@@ -115,12 +115,48 @@ class DatabaseRepo(
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
updateAmSatLiveLists()
setUpdateSuccessful(System.currentTimeMillis())
}
/**
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
* on the air). Best effort: any failure keeps the previous lists and never
* blocks the rest of the data update, so the mutual-match filter simply
* falls back to the last successful snapshot.
*/
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
runCatching {
val entries = localSource.getEntriesList() // catnum -> name
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
async { type to remoteSource.getNetworkStream(url) }
}
val results = jobs.awaitAll()
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
fun resolve(names: List<String>): Set<Int> = names.mapNotNull { name ->
val keys = dataParser.normalizeAmSatName(name)
nameToCatnum.entries.firstOrNull { (localName, _) ->
keys.any { key -> localName.contains(key) }
}?.value
}.toSet()
val fmCatnums = resolve(fmNames)
val linearCatnums = resolve(linearNames)
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}")
}.onFailure {
// Keep the previous lists; the mutual filter stays on the last good snapshot.
println("AMSAT live lists update failed: $it")
}
}
override suspend fun clearAllData() = withContext(dispatcher) {
localSource.deleteEntries()
localSource.deleteRadios()
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
setUpdateSuccessful(0L)
}
@@ -65,6 +65,8 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
@@ -76,9 +78,11 @@ class SatelliteRepo(
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
val now = System.currentTimeMillis()
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = now - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
@@ -43,11 +43,13 @@ class SelectionRepo(
// Resolve type IDs once when types change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
// are resolved from live transponder data instead of SharedPreferences.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = resolveTypeIds(types)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -61,8 +63,28 @@ class SelectionRepo(
override fun getCurrentTypes() = currentTypes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
/**
* Resolves a list of type names to satellite catnums. The three virtual
* transponder/activity types are resolved from live data; all other types
* come from the per-type ID lists persisted by [ISettingsRepo].
*/
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
when (type) {
"AMSAT Live FM" -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
"AMSAT Live Linear" -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
"Live SSTV" -> idsSet.addAll(localSource.getIdsWithModes(listOf("SSTV")))
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
}
}
return idsSet.toList()
}
override fun getTypesList() = buildList {
// 三个转发器/活动虚拟类型排在最前.
addAll(listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV"))
addAll(Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { removeAt(0) })
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
@@ -111,12 +133,29 @@ class SelectionRepo(
/**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items
val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase()
return items.filter { it.name.lowercase().contains(lowerQuery) }
val tokens = query.split(' ')
.map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
}
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
}
@@ -27,6 +27,7 @@ import android.view.Display
import android.view.Surface
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -41,7 +42,12 @@ class SensorsRepo(
) : ISensorsRepo, SensorEventListener {
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
private val _compassAccuracy = MutableStateFlow(
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
else CompassAccuracy.UNRELIABLE
)
private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
@@ -50,6 +56,7 @@ class SensorsRepo(
private var hasInitialReading = false
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
return GeomagneticField(
@@ -62,15 +69,41 @@ class SensorsRepo(
override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
if (rotationSensor == null || magneticSensor == null) {
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
return
}
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
if (!rotationRegistered || !magneticRegistered) {
sensorManager.unregisterListener(this)
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
}
}
override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
updateAccuracy(accuracy)
}
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
when (event.sensor.type) {
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
}
}
private fun updateAccuracy(accuracy: Int) {
_compassAccuracy.value = when (accuracy) {
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
else -> CompassAccuracy.UNRELIABLE
}
}
private fun getDisplayRotation(): Int {
@@ -58,6 +58,7 @@ class SettingsRepo(
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterHoursBefore = "filterHoursBefore"
private val keyFilterMinElevation = "filterMinElevation"
private val keyFilterAosStartMinute = "filterAosStartMinute"
private val keyFilterAosEndMinute = "filterAosEndMinute"
@@ -77,12 +78,15 @@ class SettingsRepo(
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors"
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -103,6 +107,8 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAmSatFmCatnums = "amSatFmCatnums"
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -265,6 +271,59 @@ class SettingsRepo(
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// Marked stations in unworked gridsquares, persisted as a single JSON
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
// ordered list (first = pinned/top). v14.1 stored a single object per
// grid; reads transparently migrate that old shape to a one-element list.
private val keyMarkedGridStations = "markedGridStations"
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
for (grid in root.keys()) {
val value = root.opt(grid)
val list = when (value) {
// v14.2+ shape: JSON array of station objects.
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
val o = value.optJSONObject(i) ?: return@mapNotNull null
val call = o.optString("c").ifBlank { return@mapNotNull null }
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
}
// v14.1 legacy shape: a single station object per grid.
is org.json.JSONObject -> {
val call = value.optString("c").ifBlank { null }
if (call == null) emptyList()
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
}
else -> emptyList()
}
if (list.isNotEmpty()) result[grid] = list
}
result
} catch (_: Exception) {
emptyMap()
}
}
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
val root = org.json.JSONObject()
for ((grid, list) in stations) {
val array = org.json.JSONArray()
for (station in list) {
array.put(
org.json.JSONObject()
.put("c", station.call)
.put("t", station.epochMs)
)
}
root.put(grid, array)
}
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
@@ -316,6 +375,7 @@ class SettingsRepo(
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
putInt(keyFilterHoursAhead, settings.hoursAhead)
putInt(keyFilterHoursBefore, settings.hoursBefore)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
@@ -327,6 +387,7 @@ class SettingsRepo(
private fun getPassesSettings(): PassesSettings {
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
@@ -340,7 +401,8 @@ class SettingsRepo(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
selectedModes,
hoursBefore
)
}
//endregion
@@ -538,12 +600,15 @@ class SettingsRepo(
val new = transform(current)
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -556,12 +621,15 @@ class SettingsRepo(
private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -679,6 +747,25 @@ class SettingsRepo(
}
//endregion
//region # AMSAT live-transponder lists
override fun getAmSatFmCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun getAmSatLinearCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
preferences.edit {
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
}
}
//endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class AmSatRepositoryTest {
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
report("r1", "AO-7", "Heard", nowSec - 3600),
report("r2", "AO-7", "Heard", nowSec - 5400),
report("r3", "AO-7", "Not Heard", nowSec - 7000),
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
report("r4", "AO-7", "Heard", nowSec - 63000),
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
report("r5", "AO-7", "Heard", nowSec - 90000),
report("r6", "AO-7", "Not Heard", nowSec - 100000)
// day 2 无报告 → 0
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
val status = page?.statuses?.single()
assertEquals("AO-7", status?.name)
assertEquals(3, status?.days?.size)
assertEquals(2, status?.days?.get(0)?.streakCount)
assertEquals(1, status?.days?.get(1)?.streakCount)
assertEquals(0, status?.days?.get(2)?.streakCount)
}
@Test
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
report("r1", "AO-7", "Heard", nowSec - 1800),
report("r2", "AO-7", "Heard", nowSec - 3600),
report("r3", "AO-7", "Heard", nowSec - 5400),
report("r4", "AO-7", "Heard", nowSec - 30000)
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
private class FakeAmSatRemoteSource(
private val responseDelayMillis: Long = 0L,
private val reportsJson: String = """{"data":[]}"""
) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return """{"data":[]}"""
return reportsJson
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private fun amSatReportsJson(vararg reports: String): String =
"""{"data":[${reports.joinToString(",")}]}"""
private fun report(id: String, name: String, status: String, epochSec: Long): String =
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
private fun isoUtc(epochSec: Long): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(epochSec * 1000))
@@ -269,6 +269,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
@@ -312,6 +316,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -0,0 +1,269 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoSearchTest {
private val sampleItems = listOf(
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
SatItem(catnum = 99999, name = "FO-29"),
)
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("ao7")
val results = repo.getEntriesFlow().first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
// key guarantee is that AO-7 — which was unreachable before because of
// the dashes/brackets — is now found.
assertTrue(results.map { it.catnum }.contains(7530))
assertEquals(7530, results.first().catnum)
}
@Test
fun `query without separators matches name with only dashes`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("fo29")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(99999), results.map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zarya iss")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `exact name query still matches`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(7530), results.map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("funcube")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(39444), results.map { it.catnum })
}
@Test
fun `numeric query matches catnum exactly`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("25544")
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `no query returns all items`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("")
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.size, results.size)
}
@Test
fun `unmatched query returns nothing`() = runTest {
val repo = createRepo(sampleItems)
repo.setQuery("zzzznomatch")
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
@Test
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544, 39444)
)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544, 39444), results.map { it.catnum })
}
@Test
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(43803), results.map { it.catnum })
}
@Test
fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544),
amSatLinear = setOf(7530),
sstvIds = listOf(43803)
)
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
}
@Test
fun `clearing types restores full list`() = runTest {
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
repo.setTypes(emptyList())
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
}
@Test
fun `types list has virtual transponder types first and keeps All`() {
val repo = createRepo(sampleItems)
val types = repo.getTypesList()
assertTrue(types.size >= 4)
assertEquals("AMSAT Live FM", types[0])
assertEquals("AMSAT Live Linear", types[1])
assertEquals("Live SSTV", types[2])
// "All" must be kept in the list (first original type shown after virtual ones).
assertTrue("All" in types)
}
private fun createRepo(
items: List<SatItem>,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet(),
sstvIds: List<Int> = emptyList()
): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear)
)
}
}
private class FakeLocalSourceForSearch(
private val items: List<SatItem>,
private val sstvIds: List<Int> = emptyList()
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
override suspend fun deleteManagedRadios() = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepoForSearch(
private val amSatFm: Set<Int> = emptySet(),
private val amSatLinear: Set<Int> = emptySet()
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(
false, false, false, false, false, false, false,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun updateDatabaseState(state: DatabaseState) = Unit
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
override fun setRoamedGrids(grids: Set<String>) = Unit
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
}
@@ -0,0 +1,31 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
/**
* A station the user marked in a gridsquare they have NOT yet worked — a
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
* per grid).
*
* @param call the callsign the user wants to work (uppercase)
* @param epochMs the moment the mark was created, UTC milliseconds
*/
data class MarkedStation(
val call: String,
val epochMs: Long
)
@@ -20,7 +20,8 @@ data class SatSlot(
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
)
/** One satellite, 3 days of state */
@@ -30,7 +30,9 @@ data class PassesSettings(
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val selectedModes: List<String>,
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
val hoursBefore: Int = 0
)
data class RCSettings(
@@ -60,11 +62,20 @@ data class OtherSettings(
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
/** Grid mode: label worked (green) cells with the FIRST callsign worked
* in that grid instead of the Maidenhead code; non-worked cells get no
* label at all. */
val stateOfMapFirstCall: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0
val highElevation: Double = 45.0,
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
val compassOffsetDegrees: Float = 0f
)
data class DataSourcesSettings(
@@ -65,4 +65,9 @@ interface ISatelliteRepo {
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** Satellite catnums whose radios carry any of the given downlink modes.
* Empty modes -> empty result; used to filter the mutual-match satellite
* set to transponder satellites (FM voice / linear). */
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
}
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
enum class CompassAccuracy {
UNAVAILABLE,
UNRELIABLE,
LOW,
MEDIUM,
HIGH
}
interface ISensorsRepo {
val sensorData: StateFlow<Pair<Float, Float>>
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
val compassAccuracy: StateFlow<CompassAccuracy>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor()
fun disableSensor()
@@ -79,6 +79,14 @@ interface ISettingsRepo {
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # AMSAT live-transponder lists
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
fun getAmSatFmCatnums(): Set<Int>
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -105,6 +113,11 @@ interface ISettingsRepo {
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
/** Stations the user marked as "want to contact" in unworked gridsquares
* (grid -> ordered list of marks; the first is the pinned/top one). The
* whole grid clears automatically once it becomes worked. */
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
//endregion
//region # LoTW confirmed-grids settings
@@ -0,0 +1,169 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
* and the automatic sync on app start (MainApplication), so the two paths can
* never drift apart:
*
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
* deduped by call + QSO time).
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
*/
/**
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
* gate granularity.
*/
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyyMMdd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/**
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
* ignored by LoTW, which falls back to its own system-supplied default),
* and it keeps enough precision to display the last-sync time like the
* ephemeris update time.
*/
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
fun lotwCursorDate(cursor: String): String = when {
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
else -> cursor.take(8)
}
/** qso_qslsince value for the API: passes the full timestamp through and
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
fun lotwCursorApi(cursor: String): String = when {
cursor.isBlank() -> ""
cursor.length >= 10 && cursor[4] == '-' -> cursor
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
}
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
fun lotwCursorEpochMs(cursor: String): Long = try {
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
SimpleDateFormat(pattern, Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.parse(cursor)?.time ?: 0L
} catch (_: Exception) {
0L
}
/**
* Resolves the effective sync mode for a given request:
* - an explicit [LoTWSyncMode.Full] request is always honored;
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
* matches the last sync — a first sync, an unknown/empty record or a
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
* another account linger.
* With [requested] == null (automatic sync) it picks incremental whenever
* possible and full otherwise.
*/
fun resolveLoTWSyncMode(
lastSyncCallsign: String,
callsign: String,
requested: LoTWSyncMode?
): LoTWSyncMode {
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
}
/**
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
* least one prior manual sync (the first sync stays manual/full), and not
* already synced today (ARRL limits report pulls, ~once a day per account).
*/
fun shouldAutoSyncLoTW(
isConfigured: Boolean,
autoLotwSyncEnabled: Boolean,
lastSyncDate: String,
today: String
): Boolean {
val lastDate = lotwCursorDate(lastSyncDate)
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
}
/**
* Applies a successful LoTW report to the stored grid data and advances the
* sync cursor so the next incremental pull asks LoTW for only the
* confirmations since today. Persistence runs on the IO dispatcher — building
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
* thread, and for large accounts (multi-MB JSON) that froze the main thread
* after a manual sync ('bar done but app stuck').
*
* @return the resulting number of worked grids.
*/
suspend fun applyLoTWGridResult(
settingsRepo: ISettingsRepo,
result: LoTWResult.Success,
mode: LoTWSyncMode,
callsign: String,
now: Long = System.currentTimeMillis()
): Int {
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
settingsRepo.setLastLotwSyncCallsign(callsign)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
return mergedGrids.size
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
fun mergeGridQsos(
existing: Map<String, List<GridQso>>,
fresh: Map<String, List<GridQso>>
): Map<String, List<GridQso>> {
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
fresh.forEach { (grid, list) ->
val target = merged.getOrPut(grid) { mutableListOf() }
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
list.forEach { qso ->
if (known.add(qso.call to qso.epochMs)) target.add(qso)
}
}
return merged
}
@@ -46,3 +46,6 @@ sealed class LoTWResult {
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
@@ -55,4 +55,11 @@ object Sources {
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json"
)
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
* transponders currently on the air. Parsed into the mutual-match filter
* (satellites that actually carry a working FM-voice / linear repeater). */
val amSatLiveUrls = mapOf(
"FM" to "https://www.amsat.org/live-fm-satellites/",
"Linear" to "https://www.amsat.org/live-linear-satellites/"
)
}
@@ -58,6 +58,82 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList())
}
/**
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
* The page is an HTML table whose first column is the satellite name,
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
*/
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
runCatching {
val html = stream.bufferedReader().readText()
// Grab table rows, take the first cell of each row, keep rows that
// look like satellite names (start with letters/digits, not a header).
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
.findAll(html)
.mapNotNull { match ->
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
.findAll(match.groupValues[1])
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
.toList()
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
}
.toList()
rows.flatMap { expandNameRange(it) }.distinct()
}.getOrDefault(emptyList())
}
private fun stripHtml(raw: String): String =
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
private fun expandNameRange(name: String): List<String> {
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
if (m == null) return listOf(name)
val prefix1 = m.groupValues[1].trim()
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
val prefix2 = m.groupValues[3].trim()
if (startNum > endNum) return listOf(name)
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
return (startNum..endNum).map { "$prefix1$it$suffix" }
}
/**
* Normalize a satellite name from the AMSAT live pages into lookup keys
* used to match against the local entries table:
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
* - alias keys: every bracketed alias, uppercased, digits preserved
* The local entry names are normalized the same way in [matchesAmSatName].
*/
fun normalizeAmSatName(name: String): List<String> {
val keys = mutableListOf<String>()
// Primary: everything before the first '(' (or '['), then the first token.
val primary = name.substringBefore('(').substringBefore('[').trim()
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
keys += it.uppercase()
}
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
return keys.distinct()
}
/** True if a local entry name matches any of the AMSAT normalized keys. */
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localUpper = localName.uppercase()
return amSatKeys.any { key -> localUpper.contains(key) }
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
@@ -239,4 +239,51 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
val html = """
<html><body><table>
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
</table></body></html>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
assert(names.contains("SO-50(SaudiSat-1C)"))
assert(names.contains("TEVEL2-1"))
assert(names.contains("TEVEL2-9"))
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
}
@Test
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
val html = """
<table>
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
</table>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
}
@Test
fun `AMSAT name normalization produces primary and alias keys`() {
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
}
@Test
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
val html = "<html><body>No data</body></html>".byteInputStream()
assert(dataParser.parseAmSatLivePage(html).isEmpty())
}
}
@@ -0,0 +1,297 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
import com.rtbishop.look4sat.core.domain.model.MarkedStation
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWGridSyncTest {
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
private val now = 1_789_516_800_000L
// region resolveLoTWSyncMode
@Test
fun resolveModeHonorsExplicitFull() {
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
}
@Test
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
assertEquals(
LoTWSyncMode.Incremental,
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
)
assertEquals(
LoTWSyncMode.Full,
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
)
}
@Test
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
}
// endregion
// region shouldAutoSyncLoTW
@Test
fun autoSyncGateRequiresEverything() {
val today = "20260916"
// Not configured.
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
// LoTW auto-sync toggle off.
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
// Never synced manually — first sync stays manual/full.
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
// All gates open — yesterday in either format.
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
}
// endregion
// region cursor helpers
@Test
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
assertEquals("2026-09-16", lotwCursorApi("20260916"))
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
assertEquals("", lotwCursorApi(""))
}
@Test
fun cursorDateToleratesBothFormats() {
assertEquals("20260916", lotwCursorDate("20260916"))
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
assertEquals("", lotwCursorDate(""))
}
@Test
fun cursorEpochMsParsesBothFormatsUtc() {
// 2026-09-16T00:00:00Z in both spellings.
assertEquals(now, lotwCursorEpochMs("20260916"))
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
assertEquals(0L, lotwCursorEpochMs(""))
assertEquals(0L, lotwCursorEpochMs("garbage"))
}
// endregion
// region mergeGridQsos
@Test
fun mergeGridQsosDedupsByCallAndTime() {
val existing = mapOf(
"OL62" to listOf(qso("BA7ABC", 1000L)),
"OL63" to listOf(qso("BA7DEF", 2000L))
)
val fresh = mapOf(
// Same grid, same QSO re-delivered by an overlapping since-date.
"OL62" to listOf(qso("BA7ABC", 1000L)),
// Same grid, brand-new QSO.
"OL62" to listOf(qso("BA7GHI", 3000L)),
// New grid entirely.
"PM95" to listOf(qso("BA7JKL", 4000L))
)
val merged = mergeGridQsos(existing, fresh)
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
assertEquals(1, merged["OL63"]?.size)
assertEquals(1, merged["PM95"]?.size)
}
@Test
fun mergeGridQsosHandlesEmptySides() {
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
// An empty incremental pull must leave the stored side untouched.
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
}
// endregion
// region applyLoTWGridResult
@Test
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62"))
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
setRoamedGrids(setOf("OL62"))
setLastLotwSyncDate("20260915")
setLastLotwSyncCallsign("BA7OPF")
}
val result = LoTWResult.Success(
grids = setOf("OL62", "PM95"),
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
assertEquals(2, count)
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
@Test
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
val repo = FakeSettingsRepo().apply {
setWorkedGrids(setOf("OL62", "STALE"))
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
setRoamedGrids(setOf("STALE"))
setLastLotwSyncDate("20260910")
setLastLotwSyncCallsign("BG7OLD")
}
val result = LoTWResult.Success(
grids = setOf("PM95"),
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
roamedGrids = setOf("PM95")
)
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
assertEquals(1, count)
assertEquals(setOf("PM95"), repo.getWorkedGrids())
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
assertEquals(setOf("PM95"), repo.getRoamedGrids())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
// endregion
private fun qso(call: String, epochMs: Long): GridQso =
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
/**
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
*/
private class FakeSettingsRepo : ISettingsRepo {
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
)
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(
stateOfAutoUpdate = true,
stateOfSensors = false,
stateOfSweep = false,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
)
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var lastSyncDate: String = ""
private var lastSyncCallsign: String = ""
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
override fun getLastLotwSyncDate(): String = lastSyncDate
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
}
}
@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 冖 (秃宝盖): 顶横 + 两侧短竖, 笔画型 -->
<path
android:pathData="M5,4 L19,4 M5,4 L5,8.5 M19,4 L19,8.5"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- 笔画型向上箭头 ↑: 竖杆 + 两斜线箭头头 -->
<path
android:pathData="M12,9 L12,20 M12,9 L7.5,14 M12,9 L16.5,14"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
</vector>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Filtrar pases</string>
<string name="pass_filter_elev">Elevación mínima</string>
<string name="pass_filter_hours">Tiempo por delante</string>
<string name="pass_filter_history_hours">Tiempo anterior</string>
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
@@ -31,6 +31,7 @@
<string name="pass_filter_title">Фильтровать пролеты</string>
<string name="pass_filter_elev">Минимальная элевация</string>
<string name="pass_filter_hours">Период расчёта</string>
<string name="pass_filter_history_hours">Прошедший период</string>
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
@@ -146,6 +147,7 @@
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
<string name="pass_filter_hours">ඉදිරි කාලය</string>
<string name="pass_filter_history_hours">පෙර කාලය</string>
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
@@ -70,7 +71,7 @@
<string name="map_next">ඊළග</string>
<string name="map_azimuth">දිගංශය: %.1f°</string>
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
<string name="map_altitude">උන්නතාශය: %.0f km</string>
<string name="map_distance">පරතරය: %.0f km</string>
<string name="map_latitude">අක්‍ෂාංශ: %.1f°</string>
<string name="map_longitude">දේශාංශ: %.1f°</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Geçişleri filtrele</string>
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
<string name="pass_filter_hours">Gösterilecek zaman</string>
<string name="pass_filter_history_hours">Geçmiş süre</string>
<string name="pass_filter_aos_time">AOS aralığı</string>
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
@@ -189,6 +190,7 @@
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
@@ -207,7 +209,7 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA (CW decoding feature)</string>
\n• BG7NTA BG5JSU (CW decoding feature)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -32,6 +32,7 @@
<string name="pass_filter_title">Фільтр прольотів</string>
<string name="pass_filter_elev">Мінімальне піднесень</string>
<string name="pass_filter_hours">Час вперед</string>
<string name="pass_filter_history_hours">Час назад</string>
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
@@ -147,6 +148,7 @@
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
@@ -28,6 +28,7 @@
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_history_hours">历史过境时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
@@ -40,7 +41,9 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
@@ -109,6 +112,7 @@
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -121,11 +125,19 @@
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="grid_qso_match">匹配</string>
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
<string name="grid_mark_station">标记你想通联的电台</string>
<string name="grid_mark_callsign">呼号</string>
<string name="grid_mark_confirm">标记</string>
<string name="grid_mark_label">标记</string>
<string name="grid_mark_delete">删除标记</string>
<string name="grid_mark_pin">置顶</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
@@ -133,7 +145,7 @@
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
@@ -237,8 +249,22 @@
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_compass_calibration_button">校准指南针</string>
<string name="prefs_compass_calibration_title">指南针校准</string>
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
<string name="prefs_compass_accuracy">精度:%s</string>
<string name="prefs_compass_accuracy_high">高——校准完成</string>
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
<string name="prefs_compass_offset">手动航向偏置(°)</string>
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
<string name="prefs_compass_offset_reset">偏置归零</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
@@ -256,7 +282,7 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA(CW解码功能)</string>
\n• BG7NTA BG5JSU(CW解码功能)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
@@ -282,6 +308,9 @@
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_filter_transponder">转发器</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">线性</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
@@ -33,6 +33,7 @@
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Time ahead</string>
<string name="pass_filter_history_hours">Time before</string>
<string name="pass_filter_aos_time">AOS window</string>
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
@@ -51,10 +52,7 @@
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
</string>
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
@@ -133,6 +131,7 @@
<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_first_call_mode">First call</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -146,11 +145,19 @@
<string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="grid_qso_match">Match</string>
<!-- Mark-a-station reminder in unworked grids -->
<string name="grid_mark_station">Mark a station you want to contact</string>
<string name="grid_mark_callsign">Callsign</string>
<string name="grid_mark_confirm">Mark</string>
<string name="grid_mark_label">Marked</string>
<string name="grid_mark_delete">Remove mark</string>
<string name="grid_mark_pin">Pin to top</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
@@ -271,8 +278,22 @@
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_compass_calibration_button">Calibrate compass</string>
<string name="prefs_compass_calibration_title">Compass calibration</string>
<string name="prefs_compass_calibration_instruction">Move the phone slowly in a figure-eight until accuracy improves. Keep it away from magnets and metal.</string>
<string name="prefs_compass_accuracy">Accuracy: %s</string>
<string name="prefs_compass_accuracy_high">High — calibration complete</string>
<string name="prefs_compass_accuracy_medium">Medium — keep moving</string>
<string name="prefs_compass_accuracy_low">Low — keep moving</string>
<string name="prefs_compass_accuracy_unreliable">Unreliable — start the figure-eight motion</string>
<string name="prefs_compass_accuracy_unavailable">Magnetic sensor unavailable</string>
<string name="prefs_compass_heading">Corrected heading: %.1f°</string>
<string name="prefs_compass_offset">Manual heading offset (°)</string>
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
<string name="prefs_compass_offset_reset">Reset offset</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
@@ -290,7 +311,7 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA (CW decoding feature)
\n* BG7NTA BG5JSU (CW decoding feature)
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -317,6 +338,9 @@
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
<string name="mutual_min_elevation">Min elevation: %d°</string>
<string name="mutual_time_range">Time range</string>
<string name="mutual_filter_transponder">Transponders</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">Linear</string>
<string name="mutual_calculating">Calculating…</string>
<string name="mutual_query">Query Match</string>
<string name="mutual_elevation_you">You: %d°</string>
@@ -46,16 +46,29 @@ class MaidenheadGridOverlay : Overlay() {
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
// 网格标签与首通呼号标签同字号 (用户要求 2026-09-21: 字体一样大)
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 26f
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
// 首通呼号标签: 与网格标签同字号.
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(230, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
// 标记(想通联)网格填充: 红色, 透明度与绿色 workedPaint 一致(alpha 90).
private val markedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 255, 59, 48)
}
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 14f
@@ -79,6 +92,21 @@ class MaidenheadGridOverlay : Overlay() {
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** Marked gridsquares (4-char, uppercase) — unworked grids the user has
* marked stations they want to contact in; drawn red, same alpha as the
* green worked fill. Value is the ordered mark list (first = pinned);
* label mode shows the FIRST marked callsign. */
var markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
/** Grid-mode first-call labels: label worked (green) cells with the first
* callsign worked in that grid instead of the Maidenhead code; non-worked
* cells get no label at all. Only meaningful at sub-square zoom. */
var showFirstCallLabels: Boolean = false
/** Worked grid -> first callsign worked in it (earliest QSO by time).
* Only read when [showFirstCallLabels] is on. */
var firstCallsByGrid: Map<String, String> = emptyMap()
/**
* Gridsquares the station operated from (4-char, uppercase) — drawn with
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
@@ -172,6 +200,9 @@ class MaidenheadGridOverlay : Overlay() {
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
if (cellLabel(lat, lon, zoom) in markedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
} else {
@@ -198,6 +229,22 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
for (grid in markedGrids.keys) {
val cell = gridCellBounds(grid) ?: continue
for (turn in -colRepeats..colRepeats) {
val dLon = turn * 360.0
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
val yTop = projectionToY(projection, cell.latTop) ?: continue
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
}
@@ -346,9 +393,15 @@ class MaidenheadGridOverlay : Overlay() {
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom).
// Field (2-char) labels show at every zoom, subject only to the pixel-
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
// (one level above the grid lines, so zoom 6 shows lines but no names).
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
// size check below; sub-square (4-char) labels appear together with the
// grid LINES at GRID_ZOOM_SUB (user req 2026-09-21: 与首通呼号同一显示缩放).
// First-call labels replace the 4-char grid codes and only exist at
// sub-square zoom; at field zoom NO labels are drawn at all (the user
// requirement is that non-worked cells carry no grid characters).
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
// 网格标签与首通呼号同一显示缩放 (用户要求 2026-09-21): 子方块(4字符)
// 标签与首通呼号都在网格线出现的 GRID_ZOOM_SUB 同时显示, 不再晚一档.
val showLabels = zoom >= GRID_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
@@ -356,10 +409,14 @@ class MaidenheadGridOverlay : Overlay() {
val y2 = projectionToY(projection, 1.0)
if (y1 == null || y2 == null) return
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
// 两种模式同一像素门限: GRID_ZOOM_SUB 处 1° 格约 45px, 低于 48px
// 常规阈值, 统一用放宽的 0.6x 门限, 保证网格标签与首通呼号同时出现.
val minCellPx = MIN_LABEL_CELL_PX * 0.6f
if (pixelsPerDegree * cellLat < minCellPx) return
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
activePaint.textAlign = Paint.Align.CENTER
val fontMetrics = activePaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) {
val lat = row * cellLat
@@ -386,7 +443,20 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
if (showFirstCallLabels) {
// 首通呼号模式: 绿格(worked)标注该格第一个通联的呼号, 红格(marked)
// 标注用户标记想通联的呼号, 均淡黄文字; 其它格空着不写网格字符.
when {
label in workedGrids -> firstCallsByGrid[label]?.let { call ->
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station ->
canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
}
} else {
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
}
@@ -504,9 +574,8 @@ class MaidenheadGridOverlay : Overlay() {
// the sub-square grid is too dense to read; 6 roughly doubles the
// on-screen size of each square.
const val GRID_ZOOM_SUB = 6.0
// Zoom at which the 4-char sub-square names appear (one level above the
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
const val LABEL_ZOOM_SUB = 6.5
// 网格标签与首通呼号标签统一字号 (用户要求 2026-09-21: 字体一样大).
const val LABEL_TEXT_SIZE = 20f
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
@@ -0,0 +1,39 @@
package com.rtbishop.look4sat.feature.map
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AwardType
/**
* Session-scoped holder for the map's award filter selection.
*
* Scoped to the Activity (not the navigation entry) so the selection survives
* switching to another page and back; it resets to VUCC only when the process
* starts fresh (cold start), which is what the user expects as the default.
*/
class MapFilterViewModel : ViewModel() {
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
var mapCenterLat: Double? = null
var mapCenterLon: Double? = null
var mapZoom: Double? = null
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
mapCenterLat = centerLat
mapCenterLon = centerLon
mapZoom = zoom
}
companion object {
fun factory() = viewModelFactory {
initializer { MapFilterViewModel() }
}
}
}
@@ -31,10 +31,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -46,10 +48,13 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -63,6 +68,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -144,13 +150,36 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
}
@Composable
fun MapDestination() {
fun MapDestination(
mapFilterViewModel: MapFilterViewModel,
// Invoked with the tapped grid when the QSO dialog's "Match" button is
// pressed: navigate to the match page pre-filled for that grid.
onMatchGrid: (String) -> Unit = {},
// True while the map "Match" query is running (the page only navigates
// once the query finished, so the match page opens with results ready).
matchCalculating: Boolean = false
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
val mapView = rememberMapViewWithLifecycle(mapFilterViewModel, restoreViewport = uiState.isGridMode)
val lifecycle = LocalLifecycleOwner.current.lifecycle
// Save the viewport when leaving the page, but only in grid mode: the
// satellite mode intentionally keeps its original behavior (default center,
// follow the selected satellite), so a satellite-mode exit must not
// overwrite the last grid-mode viewport.
DisposableEffect(mapView, uiState.isGridMode) {
onDispose {
if (uiState.isGridMode) {
mapFilterViewModel.saveMapViewState(
mapView.mapCenter.latitude,
mapView.mapCenter.longitude,
mapView.zoomLevelDouble
)
}
}
}
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -165,11 +194,18 @@ fun MapDestination() {
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView)
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating)
}
@Composable
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
private fun MapScreen(
uiState: MapState,
onAction: (MapAction) -> Unit,
mapView: MapView,
mapFilterViewModel: MapFilterViewModel,
onMatchGrid: (String) -> Unit,
matchCalculating: Boolean
) {
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
@@ -177,9 +213,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page.
var selectedGrid by remember { mutableStateOf<String?>(null) }
// Selected award filter. Entering grid mode defaults to VUCC (the plain
// worked-grid view); the reset effect below re-asserts that on every entry.
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
// Selected award filter. Lives in an Activity-scoped ViewModel so it
// survives page switches; defaults to VUCC only once per process (cold start).
var selectedAward by mapFilterViewModel.selectedAward
// Six-award progress derived from the confirmed QSO store; recomputed when
// the store changes (LoTW/Wavelog sync).
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
@@ -193,6 +229,13 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
m
}
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
}
// Selected operated grid for VUCC counting; defaults to the grid with the
// most worked grids. Null when the store carries no per-QSO myGrid data
// (requires a LoTW resync) — then VUCC falls back to the global count.
@@ -215,16 +258,25 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
}
val isGridMode = uiState.isGridMode
// True when this composition restored a saved viewport. Only grid mode
// restores the viewport the user left (satellite mode keeps its original
// follow-the-satellite behavior); this implies grid mode was already on
// when re-entering, which is why the grid-mode auto-centering below can
// treat the entry as already centered.
val restoredViewport = remember {
isGridMode && mapFilterViewModel.mapCenterLat != null && mapFilterViewModel.mapZoom != null
}
DisposableEffect(isGridMode, workedGrids) {
val receiver = object : org.osmdroid.events.MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
if (!isGridMode || p == null) return false
val zoom = mapView.zoomLevelDouble
// Allow tapping worked cells as soon as the 4-char grid LINES
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
if (grid !in workedGrids) return false
// Every grid is tappable: worked grids show their confirmed QSOs,
// unworked grids open the same dialog with an empty list.
selectedGrid = grid
return true
}
@@ -240,12 +292,6 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
mapView.invalidate()
}
// Re-assert the VUCC default each time grid mode is entered; while already
// in grid mode the user's chip choice is preserved.
LaunchedEffect(uiState.isGridMode) {
if (uiState.isGridMode) selectedAward = AwardType.VUCC
}
LaunchedEffect(uiState.track) {
// In grid mode the map is centered on the local grid square; following
// the satellite subpoint here would override that centering on every
@@ -281,9 +327,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
// Track grid-mode transitions; initial value false so that entering
// the map page with grid mode already ON also centers the map.
var prevGridMode by remember { mutableStateOf(false) }
// Track grid-mode transitions. When the map was just restored to
// the viewport the user left (re-entry after a page switch) and
// grid mode is already on, treat the entry as already centered so
// the restored position survives. Cold-start entries and in-page
// toggles still center on the station grid as before.
var prevGridMode by remember {
mutableStateOf(restoredViewport && uiState.isGridMode)
}
AndroidView({ mapView }) { view ->
// Award filter mode: show the selected award's regions with
// worked ones filled green; hide satellite layers like grid mode.
@@ -297,6 +348,10 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
prevGridMode = uiState.isGridMode
} else {
// Center on the station grid whenever entering grid mode.
// (prevGridMode is initialized to "already centered" when
// the map was just restored to the viewport the user left,
// so a re-entry keeps the restored position; in-page
// toggles still center normally.)
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
@@ -304,7 +359,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
setGridMode(
uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
uiState.stationPosition,
centerOnStation = shouldCenter
centerOnStation = shouldCenter,
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
firstCallsByGrid = firstCallsByGrid,
markedGrids = uiState.markedGrids
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
@@ -339,24 +398,45 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
.padding(8.dp)
)
}
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
// Top-right: grid-mode pill + (grid mode only) the first-call
// label pill, stacked vertically.
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.End
) {
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) }
)
// 首通呼号开关仅 VUCC 视图显示(与 VuccGridSelector 同条件).
if (uiState.isGridMode && selectedAward == AwardType.VUCC) {
FirstCallToggle(
checked = uiState.showFirstCallLabels,
onToggle = { onAction(MapAction.ToggleFirstCallLabels(it)) }
)
}
}
}
}
}
// Centered dialog listing the tapped worked grid's confirmed satellite QSOs.
// Dismissed by tapping outside (no explicit close button).
// Dismissed by tapping outside (no explicit close button). Unworked grids
// open the same dialog with an empty list plus a mark-a-station reminder.
selectedGrid?.let { grid ->
WorkedGridQsoDialog(
grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
marked = uiState.markedGrids[grid].orEmpty(),
isUtc = uiState.isUtc,
onDismiss = { selectedGrid = null }
matchCalculating = matchCalculating,
onDismiss = { selectedGrid = null },
onMatch = { onMatchGrid(grid) },
onMark = { call -> onAction(MapAction.SetMarkedStation(grid, call)) },
onRemoveMark = { call -> onAction(MapAction.RemoveMarkedStation(grid, call)) },
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
)
}
}
@@ -367,9 +447,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
private fun WorkedGridQsoDialog(
grid: String,
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
marked: List<com.rtbishop.look4sat.core.domain.model.MarkedStation>,
isUtc: Boolean,
onDismiss: () -> Unit
matchCalculating: Boolean,
onDismiss: () -> Unit,
onMatch: () -> Unit,
onMark: (String) -> Unit,
onRemoveMark: (String) -> Unit,
onPinMark: (String) -> Unit
) {
var showMarkInput by remember { mutableStateOf(false) }
var callInput by remember { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Centered card, ~85% width, internal scroll for long lists.
Card(
@@ -377,48 +465,202 @@ private fun WorkedGridQsoDialog(
modifier = Modifier.fillMaxWidth(0.88f)
) {
Column {
// Header: grid + counts
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
Text(
text = grid,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
val distinctCalls = qsos.map { it.call }.distinct().size
Text(
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Header: grid + counts on the left, "Match" action on the right
// (jumps to the match page pre-filled with this grid).
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Column(Modifier.weight(1f)) {
Text(
text = grid,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
val distinctCalls = qsos.map { it.call }.distinct().size
Text(
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextButton(
enabled = !matchCalculating,
onClick = {
onDismiss()
onMatch()
}
) {
if (matchCalculating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_tower),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.grid_qso_match))
}
}
androidx.compose.material3.HorizontalDivider()
if (qsos.isEmpty()) {
Text(
text = stringResource(R.string.grid_qso_no_sat),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp)
)
} else {
// Group by callsign preserving first-contact order (list is
// already sorted oldest-first); expandable rows.
val grouped = remember(qsos) {
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
Column(
Modifier
.heightIn(max = 380.dp)
.verticalScroll(rememberScrollState())
) {
// Marked stations show first as red reminder rows: pale-yellow
// callsign, "标记" in the count column, the marking date in the
// date column, a small pin icon (first = pinned, grey; others
// pale yellow) and a delete icon to remove the mark.
val showPin = marked.size > 1
marked.forEachIndexed { index, station ->
MarkedStationRow(
marked = station,
isPinned = index == 0,
showPin = showPin,
isUtc = isUtc,
onRemoveMark = { onRemoveMark(station.call) },
onPinMark = { onPinMark(station.call) }
)
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
}
Column(
Modifier
.heightIn(max = 380.dp)
.verticalScroll(rememberScrollState())
) {
if (qsos.isNotEmpty()) {
// Group by callsign preserving first-contact order (list is
// already sorted oldest-first); expandable rows.
val grouped = remember(qsos) {
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
}
grouped.forEach { (call, callQsos) ->
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
}
}
// Unworked grids: offer to mark a station the user wants to
// contact in this grid (a reminder, cleared when worked).
if (qsos.isEmpty()) {
TextButton(
onClick = {
callInput = ""
showMarkInput = true
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.grid_mark_station))
}
}
}
}
}
}
// Mark-a-station input dialog: enter a callsign as a reminder.
if (showMarkInput) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showMarkInput = false },
title = { Text(stringResource(R.string.grid_mark_station)) },
text = {
androidx.compose.material3.OutlinedTextField(
value = callInput,
onValueChange = { callInput = it.uppercase() },
label = { Text(stringResource(R.string.grid_mark_callsign)) },
singleLine = true
)
},
confirmButton = {
TextButton(
enabled = callInput.isNotBlank(),
onClick = {
onMark(callInput)
showMarkInput = false
}
) { Text(stringResource(R.string.grid_mark_confirm)) }
},
dismissButton = {
TextButton(onClick = { showMarkInput = false }) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
}
@Composable
private fun MarkedStationRow(
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
isPinned: Boolean,
showPin: Boolean,
isUtc: Boolean,
onRemoveMark: () -> Unit,
onPinMark: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = marked.call,
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = ComposeColor(0xFFFFE082),
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// "标记" column: pin icon (only when the grid has multiple marks) sits
// left of the label; pinned (first) row's icon is grey, others pale
// yellow — tapping moves that mark to the top.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
if (showPin) {
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.grid_mark_pin),
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082),
modifier = Modifier
.size(18.dp)
.clickable(onClick = onPinMark)
)
Spacer(Modifier.width(2.dp))
}
Text(
text = stringResource(R.string.grid_mark_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.weight(1f)
) {
Text(
text = formatDate(marked.epochMs, isUtc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.grid_mark_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.size(18.dp)
.clickable(onClick = onRemoveMark)
)
}
}
}
@Composable
@@ -767,6 +1009,40 @@ private fun GridModeToggle(
}
}
/**
* 网格模式下的"首通呼号"开关: 打开后绿格(worked)的标签显示该格第一个通联的
* 呼号, 非绿格不显示任何文字. 仅在网格模式下显示(与网格模式开关叠放).
*/
@Composable
private fun FirstCallToggle(
checked: Boolean,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(percent = 50),
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.map_first_call_mode),
color = ComposeColor.White,
fontSize = 12.sp,
modifier = Modifier.padding(end = 2.dp)
)
Switch(
checked = checked,
onCheckedChange = onToggle,
modifier = Modifier.scale(0.75f)
)
}
}
}
// endregion
// region Map overlay helpers
@@ -810,7 +1086,10 @@ private fun setGridMode(
roamedGrids: Set<String>,
mapView: MapView,
stationPosition: GeoPos?,
centerOnStation: Boolean = false
centerOnStation: Boolean = false,
showFirstCallLabels: Boolean = false,
firstCallsByGrid: Map<String, String> = emptyMap(),
markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
@@ -818,6 +1097,10 @@ private fun setGridMode(
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.roamedGrids = roamedGrids
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
gridOverlay.showFirstCallLabels = showFirstCallLabels
gridOverlay.firstCallsByGrid = firstCallsByGrid
gridOverlay.markedGrids = markedGrids
// ownGrid must be set on EVERY update — the position is available
// regardless of whether this frame centers (centering happens only
// on entry, but passing null here would wipe the bold outline).
@@ -827,6 +1110,9 @@ private fun setGridMode(
isEnabled = gridMode
this.workedGrids = workedGrids
this.roamedGrids = roamedGrids
this.showFirstCallLabels = showFirstCallLabels
this.firstCallsByGrid = firstCallsByGrid
this.markedGrids = markedGrids
this.ownGrid = ownGridOf(stationPosition)
}
}
@@ -1173,7 +1459,10 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
// region MapView lifecycle
@Composable
private fun rememberMapViewWithLifecycle(): MapView {
private fun rememberMapViewWithLifecycle(
mapFilterViewModel: MapFilterViewModel,
restoreViewport: Boolean
): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val isVertical = isVerticalLayout()
@@ -1189,8 +1478,19 @@ private fun rememberMapViewWithLifecycle(): MapView {
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
// Restore the viewport the user left on the previous visit, but only
// in grid mode: satellite mode intentionally keeps its original
// behavior (default center, then follows the selected satellite).
val savedLat = mapFilterViewModel.mapCenterLat
val savedLon = mapFilterViewModel.mapCenterLon
val savedZoom = mapFilterViewModel.mapZoom
if (restoreViewport && savedLat != null && savedLon != null && savedZoom != null) {
controller.setCenter(GeoPoint(savedLat, savedLon))
controller.setZoom(savedZoom)
} else {
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
}
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
@@ -1208,6 +1508,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
}
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
// (The viewport save lives in MapDestination, where the grid/satellite mode is known.)
DisposableEffect(mapView) {
onDispose { clearMapCaches() }
}
@@ -27,10 +27,16 @@ data class MapState(
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
val showFirstCallLabels: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
val roamedGrids: Set<String> = emptySet(),
/** Stations the user marked in unworked gridsquares (grid -> ordered list;
* first = pinned/top) — red fill. */
val markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -48,6 +54,10 @@ sealed interface MapAction {
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetMarkedStation(val grid: String, val call: String) : MapAction
data class RemoveMarkedStation(val grid: String, val call: String) : MapAction
data class PinMarkedStation(val grid: String, val call: String) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
@@ -79,16 +79,30 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
}
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = settingsRepo.getWorkedGrids(),
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids()
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
}
@@ -105,6 +119,39 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
is MapAction.SetMarkedStation -> {
val call = action.call.trim().uppercase()
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
// 重复呼号忽略: 同一网格已标记过的呼号不再重复添加.
val updated = if (current.any { it.call == call }) existing
else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation(
call = call,
epochMs = System.currentTimeMillis()
))
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.RemoveMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val remaining = current.filterNot { it.call == action.call }
val updated = if (remaining.isEmpty()) existing - action.grid
else existing + (action.grid to remaining)
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.PinMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val target = current.firstOrNull { it.call == action.call }
if (target != null) {
val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call })
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
}
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
+17 -1
View File
@@ -4,4 +4,20 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
testOptions {
unitTests {
// Robolectric needs real Android resources for Compose UI tests.
isIncludeAndroidResources = true
}
}
}
dependencies {
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.navigation3)
debugImplementation(libs.compose.debug.manifest)
}
@@ -17,6 +17,7 @@
*/
package com.rtbishop.look4sat.feature.mutual
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@@ -49,13 +51,17 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -74,6 +80,8 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun MutualScreen(
@@ -150,9 +158,10 @@ fun MutualScreen(
}
) { isVertical ->
MutualContent(
viewModel = viewModel,
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
onQuery = { viewModel.queryMutualPasses() },
onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat,
@@ -165,6 +174,8 @@ fun MutualScreen(
onStationBMinElev = viewModel::onStationBMinElev,
onUseCurrentPosition = viewModel::onUseCurrentPosition,
onHoursAhead = viewModel::onHoursAhead,
onFilterFM = viewModel::onFilterFM,
onFilterLinear = viewModel::onFilterLinear,
onClearError = viewModel::clearError
)
}
@@ -172,6 +183,7 @@ fun MutualScreen(
@Composable
private fun MutualContent(
viewModel: MutualViewModel,
state: MutualUiState,
isVertical: Boolean,
onQuery: () -> Unit,
@@ -187,6 +199,8 @@ private fun MutualContent(
onStationBMinElev: (Double) -> Unit,
onUseCurrentPosition: () -> Unit,
onHoursAhead: (Int) -> Unit,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onClearError: () -> Unit
) {
val timeFormat = remember(state.isUtc) {
@@ -195,8 +209,44 @@ private fun MutualContent(
}
}
// List state survives page switches via the Activity-scoped ViewModel: the
// initial position is restored from the VM, and scrolling writes back to it,
// so returning to this page keeps exactly where the user left off. A new
// query (queryGeneration bump) rebuilds the state at the top.
val listState = remember(viewModel.queryGeneration) {
LazyListState(
firstVisibleItemIndex = viewModel.listScrollIndex,
firstVisibleItemScrollOffset = viewModel.listScrollOffset
)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
viewModel.listScrollIndex = index
viewModel.listScrollOffset = offset
}
}
// Set once the list has actually been laid out. The prefill scroll only
// fires after this, because on a real device the Mutual entry appears
// through the NavDisplay transition and LaunchedEffect alone can run
// before the list is measurable (the scroll then never lands and the
// page stays at the top). onGloballyPositioned fires on the first real
// layout, so the subsequent scrollToItem always has a measured list.
var listReady by remember { mutableStateOf(false) }
Log.d(TAG, "composed: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=${if (state.errorMessage != null) 2 else 1} listReady=$listReady")
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
if (!listReady) {
Log.d(TAG, "lazy list laid out, opening prefill scroll gate")
listReady = true
}
},
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Error message
@@ -220,7 +270,7 @@ private fun MutualContent(
}
// Input form
item {
item(key = "stationInputs") {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
@@ -297,16 +347,25 @@ private fun MutualContent(
)
}
}
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
}
// Time range + query card. Its own LazyColumn item so a prefill from
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
// to the time-range selector.
item(key = "matchSearch") {
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
filterFM = state.filterFM,
filterLinear = state.filterLinear,
onFilterFM = onFilterFM,
onFilterLinear = onFilterLinear,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
// Results
itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData(
@@ -338,8 +397,44 @@ private fun MutualContent(
)
}
}
// Prefill from the map's grid-QSO dialog "Match" button: once the page is
// laid out, jump straight to the time-range card. Index accounts for the
// optional error card at the top (error = 0, station inputs = 1, time
// range = 2; else 1). scrollToItem is used instead of initializing the
// LazyListState at index 1: the constructor parameter is ignored by this
// Compose version (verified in tests), while scrollToItem lands correctly.
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
if (state.scrollToTimeRange && listReady) {
// Keep trying until the scroll really lands: on a device the first
// frame only contains the station cards + time-range card, which
// can be shorter than the viewport (no scroll range), so a single
// scrollToItem does nothing. When the async query results arrive
// the list grows past one screen and the scroll becomes possible.
for (attempt in 0 until 20) {
Log.d(TAG, "attempting scrollToItem($matchSearchIndex) #$attempt")
try {
listState.scrollToItem(matchSearchIndex)
} catch (t: Throwable) {
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
break
}
if (listState.firstVisibleItemIndex == matchSearchIndex) {
Log.d(TAG, "scroll landed at $matchSearchIndex on attempt #$attempt")
break
}
delay(100)
}
viewModel.consumeScrollToTimeRange()
Log.d(TAG, "scrollToTimeRange consumed")
}
}
}
private const val TAG = "Look4SatMutual"
@Composable
private fun MutualStatusChip(state: MutualUiState) {
val colorScheme = MaterialTheme.colorScheme
@@ -483,6 +578,10 @@ private fun StationInputCard(
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
filterFM: Boolean,
filterLinear: Boolean,
onFilterFM: (Boolean) -> Unit,
onFilterLinear: (Boolean) -> Unit,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
@@ -509,6 +608,28 @@ private fun MatchSearchCard(
)
}
}
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
// 防止选入大量无转发器卫星占用匹配页.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mutual_filter_transponder),
style = MaterialTheme.typography.labelLarge
)
FilterChip(
selected = filterFM,
onClick = { onFilterFM(!filterFM) },
label = { Text(stringResource(R.string.mutual_filter_fm)) }
)
FilterChip(
selected = filterLinear,
onClick = { onFilterLinear(!filterLinear) },
label = { Text(stringResource(R.string.mutual_filter_linear)) }
)
}
Button(
onClick = onQuery,
enabled = !isCalculating,
@@ -40,6 +40,8 @@ import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.roundToInt
private const val TAG = "Look4SatMutual"
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
@@ -50,12 +52,28 @@ data class MutualUiState(
val stationBGrid: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
val hoursBefore: Int = 0,
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
val filterFM: Boolean = true,
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
val filterLinear: Boolean = true,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1,
val isUtc: Boolean = false,
val errorMessage: String? = null
val errorMessage: String? = null,
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
// the page scrolls to the time-range card after the first layout, then
// consumeScrollToTimeRange() clears it.
val scrollToTimeRange: Boolean = false,
// One-shot flag set by prefillMatchFromGrid(): the map page waits for the
// pre-filled query to finish, then navigates to the match page so the very
// first frame already contains the results (time-range card + pass curves,
// no scroll flicker). consumePendingNavigation() clears it.
val pendingNavigation: Boolean = false
)
class MutualViewModel(
@@ -69,6 +87,15 @@ class MutualViewModel(
private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
// survives switching to another page and back). Reset to the top whenever a
// new query replaces the results.
var listScrollIndex: Int = 0
var listScrollOffset: Int = 0
/** Bumped on every new query so the list scrolls back to the top. */
var queryGeneration: Int = 0
init {
// Pre-fill station A with the user's current station position (as grid),
// and default min elevation to the same value used by the main radar passes
@@ -88,6 +115,12 @@ class MutualViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
// 历史过境回看窗口跟随主页面 Passes 设置 (设置里改小时数, 匹配页同步生效)
viewModelScope.launch {
settingsRepo.passesSettings.collectLatest { settings ->
_uiState.update { it.copy(hoursBefore = settings.hoursBefore) }
}
}
}
fun onStationALat(value: String) {
@@ -172,9 +205,54 @@ class MutualViewModel(
) }
}
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
fun queryMutualPasses() {
/**
* Pre-fill the match page for a target grid picked from the map's grid-QSO
* dialog ("Match" button): set the opposite-station grid (with its
* coordinates), reset the time range to 24h, start the query immediately
* so results are ready when the page opens, and flag the page to scroll
* straight to the time-range card once it is laid out.
*/
fun prefillMatchFromGrid(grid: String) {
val g = grid.trim().uppercase()
val pos = qthToPosition(g)
android.util.Log.d(TAG, "prefillMatchFromGrid('$g')")
_uiState.update {
it.copy(
stationBGrid = g,
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
hoursAhead = 24,
scrollToTimeRange = true,
pendingNavigation = true
)
}
queryMutualPasses()
}
fun consumePendingNavigation() {
android.util.Log.d(TAG, "consumePendingNavigation")
_uiState.update { it.copy(pendingNavigation = false) }
}
fun consumeScrollToTimeRange() {
android.util.Log.d(TAG, "consumeScrollToTimeRange")
_uiState.update { it.copy(scrollToTimeRange = false) }
}
/**
* Run a mutual-pass query. `initialScrollIndex` non-null forces the list
* to rebuild at that position (legacy callers); null keeps the current
* scroll position, so re-running a query from the match page does not
* jump back to the top. The previous results stay on screen while the
* query runs (the list keeps its height, so the scroll position is not
* clamped away); they are replaced — possibly with empty — when the new
* results arrive.
*/
fun queryMutualPasses(initialScrollIndex: Int? = null) {
val state = _uiState.value
// Resolve positions from lat/lon or grid
@@ -192,24 +270,73 @@ class MutualViewModel(
return
}
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
// 防止用户选入大量无转发器卫星占用匹配页.
if (!state.filterFM && !state.filterLinear) {
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
return
}
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
_uiState.update {
it.copy(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
// Deliberately keep mutualPasses: clearing it would shrink the
// list below one screen and clamp the scroll position away.
// The stale results are replaced when the new ones arrive.
selectedPassIndex = -1
)
}
// Scroll position: an explicit initialScrollIndex overrides (legacy
// callers start at a fixed spot); null keeps the current position so
// re-querying does not jump back to the top.
if (initialScrollIndex != null) {
listScrollIndex = initialScrollIndex
listScrollOffset = 0
}
queryGeneration += 1
viewModelScope.launch {
val time = System.currentTimeMillis()
val now = System.currentTimeMillis()
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
val amSatFm = settingsRepo.getAmSatFmCatnums()
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
val filterModes = buildList {
if (state.filterFM) add("FM")
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
}
val idsWithModes = satelliteRepo.getSatelliteIdsWithModes(filterModes)
if (idsWithModes.isEmpty()) satellites
else satellites.filter { it.data.catnum in idsWithModes }
} else {
val allowed = buildSet {
if (state.filterFM) addAll(amSatFm)
if (state.filterLinear) addAll(amSatLinear)
}
satellites.filter { it.data.catnum in allowed }
}
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
// 终点 = now + hoursAhead 不变.
val time = now - state.hoursBefore * 60L * 60L * 1000L
val minElevA = state.stationAMinElev
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
val results = try {
withContext(computeDispatcher) {
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
}
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
// block the pending map→match navigation forever).
android.util.Log.e(TAG, "findMutualPasses threw", t)
emptyList()
}
val errorMsg = if (results.isEmpty()) {
@@ -248,15 +375,20 @@ class MutualViewModel(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, hours: Int
time: Long, hours: Int, hoursBefore: Int
): List<MutualPass> {
val endTime = time + hours * 60L * 60L * 1000L
// 终点始终是 now + hoursAhead; 起点已由调用方下移 hoursBefore 小时,
// 因此这里的 endTime 要在起点之上补回 hoursBefore, 保证窗口 = [now-hb, now+h].
val endTime = time + (hoursBefore + hours) * 60L * 60L * 1000L
val sampleInterval = 5_000L
// Use the main page's pass list for AOS/LOS times, then sample the curves
// using the actual positions. The passes list is already computed by getLeoPass
// and its AOS/LOS times match the Passes page exactly.
val existingPasses = satelliteRepo.passes.value
// The satellites argument may be a subset (transponder-type filter), so
// only passes belonging to the filtered satellites are reused.
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
if (results.isNotEmpty()) return results
@@ -44,13 +44,18 @@ class FakeSatelliteRepo(
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
}
/**
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
* state; everything else the ViewModel never touches fails loudly.
*/
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
class FakeSettingsRepo(
initialPosition: GeoPos = GeoPos(23.13, 113.26),
val amSatFm: Set<Int> = emptySet(),
val amSatLinear: Set<Int> = emptySet()
) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
@@ -103,6 +108,10 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -115,6 +124,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
override fun getRoamedGrids(): Set<String> = TODO()
override fun setRoamedGrids(grids: Set<String>) = TODO()
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
@@ -0,0 +1,291 @@
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.presentation.Screen
import kotlinx.coroutines.delay
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Verifies the map grid-QSO dialog "Match" prefill: after
* [MutualViewModel.prefillMatchFromGrid] the page opens with the time-range
* card at the top and the 24h query auto-started.
*
* Note: `LazyListState(firstVisibleItemIndex = 1)` is deliberately NOT used to
* position the page. In this Compose version (BOM 2026.06.01 / ui 1.11.x) the
* constructor parameter is ignored — the list still starts at item 0 — while
* `listState.scrollToItem(1)` from a LaunchedEffect lands correctly. See
* [scrollToItemLandsAtTop] below, which guards the scrollToItem mechanism.
*
* Robolectric quirk: `assertIsNotDisplayed()` is unreliable here (a node
* scrolled fully out of the viewport can still report "displayed"), so the
* prefill test asserts the time-range card IS displayed (positive assertion)
* rather than that the station card is NOT.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w411dp-h891dp-port")
class MutualMatchPrefillScrollTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun sanity_textRenders() {
composeRule.setContent {
MaterialTheme {
Text("sanity-check-text")
}
}
composeRule.onNodeWithText("sanity-check-text").assertIsDisplayed()
}
@Test
fun scrollToItemLandsAtTop() {
// Guards the mechanism the prefill relies on: scrollToItem(1) from a
// LaunchedEffect moves item 1 to the top of the viewport.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val state = remember { LazyListState(firstVisibleItemIndex = 0) }
LaunchedEffect(Unit) { state.scrollToItem(1) }
LazyColumn(state = state) {
item { Text("min-item-0") }
item { Text("min-item-1") }
item { Text("min-item-2") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("min-item-1").assertIsDisplayed()
}
@Test
fun defaultOpen_showsStationInputsAtTop() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
// Without a prefill the list starts at item 0 (station inputs).
composeRule.onNodeWithText("Your Station").assertIsDisplayed()
}
@Test
fun prefillFromMap_opensAtTimeRangeCard() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
// Same call the map grid-QSO "Match" button makes.
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
// The time-range card must be visible at the top of the page after the
// prefill scroll.
composeRule.onNodeWithText("Time range").assertIsDisplayed()
// The snapshotFlow write-back stores the scrolled position in the VM.
// It equals 1 only if scrollToItem(1) REALLY scrolled the station card
// out. If the list content is shorter than the viewport there is no
// scroll range, scrollToItem cannot move, and this stays 0 — that is
// exactly the device symptom ("page stays at the top").
org.junit.Assert.assertEquals(
"list must have actually scrolled to item 1 (content shorter than viewport?)",
1, vm.listScrollIndex
)
}
@Test
fun prefillAutoStartsQueryAndFillsState() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val s = vm.uiState.value
// Query auto-started: with the empty-satellite fake the query reaches
// the "no satellite data" guard (rather than never being triggered),
// proving prefillMatchFromGrid kicks off queryMutualPasses.
assertTrue(
"auto-query should reach the satellite guard: gridA=${s.stationAGrid} " +
"gridB=${s.stationBGrid} err=${s.errorMessage}",
s.errorMessage?.contains("satellite", ignoreCase = true) == true
)
// ...and the target grid + 24h range pre-filled.
org.junit.Assert.assertEquals("OL62", s.stationBGrid)
org.junit.Assert.assertEquals(24, s.hoursAhead)
// ...and navigation to the match page is pending until the query done.
assertTrue("navigation should be pending", s.pendingNavigation)
// The early-return guard means the query finished immediately (no
// satellites): the map page must now be free to navigate.
org.junit.Assert.assertFalse("query finished -> not calculating", s.isCalculating)
vm.consumePendingNavigation()
org.junit.Assert.assertFalse("consume clears pending navigation", vm.uiState.value.pendingNavigation)
}
@Test
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
// The Mutual screen composed inside a NavDisplay entry (back stack
// [Mutual]) right after prefillMatchFromGrid.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Mutual)
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun navDisplay_switchToMutualEntry_scrollsToTimeRange() {
// Closest device path: start on the Map entry, then push the Mutual
// entry (what the map "Match" button does). NavDisplay runs a real
// fade transition while the Mutual screen composes.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val backStackRef = mutableStateOf<NavBackStack<NavKey>?>(null)
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Map)
backStackRef.value = backStack
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Map> { Text("MAP PAGE") }
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
// Push Mutual like onMatchGrid does, so the transition composes
// MutualScreen with scrollToTimeRange already set.
composeRule.runOnIdle { backStackRef.value?.add(Screen.Mutual) }
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun scrollRetriesUntilContentGrows() {
// Reproduces the real-device mechanism: the first frame's content
// (item 0 short) is shorter than the viewport, so scrollToItem(1) has
// no range and cannot move. Then the content grows (async results)
// past one screen; the retry loop must land on item 1.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val tall = mutableStateOf(false)
LaunchedEffect(Unit) { delay(300); tall.value = true }
val state = remember { LazyListState() }
LaunchedEffect(state) {
// Same retry loop MutualScreen uses for the prefill.
for (attempt in 0 until 20) {
state.scrollToItem(1)
if (state.firstVisibleItemIndex == 1) return@LaunchedEffect
delay(100)
}
}
LazyColumn(state = state) {
item {
if (tall.value) Spacer(modifier = Modifier.height(900.dp))
else Spacer(modifier = Modifier.height(40.dp))
}
item { Text("target-item") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("target-item").assertIsDisplayed()
// Confirm the list really scrolled (station card equivalent is gone).
// We can't read listState from here, so use the VM-style probe: none —
// the target visible + viewport tall enough implies item 1 on top.
}
@Test
fun keepPositionAcrossTabs_thenMapMatchPrefill_scrollsToTimeRange() {
// User hypothesis: the "keep scroll position across tab switches"
// machinery (listState remember(queryGeneration) + snapshotFlow
// write-back) interferes with the prefill auto-scroll. Reproduce the
// full journey: first visit -> scroll a bit -> leave -> return
// (position restored) -> leave -> enter via map Match button.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
val showMutual = mutableStateOf(true)
// First visit (e.g. bottom nav), user scrolls a little; the
// snapshotFlow write-back stored index/offset in the VM.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
if (showMutual.value) MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
vm.listScrollIndex = 0
vm.listScrollOffset = 40
// Leave the page (tab switch destroys the composition).
showMutual.value = false
composeRule.waitForIdle()
// Re-enter: keep-position restores the scroll offset.
showMutual.value = true
composeRule.waitForIdle()
// Leave again, then enter via the map grid-QSO Match button.
showMutual.value = false
composeRule.waitForIdle()
vm.prefillMatchFromGrid("OL62")
showMutual.value = true
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
}
@@ -31,10 +31,12 @@ class MutualViewModelTest {
private fun TestScope.createVm(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList(),
position: GeoPos = TestOrbits.GUANGZHOU
position: GeoPos = TestOrbits.GUANGZHOU,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet()
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position),
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
)
@@ -285,4 +287,102 @@ class MutualViewModelTest {
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `requery keeps scroll position and stale results while running`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
assertTrue("fixture must produce ISS windows", windows.isNotEmpty())
val vm = createVm(
satellites = listOf(TestOrbits.ISS),
passes = windows
)
vm.onStationBGrid("OL62")
queryAndSettle(vm)
assertTrue("fixture must yield passes", vm.uiState.value.mutualPasses.isNotEmpty())
// The user scrolled into the results; the snapshotFlow write-back has
// stored this position in the VM.
vm.listScrollIndex = 4
vm.listScrollOffset = 137
// Re-run the query from the match page ("query" button).
vm.queryMutualPasses()
// While the query runs, the old results stay on screen (the list keeps
// its height, so the scroll position is not clamped away)...
assertTrue("stale results kept while calculating", vm.uiState.value.mutualPasses.isNotEmpty())
// ...and the scroll position must not be reset to the top.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCalculating)
assertTrue("fresh results replace the stale ones", vm.uiState.value.mutualPasses.isNotEmpty())
// Position intact after the query settled.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
}
// --------------------------------------------- transponder filters (AMSAT)
@Test
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
vm.onStationBGrid("OL62")
vm.onFilterFM(false)
vm.onFilterLinear(false)
queryAndSettle(vm)
assertEquals(
"Select at least one transponder type (FM / Linear) to run the match.",
vm.uiState.value.errorMessage
)
assertFalse(vm.uiState.value.hasSearched)
}
@Test
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
assertTrue("fixture must produce windows", windows.isNotEmpty())
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544)
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue(state.mutualPasses.isNotEmpty())
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
}
@Test
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544),
amSatLinear = setOf(25545)
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
vm.onFilterFM(false)
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
}
}
@@ -73,6 +73,7 @@ private val allModes = listOf(
)
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
private val historyHourSteps = listOf(0) + hourSteps
private const val dayMinutes = 24 * 60
private const val minuteStep = 15
private const val endOfDayMinute = dayMinutes - 1
@@ -80,6 +81,7 @@ private const val quarterHourSlots = dayMinutes / minuteStep
data class PassFilterParams(
val hours: Int,
val hoursBefore: Int,
val elevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -95,6 +97,7 @@ private fun PassesDialogPreview() {
MainTheme {
PassesFilterDialog(
hours = 24,
hoursBefore = 0,
elevation = 16.0,
lowElevation = 16.0,
highElevation = 65.0,
@@ -111,6 +114,7 @@ private fun PassesDialogPreview() {
@Composable
internal fun PassesFilterDialog(
hours: Int,
hoursBefore: Int,
elevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -122,6 +126,9 @@ internal fun PassesFilterDialog(
accept: (PassFilterParams) -> Unit
) {
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
val hoursBeforeIndex = remember {
mutableIntStateOf(historyHourSteps.indexOfFirst { it >= hoursBefore }.coerceAtLeast(0))
}
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val highlightBounds = 0f..90f
var highlightRange by remember(lowElevation, highElevation) {
@@ -136,6 +143,7 @@ internal fun PassesFilterDialog(
accept(
PassFilterParams(
hours = hourSteps[hoursIndex.intValue],
hoursBefore = historyHourSteps[hoursBeforeIndex.intValue],
elevation = elevationValueNew.doubleValue,
lowElevation = highlightRange.start.roundToInt().toDouble(),
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
@@ -164,6 +172,14 @@ internal fun PassesFilterDialog(
valueRange = 0f..(hourSteps.size - 1).toFloat(),
steps = hourSteps.size - 2
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
SliderRow(
title = stringResource(R.string.pass_filter_history_hours),
value = hoursBeforeIndex.intValue.toDouble(),
displayValue = formatHoursLabel(historyHourSteps[hoursBeforeIndex.intValue]),
valueResId = R.drawable.ic_clock,
valueRange = 0f..(historyHourSteps.size - 1).toFloat(),
steps = historyHourSteps.size - 2
) { hoursBeforeIndex.intValue = it.toInt().coerceIn(0, historyHourSteps.size - 1) }
ToggleRow(
title = stringResource(R.string.pass_filter_deep_space),
checked = deepSpaceEnabled,
@@ -30,12 +30,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -103,6 +106,7 @@ private fun PassesScreen(
if (uiState.isPassesDialogShown) {
PassesFilterDialog(
hours = uiState.hours,
hoursBefore = uiState.hoursBefore,
elevation = uiState.elevation,
lowElevation = uiState.lowElevation,
highElevation = uiState.highElevation,
@@ -115,6 +119,7 @@ private fun PassesScreen(
onAction(
PassesAction.FilterPasses(
hoursAhead = params.hours,
hoursBefore = params.hoursBefore,
minElevation = params.elevation,
lowElevation = params.lowElevation,
highElevation = params.highElevation,
@@ -143,11 +148,16 @@ private fun PassesScreen(
onAccept = dismiss,
titleFontSize = 18
) { padding ->
// The changelog is long; keep the dialog frame fixed and let the
// text scroll inside it (matches the update-checker page pattern).
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
modifier = Modifier
.padding(horizontal = padding)
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
)
}
}
@@ -28,6 +28,8 @@ data class PassesState(
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
/** 历史过境回看窗口(小时), 0 = 只看未来. */
val hoursBefore: Int = 0,
val elevation: Double = 16.0,
val lowElevation: Double = 16.0,
val highElevation: Double = 65.0,
@@ -47,6 +49,7 @@ sealed interface PassesAction {
data object DismissWhatsNew : PassesAction
data class FilterPasses(
val hoursAhead: Int,
val hoursBefore: Int,
val minElevation: Double,
val lowElevation: Double,
val highElevation: Double,
@@ -55,6 +55,7 @@ class PassesViewModel(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
hoursBefore = settingsRepo.passesSettings.value.hoursBefore,
elevation = settingsRepo.passesSettings.value.minElevation,
lowElevation = settingsRepo.otherSettings.value.lowElevation,
highElevation = settingsRepo.otherSettings.value.highElevation,
@@ -95,8 +96,12 @@ class PassesViewModel(
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
// 历史过境窗口: 已结束的过境保留到超过 hoursBefore 小时后消失;
// DeepSpace 保持原行为(始终显示). hoursBefore=0 时过境一结束即消失.
val historyCutoff = timeNow - _uiState.value.hoursBefore * 3600_000L
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
.filter { pass -> pass.isDeepSpace || pass.losTime >= historyCutoff }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
@@ -123,6 +128,7 @@ class PassesViewModel(
is PassesAction.FilterPasses ->
applyFilter(
hoursAhead = action.hoursAhead,
hoursBefore = action.hoursBefore,
minElevation = action.minElevation,
lowElevation = action.lowElevation,
highElevation = action.highElevation,
@@ -135,6 +141,7 @@ class PassesViewModel(
is PassesAction.FilterRadios ->
applyFilter(
hoursAhead = _uiState.value.hours,
hoursBefore = _uiState.value.hoursBefore,
minElevation = _uiState.value.elevation,
lowElevation = _uiState.value.lowElevation,
highElevation = _uiState.value.highElevation,
@@ -246,7 +253,7 @@ class PassesViewModel(
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */
/** Computes live progress while retaining completed passes in the requested history window. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
@@ -254,9 +261,9 @@ class PassesViewModel(
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
val boundedProgress = newProgress.coerceIn(0f, 1f)
if (boundedProgress != pass.progress) {
result.add(pass.copy(progress = boundedProgress))
} else {
result.add(pass)
}
@@ -285,6 +292,7 @@ class PassesViewModel(
private fun applyFilter(
hoursAhead: Int,
hoursBefore: Int,
minElevation: Double,
lowElevation: Double,
highElevation: Double,
@@ -302,13 +310,15 @@ class PassesViewModel(
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
modes
modes,
hoursBefore
)
)
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
_uiState.update {
it.copy(
hours = hoursAhead,
hoursBefore = hoursBefore,
elevation = minElevation,
lowElevation = lowElevation,
highElevation = highElevation,
@@ -319,9 +329,10 @@ class PassesViewModel(
modes = modes
)
}
val now = System.currentTimeMillis()
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = hoursAhead,
time = now - hoursBefore * 60L * 60L * 1000L,
hoursAhead = hoursBefore + hoursAhead,
minElevation = minElevation,
aosStartMinute = aosStartMinute,
aosEndMinute = aosEndMinute,
@@ -332,9 +343,10 @@ class PassesViewModel(
private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value
// 下拉刷新也保留历史过境窗口, 否则刷新即丢 history (与筛选/启动计算口径一致)
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = System.currentTimeMillis() - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
@@ -82,7 +82,7 @@ class RadarViewModel(
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.sensorData.value,
orientationValues = correctedOrientation(sensorsRepo.sensorData.value),
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
@@ -102,12 +102,19 @@ class RadarViewModel(
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.sensorData.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
val orientationValues = correctedOrientation(data)
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
}
/** 指南针方位 = 传感器方位 + 磁偏角 + 手动偏置(compassOffsetDegrees, 校准用). */
private fun correctedOrientation(data: Pair<Float, Float>): Pair<Float, Float> {
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
val azimuth = ((data.first + magDeclination + offset) % 360f + 360f) % 360f
return azimuth to data.second
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
@@ -177,6 +177,7 @@ private fun SatellitesScreen(
)
}
}
// 转发器/活动筛选已在"卫星类型"对话框前三个选项中体现(AMSAT Live FM/Linear, Live SSTV).
ElevatedCard(modifier = Modifier.fillMaxSize()) {
val emptyMessage = stringResource(R.string.sat_empty_list_message)
when {
@@ -87,6 +87,7 @@ import androidx.compose.ui.zIndex
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -38,8 +38,10 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -56,6 +58,8 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -66,8 +70,11 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
@@ -203,6 +210,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.compassCalibration) {
LaunchedEffect(Unit) { onAction(SettingsAction.StartCompassCalibration) }
CompassCalibrationDialog(
accuracy = uiState.compassAccuracy,
headingDegrees = uiState.compassHeadingDegrees,
initialOffsetDegrees = uiState.otherSettings.compassOffsetDegrees,
onDismiss = {
onAction(SettingsAction.StopCompassCalibration)
dialogs.compassCalibration = false
},
onSave = { onAction(SettingsAction.SetCompassOffset(it)) }
)
}
if (dialogs.lotw) {
// LoTW sync failures are typed codes from the ViewModel; render them
// through string resources so the dialog follows the system language.
@@ -337,10 +357,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
LoTWCard(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
showLoTWDialog = { dialogs.lotw = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item {
OtherCard(
settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true },
onAction = onAction
)
}
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
@@ -528,15 +555,19 @@ private fun OtherCardPreview() = MainTheme {
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
OtherCard(settings = values) {}
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
}
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
private fun OtherCard(
settings: OtherSettings,
onCompassCalibration: () -> Unit,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(272.dp)
.height(370.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -549,12 +580,20 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
onAction(SettingsAction.ToggleSensor(it))
}
CardButton(
onClick = onCompassCalibration,
text = stringResource(R.string.prefs_compass_calibration_button),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
@@ -562,6 +601,94 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
}
}
/**
* 指南针校准对话框: 8 字动作提升精度, 实时显示校正后航向, 可手动输入偏置(±180°).
*/
@Composable
private fun CompassCalibrationDialog(
accuracy: CompassAccuracy,
headingDegrees: Float,
initialOffsetDegrees: Float,
onDismiss: () -> Unit,
onSave: (Float) -> Unit
) {
var offsetText by rememberSaveable(initialOffsetDegrees) {
mutableStateOf(initialOffsetDegrees.toString())
}
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
val statusRes = when (accuracy) {
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
}
val progress = when (accuracy) {
CompassAccuracy.HIGH -> 1f
CompassAccuracy.MEDIUM -> 0.66f
CompassAccuracy.LOW -> 0.33f
CompassAccuracy.UNRELIABLE, CompassAccuracy.UNAVAILABLE -> 0f
}
SharedDialog(
title = stringResource(R.string.prefs_compass_calibration_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = {
parsedOffset?.let {
onSave(it)
onDismiss()
}
}
) { padding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = "∞",
fontSize = 92.sp,
color = MaterialTheme.colorScheme.primary,
lineHeight = 92.sp
)
Text(
text = stringResource(R.string.prefs_compass_calibration_instruction),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.prefs_compass_accuracy, stringResource(statusRes)),
fontWeight = FontWeight.Medium,
color = if (accuracy == CompassAccuracy.HIGH) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
text = stringResource(R.string.prefs_compass_heading, headingDegrees),
fontSize = 20.sp,
fontWeight = FontWeight.Medium
)
OutlinedTextField(
value = offsetText,
onValueChange = { offsetText = it },
label = { Text(stringResource(R.string.prefs_compass_offset)) },
supportingText = { Text(stringResource(R.string.prefs_compass_offset_support)) },
isError = parsedOffset == null,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
CardButton(
onClick = { offsetText = "0" },
text = stringResource(R.string.prefs_compass_offset_reset),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
@@ -578,6 +705,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
lastSyncEpochMs: Long,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -596,6 +724,14 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
// Last successful sync, shown exactly like the ephemeris update time.
if (lastSyncEpochMs != 0L) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatUpdateTime(updateTime = lastSyncEpochMs),
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showLoTWDialog,
@@ -727,6 +863,7 @@ private class DialogVisibility {
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
var compassCalibration by mutableStateOf(false)
}
@Composable
@@ -734,13 +871,14 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
compassCalibration = it.getOrElse(8) { false }
}
}
)
@@ -24,6 +24,8 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import java.io.File
data class PositionSettings(
@@ -64,12 +66,15 @@ data class SettingsState(
val lotwSyncMode: LoTWSyncMode? = null,
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
val lotwError: LoTWError? = null,
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
val lotwLastSyncEpochMs: Long = 0L,
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
val compassHeadingDegrees: Float = 0f,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
/** What a LoTW sync button does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
sealed interface LoTWError {
data object NotConfigured : LoTWError
@@ -95,8 +100,12 @@ sealed interface SettingsAction {
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data object StartCompassCalibration : SettingsAction
data object StopCompassCalibration : SettingsAction
data class SetCompassOffset(val degrees: Float) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
@@ -25,8 +25,15 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
@@ -35,7 +42,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class SettingsViewModel(
@@ -44,6 +50,7 @@ class SettingsViewModel(
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val sensorsRepo: ISensorsRepo,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -52,6 +59,8 @@ class SettingsViewModel(
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
private var lotwSyncJob: kotlinx.coroutines.Job? = null
/** 指南针校准: 传感器原始方位(未加磁偏角/偏置), 校准对话框实时显示校正后航向. */
private var rawCompassHeading = sensorsRepo.sensorData.value.first
private val _uiState = MutableStateFlow(
SettingsState(
appVersionName = settingsRepo.appVersionName,
@@ -63,7 +72,10 @@ class SettingsViewModel(
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
)
@@ -73,7 +85,10 @@ class SettingsViewModel(
viewModelScope.launch {
settingsRepo.stationPosition.collect { geoPos ->
_uiState.update {
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
it.copy(
positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos),
compassHeadingDegrees = correctedCompassHeading()
)
}
}
}
@@ -98,7 +113,20 @@ class SettingsViewModel(
}
viewModelScope.launch {
settingsRepo.otherSettings.collect { settings ->
_uiState.update { it.copy(otherSettings = settings) }
_uiState.update {
it.copy(otherSettings = settings, compassHeadingDegrees = correctedCompassHeading())
}
}
}
viewModelScope.launch {
sensorsRepo.compassAccuracy.collect { accuracy ->
_uiState.update { it.copy(compassAccuracy = accuracy) }
}
}
viewModelScope.launch {
sensorsRepo.sensorData.collect { orientation ->
rawCompassHeading = orientation.first
_uiState.update { it.copy(compassHeadingDegrees = correctedCompassHeading()) }
}
}
viewModelScope.launch {
@@ -148,8 +176,14 @@ class SettingsViewModel(
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
SettingsAction.StartCompassCalibration -> sensorsRepo.enableSensor()
SettingsAction.StopCompassCalibration -> sensorsRepo.disableSensor()
is SettingsAction.SetCompassOffset -> settingsRepo.updateOtherSettings {
it.copy(compassOffsetDegrees = action.degrees.coerceIn(-180f, 180f))
}
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
// Remote control & data sources
@@ -209,12 +243,9 @@ class SettingsViewModel(
// a first sync, an unknown/empty record or a callsign change must fall
// back to a full report so no stale grids from another account linger.
val callsign = settings.callsign.trim().uppercase()
val lastCallsign = settingsRepo.getLastLotwSyncCallsign()
val effectiveMode = if (mode == LoTWSyncMode.Incremental &&
lastCallsign.isNotBlank() && lastCallsign == callsign
) LoTWSyncMode.Incremental else LoTWSyncMode.Full
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
settingsRepo.getLastLotwSyncDate()
lotwCursorApi(settingsRepo.getLastLotwSyncDate())
} else ""
_uiState.update {
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
@@ -224,67 +255,41 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwProgress = progress) }
}
when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
is LoTWResult.Success -> {
// Incremental: merge new grids/QSOs into the stored set (dedup
// by call + QSO time); full: the fresh report replaces it all.
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (effectiveMode == LoTWSyncMode.Incremental) {
existingGrids + result.grids
} else result.grids
val mergedQsos = if (effectiveMode == LoTWSyncMode.Incremental) {
mergeGridQsos(existingQsos, result.qsos)
} else result.qsos
val mergedRoamed = if (effectiveMode == LoTWSyncMode.Incremental) {
existingRoamed + result.roamedGrids
} else result.roamedGrids
// Remember when/what we synced, so the next incremental pull
// asks LoTW for only the confirmations since today.
val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US)
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
.format(java.util.Date())
settingsRepo.setLastLotwSyncDate(today)
settingsRepo.setLastLotwSyncCallsign(callsign)
// Persist on the IO dispatcher: building the per-grid QSO JSON
// and writing SharedPreferences synchronously blocks the
// calling thread, and for large accounts (tens of thousands
// of QSOs → multi-MB JSON) that froze the main thread after
// the download finished — 'bar done but app stuck'.
withContext(kotlinx.coroutines.Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
// Shared with the automatic sync on app start (MainApplication).
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
workedGridsCount = mergedGrids.size, lotwError = null
workedGridsCount = mergedGridsCount, lotwError = null,
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
is LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.BadCredentials
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
is LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.RateLimited
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
is LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Timeout
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
is LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
@@ -302,22 +307,6 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
private fun mergeGridQsos(
existing: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
fresh: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
fresh.forEach { (grid, list) ->
val target = merged.getOrPut(grid) { mutableListOf() }
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
list.forEach { qso ->
if (known.add(qso.call to qso.epochMs)) target.add(qso)
}
}
return merged
}
// endregion
// region Update checker
@@ -396,6 +385,18 @@ class SettingsViewModel(
}
}
/** 校正后航向 = 传感器原始方位 + 磁偏角 + 手动偏置. */
private fun correctedCompassHeading(): Float {
val declination = sensorsRepo.getMagDeclination(settingsRepo.stationPosition.value)
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
return ((rawCompassHeading + declination + offset) % 360f + 360f) % 360f
}
override fun onCleared() {
sensorsRepo.disableSensor()
super.onCleared()
}
// endregion
// region Data update helpers — consolidated from 3 near-identical functions
@@ -443,6 +444,7 @@ class SettingsViewModel(
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
sensorsRepo = container.provideSensorsRepo(),
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
@@ -338,15 +338,16 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
/**
* Day block: colored by the newest reported status among the day's 12 slots
* (gray when none); the number is the TOTAL report count of the whole day
* (sum over all slots), not the count of the newest slot alone.
* (gray when none); the number is the count of the most recent consecutive
* reports sharing that same status (day.streakCount), not the total report
* count of the whole day.
*/
@Composable
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
val noReportGray = 0xFFC0C0C0L
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
val color = Color(slot.statusColor)
val totalCount = day.slots.sumOf { it.count }
val streakCount = day.streakCount
Box(
modifier = modifier
.height(28.dp)
@@ -355,8 +356,8 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (totalCount > 0) {
Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (streakCount > 0) {
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
+9 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "476"
appVersionCode = "497"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.6"
appVersionName = "4.4.7-ba7opf.15"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -33,6 +33,8 @@ other-okhttp = "5.4.0"
other-osmdroid = "6.1.20"
test-junit4 = "4.13.2"
robolectric = "4.16"
androidx-test-core = "1.7.0"
androidTest-junit = "1.3.0"
androidTest-espresso = "3.7.0"
@@ -79,6 +81,11 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
# Real org.json for unit tests (android.jar stub throws "not mocked")
test-json = { module = "org.json:json", version = "20240303" }
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }