Compare commits

...
Author SHA1 Message Date
atsunatsu eb1a5d51a2 chore: bump version to 4.4.6-ba7opf.9 2026-09-07 02:02:36 +08:00
atsunatsu 4e3547649f feat: Maidenhead grid mode with worked-grid highlights from Wavelog (API v2 grid=all) 2026-09-07 01:42:42 +08:00
atsunatsu 7abbeb6eb3 fix: update check via GitHub web endpoint, avoiding API 60 req/hour rate limit 2026-09-06 21:33:41 +08:00
atsunatsu 5c73d5ed7a feat: default TLE source order with LAPAN-A2 (Kaggle) first, R4UAB second 2026-09-06 20:18:04 +08:00
atsunatsu a663ef7f1b build: read release signing credentials from local.properties/env instead of hardcoding
Passwords for the release keystore were committed in plaintext to a
public repo. Move them to gitignored local.properties with an
environment-variable fallback. Keystore file itself stays in $HOME.
2026-09-05 07:57:33 +08:00
26 changed files with 693 additions and 49 deletions

No files matched your search

+15 -3
View File
@@ -1,7 +1,19 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Release signing credentials live in local.properties (gitignored) or
// environment variables — never hardcode passwords in VCS.
val releaseProps = Properties().apply {
val propsFile = rootProject.file("local.properties")
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
}
fun releaseCred(name: String): String =
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
android {
namespace = libs.versions.packageName.get()
defaultConfig {
@@ -11,9 +23,9 @@ android {
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
}
}
buildTypes {
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -52,6 +53,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -81,6 +83,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -81,6 +82,7 @@ class SettingsRepo(
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -137,7 +139,48 @@ class SettingsRepo(
}
//endregion
//region # Passes filter settings
//region # Wavelog worked-grids settings
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogToken = "wavelogToken"
private val keyWorkedGrids = "workedGrids"
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
override fun updateWavelogSettings(settings: WavelogSettings) {
preferences.edit {
putString(keyWavelogUrl, settings.url.trim())
putString(keyWavelogToken, settings.token.trim())
}
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
}
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
token = preferences.getString(keyWavelogToken, null).orEmpty()
)
override fun getWorkedGrids(): Set<String> {
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setWorkedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyWorkedGrids, array.toString()) }
}
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -371,6 +414,7 @@ class SettingsRepo(
putBoolean(keyStateOfUtc, new.stateOfUtc)
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -388,6 +432,7 @@ class SettingsRepo(
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -5,7 +5,6 @@ import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
class UpdateRepository(
@@ -13,11 +12,15 @@ class UpdateRepository(
) : IUpdateRepository {
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
// Use the GitHub releases web page instead of the REST API endpoint:
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
// failures. The web endpoint redirects to the latest tag with no such limit.
val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
val stream = result.stream ?: return@withContext null
try {
val json = stream.bufferedReader().use { it.readText() }
parseRelease(json)
val html = stream.bufferedReader().use { it.readText() }
parseRelease(html)
} catch (e: Exception) {
println("UpdateRepository parse failure: $e")
null
@@ -37,31 +40,33 @@ class UpdateRepository(
}
}
private fun parseRelease(json: String): LatestRelease? {
val obj = JSONObject(json)
val tag = obj.optString("tag_name")
if (tag.isBlank()) return null
val assets = obj.optJSONArray("assets")
var apkUrl: String? = null
if (assets != null) {
for (i in 0 until assets.length()) {
val asset = assets.optJSONObject(i) ?: continue
val name = asset.optString("name", "")
if (name.endsWith(".apk")) {
apkUrl = asset.optString("browser_download_url", "").ifBlank { null }
if (apkUrl != null) break
}
}
}
private fun parseRelease(html: String): LatestRelease? {
// The redirect target is the latest release's tag page. Extract the tag
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
// unambiguous. The <title> also holds the release name, but that is free
// text and cannot be used to build the asset URL.
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
?.groupValues?.get(1) ?: return null
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
?.groupValues?.get(1) ?: return null
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// The release APK asset follows the fixed naming scheme used by the build:
// Look4Sat-<tag without leading v>-release.apk
val apkUrl = "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
return LatestRelease(
versionTag = tag,
title = obj.optString("name", ""),
body = obj.optString("body", ""),
title = title,
body = "",
apkUrl = apkUrl
)
}
private companion object {
const val LATEST_RELEASE_URL = "https://api.github.com/repos/atsunatsu/Look4Sat/releases/latest"
// 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"
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
}
}
@@ -0,0 +1,93 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog API v2 endpoint (Wavelog >= 3.1.0):
* GET {base}/index.php/api/v2/lookup?grid=all
* Authorization: Bearer wl2_...
* Response: {"data": {"grids": ["JN47", "JO30"], "count": 2}, ...}
*
* Uses a plain HttpURLConnection (bearer auth header) — IRemoteSource does not
* support custom headers, and no feature module touches this class directly.
*/
class WavelogRepository : IWavelogRepository {
/**
* Fetch worked grids. Returns the grid set, or null on any failure
* (network error, non-200, malformed response, bad token).
*/
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
if (url.isBlank() || token.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/v2/lookup?grid=all"
try {
// IRemoteSource.getNetworkStream does not support custom headers, so
// run the bearer-token request directly here via java.net (simple GET,
// no OkHttp dependency added to this module's public API).
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connectionTimeouts(connection)
connection.setRequestProperty("Authorization", "Bearer ${token.trim()}")
connection.requestMethod = "GET"
val code = connection.responseCode
if (code != 200) {
connection.disconnect()
return@withContext null
}
val body = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(body)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
private fun connectionTimeouts(connection: java.net.HttpURLConnection) {
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val obj = JSONObject(body)
val data = obj.optJSONObject("data") ?: return null
val grids = data.optJSONArray("grids") ?: return null
val result = mutableSetOf<String>()
for (i in 0 until grids.length()) {
val grid = grids.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
) {
result.add(grid)
}
}
result
} catch (_: Exception) {
null
}
}
}
@@ -59,6 +59,7 @@ data class OtherSettings(
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -76,6 +77,13 @@ data class DataSourcesSettings(
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class WavelogSettings(
val url: String = "",
val token: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
data class RadioControlSettings(
val enabled: Boolean,
val radioModel: String,
@@ -32,6 +32,7 @@ interface IMainContainer {
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -92,4 +93,11 @@ interface ISettingsRepo {
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
//region # Wavelog worked-grids settings
val wavelogSettings: StateFlow<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
//endregion
}
@@ -0,0 +1,28 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
interface IWavelogRepository {
/**
* Fetch worked grids for the configured Wavelog URL/token.
* Returns the grid set, or null on any failure.
*/
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
}
@@ -19,6 +19,8 @@ package com.rtbishop.look4sat.core.domain.source
object Sources {
val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -46,7 +48,6 @@ object Sources {
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_switch_map_grid">Mostrar cuadrícula Maidenhead en el mapa</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -149,6 +149,7 @@
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_switch_map_grid">Показывать сетку Maidenhead на карте</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_switch_map_grid">සිතියමේ Maidenhead ජාලකය පෙන්වන්න</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -192,6 +192,7 @@
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_switch_map_grid">Haritada Maidenhead ızgarasını göster</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -150,6 +150,7 @@
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_switch_map_grid">Показувати сітку Maidenhead на мапі</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -139,6 +139,15 @@
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
<string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API 令牌</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。请先保存,再点同步。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
@@ -202,6 +211,7 @@
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -174,6 +174,15 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_wavelog_title">Wavelog worked grids</string>
<string name="prefs_wavelog_configure">Configure</string>
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API token</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Save first, then tap Sync.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_data_title">Satellite data</string>
@@ -236,6 +245,7 @@
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -0,0 +1,204 @@
/*
* 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.feature.map
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import org.osmdroid.views.MapView
import org.osmdroid.views.Projection
import org.osmdroid.views.overlay.Overlay
import kotlin.math.ceil
import kotlin.math.floor
/**
* Maidenhead locator grid overlay, drawn directly on the map canvas.
*
* Zoom levels (map maxZoom = 7):
* - zoom < GRID_ZOOM_SUB: 10° x 20° fields with 2-character labels (e.g. "PM")
* - zoom >= GRID_ZOOM_SUB: 1° x 2° squares with 4-character labels (e.g. "PM95")
*
* All geometry is computed per-frame from the projection, so the overlay
* stays correct while panning/zooming. Grid lines that cross the antimeridian
* are drawn in segments clamped to the visible bounding box.
*/
class MaidenheadGridOverlay : Overlay() {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 1.5f
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 26f
style = android.graphics.Paint.Style.FILL
color = Color.argb(220, 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)
}
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
var ownGrid: String? = null
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow || !isEnabled) return
val projection = mapView.projection
val zoom = mapView.zoomLevelDouble
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
// Visible bounding box in geographic coordinates
val north = projection.fromPixels(0, 0)
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = north.latitude.coerceIn(-90.0, 90.0)
val bottomLat = south.latitude.coerceIn(-90.0, 90.0)
val leftLon = south.longitude
val rightLon = north.longitude
val firstRow = floor(bottomLat / cellLat).toInt()
val lastRow = ceil(topLat / cellLat).toInt()
// Longitude cell indices may exceed the [-180, 180) range when the view
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
// Worked-grid highlight fills (only meaningful at sub-square zoom)
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
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 yTop = projectionToY(projection, topLatCell)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon) ?: continue
val xRight = projectionToX(projection, lon + cellLon) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
}
}
// Vertical lines (meridians)
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
}
// Horizontal lines (parallels)
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat <= -90.0 || lat >= 90.0) continue
val y = projectionToY(projection, lat)
if (y == null) continue
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)
val showLabels = when {
zoom >= GRID_ZOOM_SUB -> true
zoom >= FIELD_ZOOM_LABELS -> true
else -> false
}
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.
val y1 = projectionToY(projection, 0.0)
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
val labelOffset = 8f
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
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon) ?: continue
if (x < -200f || x > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
}
}
}
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
private fun projectionToX(projection: Projection, lon: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(0.0, lon)
val p = projection.toPixels(geo, null)
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
}
/** Y pixel for a latitude, or null when outside the viewport. */
private fun projectionToY(projection: Projection, lat: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
val p = projection.toPixels(geo, null)
return p.y.toFloat()
}
private fun cellLabel(lat: Double, lon: Double, zoom: Double): String {
// Maidenhead field: longitude 20° fields A-R starting at -180,
// latitude 10° fields A-R starting at -90.
val lonNorm = normalizeLon(lon)
val latNorm = lat.coerceIn(-90.0, 89.999)
val fieldLon = ((lonNorm + 180.0) / FIELD_LON).toInt()
val fieldLat = ((latNorm + 90.0) / FIELD_LAT).toInt()
val field = "${'A' + fieldLon}${'A' + fieldLat}"
if (zoom < GRID_ZOOM_SUB) return field
// Square: 2° x 1° digits
val squareLon = (((lonNorm + 180.0) % FIELD_LON) / SUB_SQUARE_LON).toInt()
val squareLat = (((latNorm + 90.0) % FIELD_LAT) / SUB_SQUARE_LAT).toInt()
return "$field$squareLon$squareLat"
}
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
if (l < -180.0) l += 360.0
return l
}
private companion object {
const val FIELD_LAT = 10.0
const val FIELD_LON = 20.0
const val SUB_SQUARE_LAT = 1.0
const val SUB_SQUARE_LON = 2.0
const val GRID_ZOOM_SUB = 5.0
const val FIELD_ZOOM_LABELS = 3.0
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
}
}
@@ -80,14 +80,15 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
// Overlay indices
private const val OVERLAY_STATION = 0
private const val OVERLAY_TRACK = 1
private const val OVERLAY_FOOTPRINT = 2
private const val OVERLAY_POSITIONS = 3
private const val OVERLAY_TERMINATOR = 4
private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7
private const val OVERLAY_GRID = 0
private const val OVERLAY_STATION = 1
private const val OVERLAY_TRACK = 2
private const val OVERLAY_FOOTPRINT = 3
private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_TERMINATOR = 5
private const val OVERLAY_SUN = 6
private const val OVERLAY_MOON = 7
private const val OVERLAY_COUNT = 8
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
@@ -159,13 +160,16 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { view ->
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
setGridMode(uiState.isGridMode, uiState.workedGrids, view)
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
}
view.invalidate()
}
uiState.mapData?.let { mapData ->
@@ -261,6 +265,30 @@ private fun MapDataCards(data: MapData) {
// endregion
// region Map overlay helpers
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: MapView) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
if (gridOverlay is MaidenheadGridOverlay) {
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
} else {
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
isEnabled = gridMode
this.workedGrids = workedGrids
}
}
// Satellite-related layers are hidden in grid mode; the grid overlay
// itself is controlled by its own enabled flag.
for (index in OVERLAY_STATION..OVERLAY_MOON) {
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
}
} catch (e: Exception) {
println(e)
}
}
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_STATION]
@@ -500,6 +528,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
}
}
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
@@ -26,6 +26,8 @@ data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -74,7 +74,12 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -51,6 +51,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -155,6 +156,69 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
}
}
@Composable
fun WavelogDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: () -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "wl2_...") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (message != null) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync button row (inside dialog content so token must be saved first)
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(onClick = onSync, enabled = !isSyncing) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
@@ -63,6 +63,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
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.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -201,6 +203,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.wavelog) {
WavelogDialog(
initialSettings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.wavelogSyncing,
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
onSync = { onAction(SettingsAction.SyncWorkedGrids) }
)
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -305,6 +318,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
WavelogCard(
settings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
showWavelogDialog = { dialogs.wavelog = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
@@ -489,6 +509,7 @@ private fun OtherCardPreview() = MainTheme {
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
@@ -500,7 +521,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
.height(312.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -522,6 +543,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
onAction(SettingsAction.ToggleMapGrid(it))
}
}
}
}
@@ -538,6 +562,44 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Preview(showBackground = true)
@Composable
private fun WavelogCardPreview() = MainTheme {
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
}
@Composable
private fun WavelogCard(
settings: WavelogSettings,
workedGridsCount: Int,
showWavelogDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_wavelog_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showWavelogDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -657,6 +719,7 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
}
@Composable
@@ -664,12 +727,12 @@ 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)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
}
}
)
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.model.OtherSettings
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 java.io.File
@@ -54,6 +55,10 @@ data class SettingsState(
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
@@ -77,6 +82,7 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class ToggleMapGrid(val value: Boolean) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -85,6 +91,10 @@ sealed interface SettingsAction {
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data object SyncWorkedGrids : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
data object DownloadUpdate : SettingsAction
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.presentation.R
@@ -39,6 +40,7 @@ class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -54,7 +56,9 @@ class SettingsViewModel(
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
)
)
@@ -107,6 +111,11 @@ class SettingsViewModel(
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
}
@@ -133,10 +142,14 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
SettingsAction.SyncWorkedGrids -> syncWorkedGrids()
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -146,6 +159,30 @@ class SettingsViewModel(
}
}
// region Wavelog worked grids
private fun syncWorkedGrids() {
val settings = _uiState.value.wavelogSettings
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
return
}
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
_uiState.update {
if (grids != null) {
settingsRepo.setWorkedGrids(grids)
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
} else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
}
}
}
}
// endregion
// region Update checker
private fun checkForUpdate() {
@@ -267,6 +304,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "451"
appVersionCode = "452"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.7"
appVersionName = "4.4.6-ba7opf.9"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry