mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 19:47:22 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6277e36a96 | ||
|
|
08a5c8c460 | ||
|
|
1ffb5ed9e8 | ||
|
|
cc684b6e3a | ||
|
|
6cb5b062d3 |
No files matched your search
+36
-22
@@ -264,9 +264,6 @@ class LoTWRepository : ILoTWRepository {
|
||||
}
|
||||
|
||||
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
||||
// LoTW answers with ADIF text; on bad credentials it returns a short error page
|
||||
// containing "password=?" or an <eoh>-less block. Treat anything without a header
|
||||
// marker as failure so the caller can show a sensible message.
|
||||
// LoTW answers with ADIF text; on bad credentials it returns a short error
|
||||
// page without an <eoh> header terminator. Real reports always carry <eoh>
|
||||
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
||||
@@ -277,25 +274,34 @@ class LoTWRepository : ILoTWRepository {
|
||||
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
||||
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
||||
// in terrestrial contacts.
|
||||
//
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
|
||||
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
|
||||
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
|
||||
// sequential gate ("only add while propMode == SAT") silently drops every
|
||||
// grid on real reports. Buffer the record and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var pendingGrids = mutableListOf<String>()
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> propMode = null
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT") grids.addAll(pendingGrids)
|
||||
propMode = null
|
||||
pendingGrids = mutableListOf()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
if (propMode == "SAT") {
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||
// split on ',' and take the 4-char field of each, or the
|
||||
// second grid is silently dropped.
|
||||
val value = line.substringAfter('>').substringBefore("E<")
|
||||
value.split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) grids.add(field.take(4))
|
||||
}
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||
// split on ',' and take the 4-char field of each, or the
|
||||
// second grid is silently dropped.
|
||||
val value = adifValue(line)
|
||||
value.split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) pendingGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,22 +320,30 @@ class LoTWRepository : ILoTWRepository {
|
||||
internal fun parseRoamedGrids(body: String): Set<String>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
val grids = mutableSetOf<String>()
|
||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
|
||||
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
|
||||
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
|
||||
// within a record. A sequential gate ("only add while propMode == SAT")
|
||||
// would silently drop every own grid on real reports; buffer the record
|
||||
// and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var myGrid: String? = null
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> propMode = null
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
|
||||
propMode = null
|
||||
myGrid = null
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
|
||||
// opposite station's grid) — only own-station grids count.
|
||||
if (propMode == "SAT") {
|
||||
val value = line.substringAfter('>').substringBefore("E<")
|
||||
val field = value.trim().uppercase()
|
||||
if (field.length >= 4) grids.add(field.take(4))
|
||||
}
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -61,6 +61,28 @@ class LoTWRepositoryTest {
|
||||
assertEquals(setOf("OL62"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
|
||||
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
|
||||
// sequential gate (add only while propMode == SAT) dropped every grid on
|
||||
// real reports — the buffered parser must collect them regardless of
|
||||
// field order and filter at <EOR>.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<MODE:3>FM\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReturnsEmptySetForReportWithoutGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
||||
@@ -163,6 +185,21 @@ class LoTWRepositoryTest {
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
|
||||
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
|
||||
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
|
||||
// while propMode == SAT) silently returned an EMPTY set on real reports —
|
||||
// this is the exact bug that made the blue roamed stripes never appear.
|
||||
val qso = "<CALL:6>BA7OPF\n" +
|
||||
"<GRIDSQUARE:4>PM95\n" +
|
||||
"<MY_GRIDSQUARE:6>OL72XX\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>IO-86\n" +
|
||||
"<EOR>\n"
|
||||
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
|
||||
+61
-24
@@ -67,7 +67,8 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 6f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 90, 200, 255)
|
||||
// 与普通网格线同色(淡黄), 仅加粗 — 用户要求当前网格框线不换色
|
||||
color = Color.argb(160, 255, 224, 130)
|
||||
}
|
||||
private val selectedLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 4.5f
|
||||
@@ -134,11 +135,13 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
|
||||
val colRepeats = if (worldTurns > 0) worldTurns else 0
|
||||
|
||||
// The station's own grid: 4-char square at sub-square zoom, 2-char
|
||||
// field at field zoom. Always marked so the "you are here" outline is
|
||||
// visible even at the default entry zoom (below GRID_ZOOM_SUB).
|
||||
val ownCell = ownGrid?.let { if (zoom >= GRID_ZOOM_SUB) it else it.take(2) }
|
||||
// The tapped worked grid gets a distinct outline (same zoom gate).
|
||||
// The station's own grid: always keep a bold outline of the 4-char
|
||||
// square. At sub-square zoom it matches the visible cell grid; at field
|
||||
// zoom (2-char labels) the own 2°x1° square is drawn on top of the
|
||||
// field grid so the operator still sees exactly where they are (user
|
||||
// req: "field zoom must also draw the own 4-char grid, bolded").
|
||||
val ownCell = ownGrid
|
||||
// The tapped worked grid gets a distinct outline (sub-square zoom only).
|
||||
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
|
||||
// Worked-grid highlight fills.
|
||||
@@ -292,22 +295,50 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
// The station's own grid square: redraw its four borders thicker on top.
|
||||
// The tapped worked grid gets the same treatment in a different color.
|
||||
if (ownCell != null || selectedCell != null) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label != ownCell && label != selectedCell) continue
|
||||
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
|
||||
if (zoom >= GRID_ZOOM_SUB) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label != ownCell && label != selectedCell) continue
|
||||
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Field zoom: the visible grid shows 2-char fields only, but the
|
||||
// station's own 2°x1° square still gets its bold outline drawn
|
||||
// on top of the field grid (user req). selectedCell is null here.
|
||||
val grid = ownCell
|
||||
if (grid != null) {
|
||||
val cell = gridCellBounds(grid)
|
||||
if (cell != null) {
|
||||
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.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,8 +368,14 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
if (topLatCell > 90.0) continue
|
||||
val yTop = projectionToY(projection, topLatCell) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val yCenter = (yTop + yBottom) / 2f - textHalfHeight
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
// Polar bands (80..90 / -90..-80) extend beyond the map's latitude
|
||||
// limit, so their geometric center falls off-screen and the label
|
||||
// would never be visible. Center the label in the VISIBLE part of
|
||||
// the cell instead; fully off-screen cells are still skipped.
|
||||
val visTop = maxOf(yTop, 0f)
|
||||
val visBottom = minOf(yBottom, canvas.height.toFloat())
|
||||
if (visBottom < 0f || visTop > canvas.height) continue
|
||||
val yCenter = (visTop + visBottom) / 2f - textHalfHeight
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
|
||||
@@ -256,7 +256,8 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||
setGridMode(
|
||||
uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view,
|
||||
if (shouldCenter) uiState.stationPosition else null
|
||||
uiState.stationPosition,
|
||||
centerOnStation = shouldCenter
|
||||
)
|
||||
if (!shouldCenter || uiState.stationPosition != null) {
|
||||
prevGridMode = uiState.isGridMode
|
||||
@@ -639,7 +640,8 @@ private fun setGridMode(
|
||||
workedGrids: Set<String>,
|
||||
roamedGrids: Set<String>,
|
||||
mapView: MapView,
|
||||
stationPosition: GeoPos?
|
||||
stationPosition: GeoPos?,
|
||||
centerOnStation: Boolean = false
|
||||
) {
|
||||
try {
|
||||
val gridOverlay = mapView.overlays[OVERLAY_GRID]
|
||||
@@ -647,6 +649,9 @@ private fun setGridMode(
|
||||
gridOverlay.isEnabled = gridMode
|
||||
gridOverlay.workedGrids = workedGrids
|
||||
gridOverlay.roamedGrids = roamedGrids
|
||||
// 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).
|
||||
gridOverlay.ownGrid = ownGridOf(stationPosition)
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
|
||||
@@ -666,7 +671,7 @@ private fun setGridMode(
|
||||
// first composition this runs before MapView's first layout, and a
|
||||
// setCenter issued pre-layout is discarded when the view lays out —
|
||||
// the map then keeps its default center forever.
|
||||
if (gridMode) {
|
||||
if (gridMode && centerOnStation) {
|
||||
val pos = stationPosition ?: return
|
||||
val lat = pos.latitude
|
||||
val lon = pos.longitude
|
||||
|
||||
@@ -156,7 +156,7 @@ private fun PassesScreen(
|
||||
TopBar(
|
||||
isVerticalLayout = isVerticalLayout,
|
||||
startAction = {
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
},
|
||||
topInfo = {
|
||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||
@@ -165,19 +165,7 @@ private fun PassesScreen(
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
},
|
||||
endAction = {
|
||||
// 日程功能: 把下个过境写入系统日历 (原仓库雷达页的 addToCalendar).
|
||||
IconCard(
|
||||
action = {
|
||||
onAction(
|
||||
PassesAction.AddToCalendar(
|
||||
uiState.nextPass.name,
|
||||
uiState.nextPass.aosTime,
|
||||
uiState.nextPass.losTime
|
||||
)
|
||||
)
|
||||
},
|
||||
resId = R.drawable.ic_calendar
|
||||
)
|
||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,4 @@ sealed interface PassesAction {
|
||||
data object ToggleRadiosDialog : PassesAction
|
||||
data class FocusCatNum(val catNum: Int) : PassesAction
|
||||
data object ClearFocus : PassesAction
|
||||
/** 日程功能: 把下个过境写入系统日历 (ic_calendar 按钮). */
|
||||
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : PassesAction
|
||||
}
|
||||
+2
-7
@@ -28,7 +28,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
@@ -47,8 +46,7 @@ import java.util.TimeZone
|
||||
|
||||
class PassesViewModel(
|
||||
private val satelliteRepo: ISatelliteRepo,
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val addToCalendar: IAddToCalendar
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val defaultPass = getDefaultPass()
|
||||
@@ -153,8 +151,6 @@ class PassesViewModel(
|
||||
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
|
||||
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
|
||||
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
|
||||
is PassesAction.AddToCalendar ->
|
||||
addToCalendar(action.name, action.aosTime, action.losTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +345,7 @@ class PassesViewModel(
|
||||
initializer {
|
||||
PassesViewModel(
|
||||
satelliteRepo = container.satelliteRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
addToCalendar = container.provideAddToCalendar()
|
||||
settingsRepo = container.settingsRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "468"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.10"
|
||||
appVersionName = "4.4.6-ba7opf.10.2"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user