diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java index 07c55cd3..520792f0 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java @@ -218,7 +218,7 @@ public static Context getMainContext() { public static String qrzApiKey = ""; //QRZ API key public static String qrzXmlUsername = ""; //QRZ XML API username (for callsign lookups) public static String qrzXmlPassword = ""; //QRZ XML API password - public static boolean pskOverlayEnabled = false; //PSK Reporter map overlay (issue #33) + public static boolean pskOverlayEnabled = true; //PSK Reporter map overlay (issue #33) public static boolean synFrequency = false;//Same-frequency transmit public static int transmitDelay = 500;//Transmit delay; also allows decoding time for the previous cycle public static int pttDelay = 100;//PTT response time; radios typically need some response time after PTT command, default 100ms diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt index b2db1e10..9d14786d 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt @@ -71,7 +71,11 @@ object PskReporterClient { @Volatile private var rateLimitedUntilEpochMs: Long = 0L - suspend fun fetchSpotsForMe(call: String, secondsBack: Int): List? = withContext(Dispatchers.IO) { + suspend fun fetchSpotsForMe( + call: String, + secondsBack: Int, + modeFilter: String? = "FT8", + ): List? = withContext(Dispatchers.IO) { val now = clock() if (now < rateLimitedUntilEpochMs) { log("skipped (rate-limit back-off ${(rateLimitedUntilEpochMs - now) / 1000}s remaining)") @@ -84,12 +88,13 @@ object PskReporterClient { lastFetchEpochMs = now val callUpper = call.uppercase() + val modeParam = modeFilter?.trim()?.takeIf { it.isNotEmpty() }?.uppercase() val url = "$baseUrl?senderCallsign=${urlEncode(callUpper)}" + "&flowStartSeconds=-$secondsBack" + "&rronly=1" + - "&mode=FT8" + + (modeParam?.let { "&mode=${urlEncode(it)}" } ?: "") + "&appcontact=${urlEncode(APP_CONTACT)}" - log("fetch start call=$callUpper secondsBack=$secondsBack") + log("fetch start call=$callUpper secondsBack=$secondsBack mode=${modeParam ?: "ALL"}") val body = fetch(url) ?: return@withContext null val spots = parseSpots(body) diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreen.kt index d81c34d6..2c502e35 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreen.kt @@ -56,10 +56,12 @@ import com.bg7yoz.ft8cn.Ft8Message import com.bg7yoz.ft8cn.GeneralVariables import com.bg7yoz.ft8cn.MainViewModel import com.bg7yoz.ft8cn.maidenhead.MaidenheadGrid +import com.bg7yoz.ft8cn.rigs.BaseRigOperation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import radio.ks3ckc.ft8us.pskreporter.PskReporterClient +import radio.ks3ckc.ft8us.pskreporter.PskReporterSpot import radio.ks3ckc.ft8us.theme.* import radio.ks3ckc.ft8us.ui.components.GlassCard import radio.ks3ckc.ft8us.ui.components.TopBar @@ -97,9 +99,39 @@ private data class PskSpotMarker( val frequencyHz: Long, ) -private const val PSK_OVERLAY_SECONDS_BACK = 3600 +internal enum class PskBandFilter(val label: String) { ALL("Band: ALL"), CURRENT("Band: CUR") } +internal enum class PskModeFilter(val label: String, val apiValue: String?) { + ALL("Mode: ALL", null), + FT8("Mode: FT8", "FT8"), + FT4("Mode: FT4", "FT4"), +} +internal enum class PskTimeFilter(val label: String, val secondsBack: Int) { + M15("Time: 15m", 15 * 60), + H1("Time: 1h", 60 * 60), + H6("Time: 6h", 6 * 60 * 60), + H24("Time: 24h", 24 * 60 * 60), +} private const val PSK_POLL_INTERVAL_MS = 5L * 60L * 1000L +internal fun filterPskSpots( + spots: List, + bandFilter: PskBandFilter, + modeFilter: PskModeFilter, + currentBandHz: Long, + maxAgeSeconds: Int, + nowEpochSeconds: Long, +): List { + val currentBand = BaseRigOperation.getMeterFromFreq(currentBandHz) + return spots.filter { spot -> + val ageSeconds = nowEpochSeconds - spot.flowStartSeconds + val withinTime = ageSeconds in 0..maxAgeSeconds.toLong() + val modeMatches = modeFilter.apiValue == null || spot.mode.equals(modeFilter.apiValue, ignoreCase = true) + val bandMatches = bandFilter != PskBandFilter.CURRENT || + BaseRigOperation.getMeterFromFreq(spot.frequencyHz) == currentBand + withinTime && modeMatches && bandMatches + } +} + private data class ProjectedPoint( val x: Float, val y: Float, @@ -179,6 +211,9 @@ fun MapScreen(mainViewModel: MainViewModel) { var selectedCallsign by remember { mutableStateOf(null) } var viewMode by rememberSaveable { mutableStateOf(MapViewMode.STANDARD) } var pskOverlayEnabled by rememberSaveable { mutableStateOf(GeneralVariables.pskOverlayEnabled) } + var pskBandFilter by rememberSaveable { mutableStateOf(PskBandFilter.ALL) } + var pskModeFilter by rememberSaveable { mutableStateOf(PskModeFilter.FT8) } + var pskTimeFilter by rememberSaveable { mutableStateOf(PskTimeFilter.H1) } var pskSpots by remember { mutableStateOf>(emptyList()) } // Zoom + pan (issue #51). Scale is clamped to [1, MAX_ZOOM]; pan is clamped so the @@ -197,7 +232,7 @@ fun MapScreen(mainViewModel: MainViewModel) { // PSK Reporter polling — fires immediately on enter and every 5 min while enabled. // Re-reads myCallsign each cycle so a mid-session change is picked up on the next tick. // Structured concurrency cancels the loop when MapScreen leaves composition. - LaunchedEffect(pskOverlayEnabled) { + LaunchedEffect(pskOverlayEnabled, pskBandFilter, pskModeFilter, pskTimeFilter, GeneralVariables.band) { if (!pskOverlayEnabled) { pskSpots = emptyList() return@LaunchedEffect @@ -207,9 +242,21 @@ fun MapScreen(mainViewModel: MainViewModel) { if (call.isEmpty()) { pskSpots = emptyList() } else { - val spots = PskReporterClient.fetchSpotsForMe(call, PSK_OVERLAY_SECONDS_BACK) + val spots = PskReporterClient.fetchSpotsForMe( + call = call, + secondsBack = pskTimeFilter.secondsBack, + modeFilter = pskModeFilter.apiValue, + ) if (spots != null) { - pskSpots = spots.map { + val filtered = filterPskSpots( + spots = spots, + bandFilter = pskBandFilter, + modeFilter = pskModeFilter, + currentBandHz = GeneralVariables.band, + maxAgeSeconds = pskTimeFilter.secondsBack, + nowEpochSeconds = System.currentTimeMillis() / 1000L, + ) + pskSpots = filtered.map { PskSpotMarker( receiverCallsign = it.receiverCallsign, grid = it.receiverGrid, @@ -317,6 +364,49 @@ fun MapScreen(mainViewModel: MainViewModel) { ) }, ) + if (pskOverlayEnabled) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + TogglePill( + label = pskBandFilter.label, + active = true, + onClick = { + pskBandFilter = if (pskBandFilter == PskBandFilter.ALL) { + PskBandFilter.CURRENT + } else { + PskBandFilter.ALL + } + }, + ) + TogglePill( + label = pskModeFilter.label, + active = true, + onClick = { + pskModeFilter = when (pskModeFilter) { + PskModeFilter.ALL -> PskModeFilter.FT8 + PskModeFilter.FT8 -> PskModeFilter.FT4 + PskModeFilter.FT4 -> PskModeFilter.ALL + } + }, + ) + TogglePill( + label = pskTimeFilter.label, + active = true, + onClick = { + pskTimeFilter = when (pskTimeFilter) { + PskTimeFilter.M15 -> PskTimeFilter.H1 + PskTimeFilter.H1 -> PskTimeFilter.H6 + PskTimeFilter.H6 -> PskTimeFilter.H24 + PskTimeFilter.H24 -> PskTimeFilter.M15 + } + }, + ) + } + } // Map canvas — pinch to zoom, drag to pan, double-tap to toggle 2× zoom in/out. // The STD/AZ mode toggle moved to the TopBar pill since drag now means pan. diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt index e61e269f..e42e3948 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt @@ -111,6 +111,7 @@ fun SettingsScreen( var autoUpdateGridFromGPS by remember { mutableStateOf(GeneralVariables.autoUpdateGridFromGPS) } var enableCloudlog by remember { mutableStateOf(GeneralVariables.enableCloudlog) } var enableQRZ by remember { mutableStateOf(GeneralVariables.enableQRZ) } + var pskReporterEnabled by remember { mutableStateOf(GeneralVariables.pskOverlayEnabled) } var saveSWLMessage by remember { mutableStateOf(GeneralVariables.saveSWLMessage) } var saveSWL_QSO by remember { mutableStateOf(GeneralVariables.saveSWL_QSO) } @@ -984,6 +985,19 @@ fun SettingsScreen( }, ) SectionDivider() + SettingsRow( + label = "PSK Reporter Spots", + description = "Show stations hearing your signal on the map", + toggle = pskReporterEnabled, + onToggleChange = { checked -> + pskReporterEnabled = checked + GeneralVariables.pskOverlayEnabled = checked + mainViewModel.databaseOpr.writeConfig( + "pskOverlayEnabled", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() SettingsRow( label = "QRZ.com", description = "Auto-upload QSOs to QRZ Logbook", diff --git a/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClientTest.kt b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClientTest.kt index bb93bf77..f4357a53 100644 --- a/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClientTest.kt +++ b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClientTest.kt @@ -147,6 +147,26 @@ class PskReporterClientTest { assertThat(req.path).contains("senderCallsign=W1AW") } + @Test + fun fetchSpots_includesModeFilterWhenSpecified() = runBlocking { + server.enqueue(MockResponse().setBody(fixture("pskreporter/reception-report.xml"))) + + PskReporterClient.fetchSpotsForMe("W1AW", 900, modeFilter = "ft4") + + val req = server.takeRequest() + assertThat(req.path).contains("mode=FT4") + } + + @Test + fun fetchSpots_omitsModeFilterWhenAllModesRequested() = runBlocking { + server.enqueue(MockResponse().setBody(fixture("pskreporter/reception-report.xml"))) + + PskReporterClient.fetchSpotsForMe("W1AW", 900, modeFilter = null) + + val req = server.takeRequest() + assertThat(req.path).doesNotContain("mode=") + } + private fun fixture(path: String): String = checkNotNull(javaClass.classLoader?.getResourceAsStream(path)) { "missing test resource: $path" diff --git a/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreenPskFilterTest.kt b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreenPskFilterTest.kt new file mode 100644 index 00000000..ed7d668a --- /dev/null +++ b/ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/map/MapScreenPskFilterTest.kt @@ -0,0 +1,64 @@ +package radio.ks3ckc.ft8us.ui.map + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8us.pskreporter.PskReporterSpot + +class MapScreenPskFilterTest { + + @Test + fun filterPskSpots_filtersByCurrentBandModeAndTime() { + val now = 1_700_000_000L + val spots = listOf( + spot(freq = 14_076_000L, mode = "FT8", flow = now - 120), + spot(freq = 7_074_000L, mode = "FT8", flow = now - 120), + spot(freq = 14_076_000L, mode = "FT4", flow = now - 120), + spot(freq = 14_076_000L, mode = "FT8", flow = now - 8_000), + ) + + val filtered = filterPskSpots( + spots = spots, + bandFilter = PskBandFilter.CURRENT, + modeFilter = PskModeFilter.FT8, + currentBandHz = 14_074_000L, + maxAgeSeconds = 3600, + nowEpochSeconds = now, + ) + + assertThat(filtered).hasSize(1) + assertThat(filtered.single().frequencyHz).isEqualTo(14_076_000L) + assertThat(filtered.single().mode).isEqualTo("FT8") + } + + @Test + fun filterPskSpots_allBandAndAllMode_keepsMultipleBands() { + val now = 1_700_000_000L + val spots = listOf( + spot(freq = 14_076_000L, mode = "FT8", flow = now - 10), + spot(freq = 7_074_000L, mode = "FT4", flow = now - 10), + ) + + val filtered = filterPskSpots( + spots = spots, + bandFilter = PskBandFilter.ALL, + modeFilter = PskModeFilter.ALL, + currentBandHz = 14_074_000L, + maxAgeSeconds = 3600, + nowEpochSeconds = now, + ) + + assertThat(filtered).hasSize(2) + } + + private fun spot(freq: Long, mode: String, flow: Long): PskReporterSpot = PskReporterSpot( + senderCallsign = "W1AW", + receiverCallsign = "RX", + receiverGrid = "FN42", + receiverLat = 42.0, + receiverLon = -71.0, + frequencyHz = freq, + snr = -10, + mode = mode, + flowStartSeconds = flow, + ) +}