diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt
index 2408a83f..18e6dfb1 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt
@@ -16,15 +16,21 @@ class UpdateRepository(
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
// failures. The web endpoint redirects to the latest tag with no such limit.
- val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
- val stream = result.stream ?: return@withContext null
- try {
- val html = stream.bufferedReader().use { it.readText() }
- parseRelease(html)
- } catch (e: Exception) {
- println("UpdateRepository parse failure: $e")
- null
+ // When GitHub is unreachable (common on mainland-China networks without a
+ // proxy), fall back to community GitHub accelerator mirrors that proxy the
+ // same page; each mirror resolves the identical tag and asset URLs.
+ for (baseUrl in LATEST_RELEASE_URLS) {
+ val result = remoteSource.getNetworkStream(baseUrl)
+ val stream = result.stream ?: continue
+ val parsed = try {
+ parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
+ } catch (e: Exception) {
+ println("UpdateRepository parse failure: $e")
+ null
+ }
+ if (parsed != null) return@withContext parsed
}
+ null
}
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
@@ -40,7 +46,7 @@ class UpdateRepository(
}
}
- private fun parseRelease(html: String): LatestRelease? {
+ private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
// The redirect target is the latest release's tag page. Extract the tag
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
// unambiguous. The
also holds the release name, but that is free
@@ -53,8 +59,16 @@ class UpdateRepository(
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// The release APK asset follows the fixed naming scheme used by the build:
- // Look4Sat--release.apk
- val apkUrl = "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
+ // Look4Sat--release.apk. When the page was fetched
+ // through an accelerator mirror (https:///https://github.com/...),
+ // download through the same mirror — raw github.com is unreachable on the
+ // networks that needed the mirror in the first place.
+ val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
+ val apkUrl = if (mirrorPrefix.isEmpty()) {
+ "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
+ } else {
+ "${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
+ }
return LatestRelease(
versionTag = tag,
title = title,
@@ -65,8 +79,18 @@ class UpdateRepository(
private companion object {
// Web pages are used instead of api.github.com to avoid the 60 req/hour
- // anonymous rate limit (see getLatestRelease above).
- const val LATEST_RELEASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/latest"
+ // anonymous rate limit (see getLatestRelease above). The GitHub URL is
+ // tried first; if it is unreachable (no proxy on mainland networks) the
+ // accelerator mirrors are tried in order. Download URLs always point at
+ // the mirrors too — raw github.com release downloads are equally blocked
+ // without a proxy, so a mirror-resolved tag must be downloaded via the
+ // same mirror.
+ val LATEST_RELEASE_URLS = listOf(
+ "https://github.com/atsunatsu/Look4Sat/releases/latest",
+ "https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
+ "https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
+ "https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
+ )
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
}
}
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 0018d5c4..63554612 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -145,15 +145,16 @@
未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格
Wavelog 地址
API key :日志 ID
- 已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。
+ 已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。
立即同步
同步中…
LoTW 已确认网格
LoTW 呼号
LoTW 密码
- 直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。请先保存,再点同步。
+ 直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。填好直接点同步,会自动保存并拉取。
立即同步
同步中…
+ 已从 LoTW 同步 — 共 %1$d 个网格
未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器
使用梅登黑德网格设置站点位置
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index b3e56cbf..2f08f8f8 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -180,15 +180,16 @@
Not configured — connect your Wavelog logbook to highlight worked grids on the map
Wavelog URL
API key : logbook ID
- Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync.
+ Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.
Sync now
Syncing…
LoTW confirmed grids
LoTW callsign
LoTW password
- Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Save first, then tap Sync.
+ Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Just tap Sync — credentials are saved automatically.
Sync now
Syncing…
+ Synced from LoTW — %1$d grids total
Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed
Set station\'s position using locator
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt
index 89f05070..d0616d61 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt
@@ -124,8 +124,8 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
}
- // Labels: place at the top-left corner of each cell, only when the cell
- // is large enough on screen to hold a label (avoid clutter at low zoom)
+ // Labels: centered in each cell, only when the cell is large enough on
+ // screen to hold a label (avoid clutter at low zoom)
val showLabels = when {
zoom >= GRID_ZOOM_SUB -> true
zoom >= FIELD_ZOOM_LABELS -> true
@@ -140,20 +140,25 @@ class MaidenheadGridOverlay : Overlay() {
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
- val labelOffset = 8f
+ labelPaint.textAlign = Paint.Align.CENTER
+ val fontMetrics = labelPaint.fontMetrics
+ val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
val topLatCell = lat + cellLat
if (topLatCell > 90.0) continue
- val y = projectionToY(projection, topLatCell) ?: continue
- if (y < -labelPaint.textSize || y > canvas.height) 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
for (col in firstCol..lastCol) {
val lon = col * cellLon
- val x = projectionToX(projection, lon, centerLon) ?: continue
- if (x < -200f || x > canvas.width) continue
+ val xLeft = projectionToX(projection, lon, centerLon) ?: continue
+ val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
+ if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
- canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
+ canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
index 0fb51824..18e0328f 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
@@ -42,7 +42,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -159,8 +161,15 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
+ // Track grid-mode transitions so the map only re-centers when the
+ // mode is switched ON, not on every recomposition.
+ var prevGridMode by remember { mutableStateOf(uiState.isGridMode) }
AndroidView({ mapView }) { view ->
- setGridMode(uiState.isGridMode, uiState.workedGrids, view)
+ setGridMode(
+ uiState.isGridMode, uiState.workedGrids, view,
+ if (uiState.isGridMode && !prevGridMode) uiState.stationPosition else null
+ )
+ prevGridMode = uiState.isGridMode
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
@@ -269,7 +278,12 @@ private fun MapDataCards(data: MapData) {
// region Map overlay helpers
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
-private fun setGridMode(gridMode: Boolean, workedGrids: Set, mapView: MapView) {
+private fun setGridMode(
+ gridMode: Boolean,
+ workedGrids: Set,
+ mapView: MapView,
+ stationPosition: GeoPos?
+) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
if (gridOverlay is MaidenheadGridOverlay) {
@@ -286,6 +300,18 @@ private fun setGridMode(gridMode: Boolean, workedGrids: Set, mapView: Ma
for (index in OVERLAY_STATION..OVERLAY_MOON) {
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
}
+ // Entering grid mode: center on the station's current grid square,
+ // keeping the current zoom level unchanged.
+ if (gridMode) {
+ val pos = stationPosition ?: return
+ val lat = pos.latitude
+ val lon = pos.longitude
+ val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
+ val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
+ val centerLat = fieldLat * 10.0 + 5.0
+ val centerLon = fieldLon * 20.0 + 10.0
+ mapView.controller.setCenter(GeoPoint(centerLat, centerLon))
+ }
} catch (e: Exception) {
println(e)
}
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
index dba1dd67..6532b36e 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
@@ -84,6 +84,7 @@ class MapViewModel(
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
+ getStationPosition()
}
fun onAction(action: MapAction) {
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
index 9282e763..abc1abba 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
@@ -164,7 +164,7 @@ fun WavelogDialog(
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
- onSync: () -> Unit
+ onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
@@ -205,12 +205,16 @@ fun WavelogDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
- // Sync button row (inside dialog content so token must be saved first)
+ // Sync uses the values typed in the fields directly — saving and syncing
+ // happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
- TextButton(onClick = onSync, enabled = !isSyncing) {
+ TextButton(
+ onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
+ enabled = !isSyncing
+ ) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
@@ -227,7 +231,7 @@ fun LoTWDialog(
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
- onSync: () -> Unit
+ onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
) {
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
@@ -269,11 +273,16 @@ fun LoTWDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
+ // Sync uses the values typed in the fields directly — saving and syncing
+ // happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
- TextButton(onClick = onSync, enabled = !isSyncing) {
+ TextButton(
+ onClick = { onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
+ enabled = !isSyncing
+ ) {
Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing)
else stringResource(R.string.prefs_lotw_sync))
}
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index e7d44fc4..7f3be694 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -211,7 +211,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
- onSync = { onAction(SettingsAction.SyncWorkedGrids) }
+ onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) }
)
}
@@ -223,7 +223,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
message = uiState.lotwMessage,
dismiss = { dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
- onSync = { onAction(SettingsAction.SyncLoTWGrids) }
+ onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
)
}
@@ -634,7 +634,7 @@ private fun LoTWCard(
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
- stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
+ stringResource(R.string.prefs_lotw_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_lotw_not_configured)
},
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
index bd9627ca..7f680274 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
@@ -96,11 +96,11 @@ sealed interface SettingsAction {
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
- data object SyncWorkedGrids : SettingsAction
+ data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
- data object SyncLoTWGrids : SettingsAction
+ data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
index 23c856e7..619172c7 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
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.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
@@ -155,10 +156,10 @@ class SettingsViewModel(
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
- SettingsAction.SyncWorkedGrids -> syncWorkedGrids()
+ is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
- SettingsAction.SyncLoTWGrids -> syncLoTWGrids()
+ is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -170,12 +171,13 @@ class SettingsViewModel(
// region Wavelog worked grids
- private fun syncWorkedGrids() {
- val settings = _uiState.value.wavelogSettings
+ private fun syncWorkedGrids(settings: WavelogSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
return
}
+ // Persist the credentials first, then sync with the freshly-typed values.
+ settingsRepo.updateWavelogSettings(settings)
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
@@ -190,12 +192,13 @@ class SettingsViewModel(
}
}
- private fun syncLoTWGrids() {
- val settings = _uiState.value.lotwSettings
+ private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
return
}
+ // Persist the credentials first, then sync with the freshly-typed values.
+ settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch {
val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password)