Compare commits

...
12 Commits
Author SHA1 Message Date
atsunatsu c86893f6b1 build: release version 4.4.8-ba7opf.6 (560) 2026-10-03 03:07:19 +08:00
atsunatsu cad085c25b feat(settings): 数据源分组补提示,说明该顺序只影响卫星名
轨道元素改为按历元取最新后,列表顺序只决定显示名;UI 不说明的话用户会
误以为顺序仍决定用哪份轨道(本轮实测的困惑即来自此)。补两组提示项并在
分组标题下小字展示(排版与上游一致:onSurfaceVariant,top 2dp/bottom 8dp):

- 卫星数据(中文):轨道数据优先取列表最新星历,此顺序仅影响卫星名称
- 收发器数据:仍为"列表靠前优先"(tx 记录按 uuid 去重取首个源),提示沿用
  上游措辞;es/ru/si/tr/uk 五种语言同样沿用上游译文
2026-10-03 03:02:11 +08:00
atsunatsu cba5eb7efe feat(data): 多源星历按历元取最新(对齐上游),源顺序只影响卫星名
采用上游 6f3e3351 的合并口径:同一 NORAD 号的轨道元素取历元最新者,名字取
列表中第一个提供该星的源,源顺序因此退化为命名偏好。fork 此前是
flatMap + distinctBy{catnum}(列表靠前者全胜)——实测 R4UAB 的元素平均比
CelesTrak 旧 8.4h(78 颗共有业余星中 67 颗更旧),却因排在列表前面而抢占。

- OrbitalData:暴露 epochDaynum / timeToDaynum / epochToDaynum(跨源比较必须
  走 daynum,原始 YYDDD 跨十年不可比)
- DatabaseRepo:新增 mergeEntries(历元最新元素 + 首个提供者的名字)、
  insertFresherEntries(只写入更新过的元素,并对未更新者原地改名,使重排源
  顺序立即生效)、pruneStaleEntries(30 天无源刷新的星清理)
- updateFromRemote 改为按源分别解析;保留 fork 的 importedTypeIds 类型映射
  (按每源结果统计,供筛选 chips 与自定义源使用);全部源失败时保留上次成功
  时间戳,下次启动重试
- updateTLEFromFile 走同一合并链路(手工导入同样只升不降)
- 新增 5 个单测(含 prune 与失败保时间戳),fixture 历元改为当前(旧历元
  会被新 prune 规则清掉);两个 fake 补齐 ILocalSource 新接口

测试:core:domain 171 / core:data 121 全绿。
2026-10-03 03:02:00 +08:00
atsunatsu 74c245a7d2 build: release version 4.4.8-ba7opf.4 (557) 2026-10-02 23:37:15 +08:00
atsunatsu 135ecb861d feat(map): 网格通联详情呼号列加宽(次数列不再等分,字样位置不变)
工作网格 QSO 详情弹窗里,中间那一列(通联行的「n QSO」、标记行的「标记」)
原来和左右两列一样占 weight(1f)(各 1/3 宽),而该列文字是居中的,字样两侧的
空白被白白占用。现改为按内容宽度占位(去掉 weight(1f)),左右两列仍各占
weight(1f):两外列等分剩余宽度 ⇒ 中列起点正好落在原居中字样的左边界,
因此

- 「n QSO」「标记」字形位置与之前逐像素一致,日期与展开箭头(右对齐)不动;
- 省出的中列左半空白全部交给呼号列,呼号列宽度由 W/3 增至 (W - 字样宽)/2,
  411dp 屏约 +33dp,长呼号(含 /P、活动特别呼号)多显示约 3 个字符,
  并可一直排到「n QSO」字样左侧才省略。

仅影响地图页「网格通联详情」弹窗内的两类行;设置页日志本行本就是
「呼号 weight + 尾部标签 wrap-content」,口径已一致,未改动。
2026-10-02 23:37:06 +08:00
atsunatsu 1fd1459ddf build: release version 4.4.8-ba7opf.3 (555) 2026-10-02 13:31:18 +08:00
atsunatsu 56a788138b feat(lotw): 漫游核验改用「当前定位 vs 证书台址」,移除记录网格核对
- 新增 LoTWPositionWarning + positionWarning(currentGrid, stationGrids):当前定位网格不在
  证书台址网格集内 → 提示(4 位归一;任一侧缺失不报)
- ISettingsRepo.getCurrentGrid():只读取「≤24h 系统最后已知定位」与「存储台址」中较新者,
  不写任何设置(避开 setStationPosition 的写副作用)
- 上传前核对弹窗(雷达 / 日志本两入口)改由 VM 在 prepare 后计算;雷达记录页新增提示行
  (stationMismatch,点击=跳转改台址并预填当前格)
- 移除旧「记录 vs 台址」核对:uploadGridWarning / LoTWGridWarning / preview.gridWarning 及
  相关 UI / 字符串全删;LoTWPositionWarningDialog 重命名替代 LoTWGridWarningDialog
- 测试:LoTWPositionWarningTest ×5 替代 LoTWGridWarningTest;domain 171 / data 116 / mutual 28 全绿
2026-10-02 13:31:13 +08:00
atsunatsu 2e704ee1d3 build: release version 4.4.8-ba7opf.3 (554) 2026-10-02 12:02:03 +08:00
atsunatsu 5b7c7d6df5 feat(logbook): 窗口外录入 + 网格显示链 + 双倍修复 + 上传前核对 + 重传选择 + 报错拆分
.2.5–.3 调试系列收口:

- 雷达窗口外录入:不在过境窗口内按 Log 时按当前过境中点落点(弹窗确认,可事后编辑改时间)
- 网格显示链:上传盖章(markUploaded 记录台址网格集)+ 日志本行尾 / 雷达页「自己的格子」+
  QSL 槽对方网格(绿 #4CD964)+ 缩写规则统一(gridsLabel 上移 core:presentation);
  QsoDatabase v2→v3 增列 theirVuccGrids
- 全量同步「双倍」三层修复:键无关身份±60s 镜像折叠、matchableMode 归一(MFSK→FT4)、
  双确认对按 contentScore 折叠
- 上传前核对:批记录网格与台址完全不交集 → 弹窗(修改台址带预填跳转 / 忽略),预览被闸住
- 重传选中:日志本长按多选(含已上传/已确认)→ prepare(resubmit=true) → 成功后网格盖章更新;
  退出日志本重置选择(需重新长按)
- 报错拆分:QSO_FUTURE(记录时间在未来)与 QSO_DATE(超出证书范围)分开提示
- 全 App 弹窗样式统一(深色圆角 sheet)

测试:domain 172 / data 116 全绿。
2026-10-02 12:01:57 +08:00
atsunatsu d13af978af build: release version 4.4.8-ba7opf.2.4 (549) 2026-09-29 17:58:10 +08:00
atsunatsu b48e23804f feat(radar): 生成通联记录帖子只含当前过境窗口
generatePost 的筛选从最近 24 小时改为当前过境窗口(aos..los),
与日志列表 recent 同一套过滤逻辑,不再混入其他过境的 QSO
2026-09-29 17:58:07 +08:00
atsunatsu a2b3ce5d69 docs: 补充第三方声明(MIT 合规)
- GridGeometry.kt 头部注明移植自 OrbitDeckiOS(Paul Stoetzer, N8HM, MIT)
- 新增 THIRD_PARTY_NOTICES.md:OrbitDeckiOS (MIT) + Material Design Icons (Apache-2.0)
2026-09-29 15:45:55 +08:00
56 changed files with 1580 additions and 138 deletions

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
# Third-Party Notices
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
components:
## OrbitDeckiOS
- Source: https://github.com/prstoetzer/OrbitDeckiOS
- Copyright (c) 2025 Paul Stoetzer, N8HM
- License: MIT
- Used for: the Grid Finder feature — the VUCC grid line / grid point
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
OrbitDeckiOS' GridGeometry.
### MIT License
```
MIT License
Copyright (c) 2025 Paul Stoetzer, N8HM
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Material Design Icons
- Source: https://materialdesignicons.com (Pictogrammers)
- License: Apache-2.0
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
(crosshairs-gps icon). Attribution is also noted in the icon file header.
@@ -147,7 +147,7 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
} }
} }
entry<GridFinderDestination> { entry<GridFinderDestination> {
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -39,6 +40,18 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry> suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations * upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database. * never need a migration of the satellite database.
*/ */
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false) @Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() { abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao abstract fun qsoDao(): QsoDao
} }
@@ -31,3 +31,10 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
} }
} }
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
}
}
@@ -47,6 +47,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "", @ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "", @ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
val dxcc: Int? = null, val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "", @ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null, val cqZone: Int? = null,
@@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3 import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
import com.rtbishop.look4sat.core.data.database.QsoDatabase import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -103,7 +104,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() } override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy { override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB") val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2) .addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3)
.build() .build()
QsoRepository(database.qsoDao(), Dispatchers.IO) QsoRepository(database.qsoDao(), Dispatchers.IO)
} }
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call) !call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call) if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd") val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) { // A record dated after "now" gets its own message: it happens when the operator
// logged against a pass that had not started yet (out-of-window flow), and "fix the
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
// operator hunting the certificate while the culprit was the record's future time.
if (record.startUtcMillis > now) {
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
}
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
fail(LoTWProblem.QSO_DATE, call) fail(LoTWProblem.QSO_DATE, call)
} }
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty() fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
@@ -279,6 +279,13 @@ class LoTWUploadRepository internal constructor(
else -> contact else -> contact
} }
} }
// The station-location grids this batch is signed with: the primary grid plus the
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
val locationGrids = buildList {
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
signing.location["MY_VUCC_GRIDS"]?.split(',')
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
}.distinct()
val preview = LoTWUploadPreview( val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"), UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped, contacts.size, skipped,
@@ -289,7 +296,9 @@ class LoTWUploadRepository internal constructor(
unavailable, unavailable,
reasons.toMap(), reasons.toMap(),
duplicates, duplicates,
contacts.map { it.record.id } contacts.map { it.record.id },
locationGrids,
resubmit
) )
if (contacts.isNotEmpty()) pending = Pending( if (contacts.isNotEmpty()) pending = Pending(
preview, preview,
@@ -46,8 +46,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
localSource.insertEntries(entries) insertFresherEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size importedCount = entries.size
} }
@@ -93,14 +93,15 @@ class DatabaseRepo(
settingsRepo.updateDataSourcesStatus( settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code } (tleResults + radioResults).associate { (url, result) -> url to result.code }
) )
// parse fetched data concurrently and associate known built-in URLs with existing type filters. // parse each source on its own: the type filters need every source's catnums, while the
val importedEntries = tleResults.flatMap { (rawUrl, result) -> // stored elements are merged across sources below by freshness.
val parsedPerSource = tleResults.map { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl) val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty() val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum }) importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries entries
}.distinctBy { it.catnum } }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) } importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioResults.flatMap { (rawUrl, result) -> val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl) val normUrl = normalizeUrl(rawUrl)
@@ -115,8 +116,14 @@ class DatabaseRepo(
localSource.deleteManagedRadios() localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
} }
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries) // Elements always come from the freshest source for every satellite; only
setUpdateSuccessful(System.currentTimeMillis()) // elements newer than the stored ones are written back.
insertFresherEntries(mergeEntries(parsedPerSource))
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
} }
override suspend fun clearAllData() = withContext(dispatcher) { override suspend fun clearAllData() = withContext(dispatcher) {
@@ -155,6 +162,62 @@ class DatabaseRepo(
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
} }
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState( settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
myGrid = myGrids.firstOrNull(), myGrid = myGrids.firstOrNull(),
myGrids = myGrids, myGrids = myGrids,
myCallsign = myCallsign, myCallsign = myCallsign,
stationKey = stationKey stationKey = stationKey,
// Copy, never the live buffer — resetRecord() clears it.
theirGrids = gridsInRecord.distinct().toList()
) )
for (grid in gridsInRecord) { for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso) result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -16,8 +16,11 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.bandsMatchOrMirror
import com.rtbishop.look4sat.core.domain.logbook.contentScore
import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
@@ -54,14 +57,14 @@ class QsoRepository(
dao.save(record.copy( dao.save(record.copy(
id = 0L, id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false, lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
lotwQslDate = "", vuccGrids = emptyList(), lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = "" dxcc = null, country = "", cqZone = null, region = ""
).toEntity()) ).toEntity())
} else { } else {
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) { val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous) record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy( } else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = "" dxcc = null, country = "", cqZone = null, region = ""
) else record ) else record
val received = if (previous != null && ( val received = if (previous != null && (
@@ -76,9 +79,18 @@ class QsoRepository(
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } } override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) { override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext if (ids.isEmpty()) return@withContext
dao.markUploaded(ids) dao.markUploaded(ids)
// Stamp the station grid set this batch went out under (a line/corner prefill from
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (normalized.isEmpty()) return@withContext
val stamped = ids.mapNotNull { id ->
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
}
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
} }
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) { override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
@@ -132,9 +144,7 @@ class QsoRepository(
val renames = rewriteOfficialNames(working) val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() } val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap() .mapValues { it.value.toMutableList() }.toMutableMap()
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) } val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = keyIndex.keys.toMutableSet()
val changes = linkedMapOf<Int, QsoRecord>().apply { val changes = linkedMapOf<Int, QsoRecord>().apply {
putAll(consolidation.merged) putAll(consolidation.merged)
putAll(renames) putAll(renames)
@@ -149,27 +159,38 @@ class QsoRepository(
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis } val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull() val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) { if (match == null) {
// A confirmation downloaded by the pre-fix parser sits as its own row with the // A confirmation downloaded by an early release sits as its own row with the
// band direction read mirrored (the downlink was stored as the uplink). It has no // band direction read mirrored (the downlink was stored as the uplink). The
// local partner (so nothing folds it) and its dedupe key equals the fresh report, // strict lookup above cannot see it: its bands fail the comparison, and its
// so it would otherwise be skipped forever, keeping the wrong band on display. // dedupe key can differ too (fields written by older releases — timestamp
// When the bands are exactly mirrored, adopt the report's direction instead. // precision, FT4 sub-mode spelling, missing callsign — so it is not treated
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull() // as known either). Fold the report into the richest mirrored row instead of
?.takeIf { index -> working[index].lotwConfirmed && // inserting a second row next to it; left as two rows, neither consolidation
working[index].band.equals(remote.rxBand, true) && // nor a later sync could reliably heal them.
working[index].rxBand.equals(remote.band, true) } else null val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
if (stale != null) { working[index].band.isNotBlank() && remote.band.isNotBlank() &&
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand) !working[index].band.equals(remote.band, true) &&
working[stale] = fixed sameContactIdentity(working[index], remote) &&
changes[stale] = fixed bandsMatchOrMirror(working[index], remote)
updated++ } else emptyList()
if (mirroredRows.isNotEmpty()) {
val target = mirroredRows.maxBy { contentScore(working[it]) }
mirroredRows.forEach { index ->
val healed = working[index].copy(
band = remote.band.ifBlank { working[index].band },
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
)
val folded = if (index == target) healed.withConfirmation(remote) else healed
working[index] = folded
changes[index] = folded
}
updated += mirroredRows.size
return@forEach return@forEach
} }
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach } if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName)) val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size) lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added) knownKeys += stableQsoKey(added)
working += added working += added
imported++ imported++
@@ -268,6 +289,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank), vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -304,6 +326,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -468,6 +468,34 @@ class SettingsRepo(
return true return true
} }
override fun getCurrentGrid(): String? {
val station = _stationPosition.value
val now = System.currentTimeMillis()
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
// station, so a stale fix can never override a position the operator just set.
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
return chosen.takeIf(String::isNotBlank)
}
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
private fun lastKnownGridFix(): Pair<String, Long>? {
return try {
val provider = when {
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
else -> return null
}
val location = locationManager.getLastKnownLocation(provider) ?: return null
val locator = positionToQth(location.latitude, location.longitude) ?: return null
locator to location.time
} catch (_: SecurityException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun getStationPosition(): GeoPos { private fun getStationPosition(): GeoPos {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble() val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble() val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects return selectedOrbitalObjects
} }
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -44,6 +44,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -51,6 +52,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher) private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test @Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
@@ -150,20 +155,144 @@ class DatabaseRepoTest {
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"]) assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
} }
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update prunes satellites no source refreshed for a month`() = runTest(dispatcher) {
val url = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[url] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(url),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
// a satellite that disappeared from every catalog: elements stored a month and a half ago
val staleDate = LocalDate.now().minusDays(45)
val staleEpoch = "%02d%03d".format(staleDate.year % 100, staleDate.dayOfYear).toDouble() + 0.5
localSource.insertEntries(
listOf(OrbitalData("OLD SAT", staleEpoch, 15.5, 0.001, 51.6, 309.4, 203.6, 299.8, 99999, 0.0003, 0.0000128))
)
repository.updateFromRemote()
assertEquals(listOf(25544), localSource.insertedEntries.map { it.catnum })
}
private fun validCsvStream(): InputStream = """ private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """ private fun validTleStream(): InputStream = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun jamxTleStream(): InputStream = """ private fun jamxTleStream(): InputStream = """
JAMX-0825b JAMX-0825b
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013 1 98248U $todayEpoch.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012 2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
} }
@@ -195,6 +324,21 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) { override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries insertedEntries += entries
} }
@@ -271,6 +415,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList() override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) { override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
result.values.forEach { assertEquals(1, it.size) } result.values.forEach { assertEquals(1, it.size) }
} }
@Test
fun parseQsosCapturesOppositeGridSet() {
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(
listOf("NL47"),
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
)
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("EN52", "EN53"),
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
)
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("OM60", "EN52", "EN53"),
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
)
}
@Test @Test
fun parseQsosRejectsBodyWithoutEoh() { fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>")) assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
// The row is also renamed to the ARRL spelling while it is merged. // The row is also renamed to the ARRL spelling while it is merged.
assertEquals("SO-50", rows.first().satelliteName) assertEquals("SO-50", rows.first().satelliteName)
assertEquals(145_850_000L, rows.first().txFrequencyHz) assertEquals(145_850_000L, rows.first().txFrequencyHz)
// The opposite station's grid set from the report lands on the row.
assertEquals("EN52", rows.first().theirGrid)
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
} }
@Test @Test
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet()) assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
} }
@Test
fun consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
// The mirrored import the old parser created (no frequencies, no report-side grids —
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
// carries the lower id.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
.toEntity()
)
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
val localId = dao.save(
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
)
assertEquals(1, repository.consolidateConfirmations())
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(localId, rows.first().id)
// The surviving row keeps the correct uplink-first bands and gains the report fills.
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertEquals("OL62", rows.first().myGrid)
assertEquals(318, rows.first().dxcc)
assertEquals("China", rows.first().country)
assertTrue(rows.first().lotwUploaded)
}
@Test
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
// into one row with the report's direction.
val stale = reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
dao.save(stale.toEntity())
dao.save(stale.copy(id = 0L).toEntity())
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
// The report's fills land on the healed row.
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
// The doubled contacts of the reported bug: a mirrored row written by an older release
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
// the exact time, differ from the report's) used to gain a second row next to it
// instead of being healed, because every net relied on the key or the strict bands.
val staleId = dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
startUtcMillis = qsoStart + 456L
)
.toEntity()
)
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(staleId, rows.first().id)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
// carries MFSK + FT4. The spelling difference must not block the heal either.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
theirGrid = "", theirVuccGrids = emptyList()
)
.toEntity()
)
repository.mergeLoTW(
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
)
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test @Test
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking { fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction // A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
@@ -221,7 +324,8 @@ class QsoRepositoryTest {
cqz = 24, cqz = 24,
state = "GD", state = "GD",
myGrid = "OL62", myGrid = "OL62",
myGrids = setOf("OL62", "OL63") myGrids = setOf("OL62", "OL63"),
theirGrids = listOf("EN52", "EN53")
).toConfirmedRecord("BA7OPF") ).toConfirmedRecord("BA7OPF")
private fun QsoRecord.toEntity() = QsoEntity( private fun QsoRecord.toEntity() = QsoEntity(
@@ -254,6 +358,7 @@ class QsoRepositoryTest {
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -282,6 +282,10 @@ private class FakeLocalSourceForSearch(
override suspend fun getEntriesTotal(): Int = items.size override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
@@ -330,6 +334,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct() types.flatMap { typeIds[it].orEmpty() }.distinct()
@@ -17,7 +17,12 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord? suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long) suspend fun delete(id: Long)
suspend fun markUploaded(ids: List<Long>) /**
* Marks the batch as uploaded and stamps the station-location grids the batch was
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
* logbook row can show which grids the QSO went out under.
*/
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs, startUtcMillis = epochMs,
theirCallsign = call, theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(), myCallsign = accountCallsign.trim().uppercase(),
theirGrid = theirGrids.firstOrNull().orEmpty(),
theirVuccGrids = theirGrids,
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(), myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(), vuccGrids = myGrids.toList(),
band = bandUp, band = bandUp,
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list" LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
LoTWProblem.MODE -> "mode not accepted by LoTW" LoTWProblem.MODE -> "mode not accepted by LoTW"
LoTWProblem.QSO_DATE -> "date outside the certificate" LoTWProblem.QSO_DATE -> "date outside the certificate"
LoTWProblem.QSO_FUTURE -> "record time is in the future"
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate" LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record" LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
LoTWProblem.STATION_GRID -> "station grid missing" LoTWProblem.STATION_GRID -> "station grid missing"
@@ -0,0 +1,20 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
/**
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
*
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
* the point is to send them again under a corrected station location, and LoTW treats an
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
*/
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
@@ -80,7 +80,7 @@ fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) && local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) && (local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
local.isSatellite == remote.isSatellite && local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode && local.matchableMode == remote.matchableMode &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
} }
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band) sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf( fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
).joinToString("|") ).joinToString("|")
/** A local row and the confirmation row that belongs to it, as indices into one list. */ /** A row to keep and the duplicate row to fold into it (then delete), as indices into one list. */
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int) data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
/** /**
* Rows that a confirmation should have been folded into but was not, so they can be * Rows that should have been one row but were not, so they can be consolidated back into one.
* consolidated back into one.
* *
* Confirmations merged before the identity fix landed as their own rows: the tracker * Two known holes leave a contact stored twice:
* name vs ARRL name comparison failed, and the band direction of the report was read *
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names * 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
* were stored twice. The pair is only accepted when the contact identity matches and * vs ARRL name comparison failed, and the band direction of the report was read mirrored
* the bands are either identical or exactly mirrored — the signature of that second * (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
* mismatch — which keeps unrelated QSOs of the same operator and minute apart. * apart. Pass 1 folds the confirmation into the local row.
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
* created plus the row that was confirmed on its own later, or a duplicate import added
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
* are confirmed — so pass 2 pairs confirmed rows with each other.
*
* Both passes accept a pair only when the contact identity matches and the bands are either
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
* other one is folded into it, so no data is lost.
*/ */
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> { fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
val taken = mutableSetOf<Int>() val taken = mutableSetOf<Int>()
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex -> val pairs = mutableListOf<SplitConfirmationPair>()
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
val confirmation = records[confirmationIndex] val confirmation = records[confirmationIndex]
val localIndex = pendingIndices.firstOrNull { index -> val localIndex = pendingIndices.firstOrNull { index ->
index !in taken && sameContactIdentity(records[index], confirmation) && index !in taken && sameContactIdentity(records[index], confirmation) &&
bandsMatchOrMirror(records[index], confirmation) bandsMatchOrMirror(records[index], confirmation)
} ?: return@mapNotNull null } ?: return@forEach
taken += localIndex taken += localIndex
SplitConfirmationPair(localIndex, confirmationIndex) taken += confirmationIndex
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
} }
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
confirmedLeft.forEach { index ->
if (index in taken) return@forEach
val cluster = mutableListOf(index)
confirmedLeft.forEach { candidate ->
if (candidate != index && candidate !in taken &&
cluster.all {
sameContactIdentity(records[it], records[candidate]) &&
bandsMatchOrMirror(records[it], records[candidate])
}
) {
cluster += candidate
}
}
if (cluster.size < 2) return@forEach
cluster.forEach { taken += it }
val keep = cluster.maxBy { contentScore(records[it]) }
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
}
return pairs
}
/** More operator/report detail first — decides which row survives a pass-2 fold. */
fun contentScore(record: QsoRecord): Int {
var score = 0
// Frequencies are the strongest operator-entered signal; a band label consistent with its
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
// contradict the side they describe.
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
.count { it.isNotBlank() }
score += record.vuccGrids.size
score += record.theirVuccGrids.size
if (record.lotwUploaded) score += 1
if (record.lotwReceived) score += 1
if (record.dxcc != null) score += 1
if (record.country.isNotBlank()) score += 1
if (record.lotwQslDate.isNotBlank()) score += 1
return score
} }
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */ /** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean = fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) || (sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band)) (sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy( fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign }, myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid }, theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
myGrid = myGrid.ifBlank { confirmed.myGrid }, myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport }, sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport }, receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
return label.trim().uppercase(Locale.US) return label.trim().uppercase(Locale.US)
} }
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
* differently, so both spellings must match. */
val QsoRecord.matchableMode: String
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
val QsoRecord.isSatellite: Boolean val QsoRecord.isSatellite: Boolean
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank()) get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
@@ -18,6 +18,10 @@ data class QsoRecord(
val theirCallsign: String, val theirCallsign: String,
val myCallsign: String, val myCallsign: String,
val theirGrid: String = "", val theirGrid: String = "",
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
* the logbook's QSL slot shows the abbreviated set. */
val theirVuccGrids: List<String> = emptyList(),
val myGrid: String = "", val myGrid: String = "",
val sentReport: String = "", val sentReport: String = "",
val receivedReport: String = "", val receivedReport: String = "",
@@ -72,7 +72,13 @@ data class GridQso(
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the * MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
* operated-grid selector can group QSOs per 台址 and show each 台址 with * operated-grid selector can group QSOs per 台址 and show each 台址 with
* its full grid set. Null for data synced before this field existed. */ * its full grid set. Null for data synced before this field existed. */
val stationKey: String? = null val stationKey: String? = null,
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
* the contact spans several squares; up to four in one QSO). The logbook
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
* same thing for the user's own side. */
val theirGrids: List<String> = emptyList()
) { ) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */ /** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String val bandLabel: String
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */ /** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean { fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0 return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
} }
private fun epochToDaynum(epoch: Double): Double { companion object {
var year = kotlin.math.floor(epoch * 1E-3) /** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
val day = (epoch * 1E-3 - year) * 1000.0 fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double { /**
val aYear = theYear - 1 * Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
val a = kotlin.math.floor(aYear / 100).toLong() * comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
val b = 2 - a + a / 4 * freshness checks must always go through this conversion.
val i = kotlin.math.floor(365.25 * aYear).toLong() */
return i + (30.6001 * 14).toLong() + 1720994.5 + b fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
} }
} }
@@ -19,6 +19,7 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import java.util.Locale
interface ILoTWUploadRepository { interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate? suspend fun certificate(): LoTWCertificate?
@@ -96,7 +97,23 @@ data class LoTWUploadPreview(
val duplicateSkipped: Int = 0, val duplicateSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these /** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */ * may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList() val submittedIds: List<Long> = emptyList(),
/** The full station-location grid set this batch is signed with (1 inside a grid,
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
* can show which gridsquares the QSO went out under. */
val grids: List<String> = emptyList(),
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
* through so the corrected station location reaches LoTW as an update of the contact. */
val resubmit: Boolean = false
)
/** Pre-upload location check: the freshest known position grid is not covered by the
* station grids an upload would be signed with. */
data class LoTWPositionWarning(
/** Normalized 4-character grid of the freshest known position. */
val currentGrid: String,
/** Distinct station-location grids the upload would be signed with. */
val stationGrids: List<String>
) )
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */ /** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
@@ -128,7 +145,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
enum class LoTWProblem { enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING, CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, QSO_FUTURE, STATION_GRID,
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
LOCATION_MISMATCH, TOO_MANY_CONTACTS LOCATION_MISMATCH, TOO_MANY_CONTACTS
} }
/**
* Roaming check run before upload: warns when the operator's freshest known position
* (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the
* batch would be signed with — the signature of operating while roaming with a station
* location that was never updated. An unknown position, missing station grids, or a covered
* position (boundary operations included) produce no warning.
*/
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
val current = currentGrid?.grid4() ?: return null
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
if (station.isEmpty()) return null
if (current in station) return null
return LoTWPositionWarning(current, station.sorted())
}
/** Normalized 4-character grid, null when the value is not a usable grid. */
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
@@ -48,6 +48,12 @@ interface ISettingsRepo {
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean fun setStationPosition(locator: String): Boolean
/**
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
* (≤ 24 h) system location fix and the stored station position. Null only when neither
* yields a usable locator. Never writes the station position.
*/
fun getCurrentGrid(): String?
//endregion //endregion
//region # Database update settings //region # Database update settings
@@ -26,6 +26,10 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>) suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
@@ -14,6 +14,10 @@
* *
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* Contains code ported from OrbitDeckiOS (https://github.com/prstoetzer/OrbitDeckiOS),
* Copyright (c) 2025 Paul Stoetzer, N8HM, licensed under the MIT License.
* See THIRD_PARTY_NOTICES.md for the full MIT license text.
*/ */
package com.rtbishop.look4sat.core.domain.utility package com.rtbishop.look4sat.core.domain.utility
@@ -40,7 +40,8 @@ class LogbookMergeTest {
propagationMode = "SAT", propagationMode = "SAT",
status = QsoStatus.COMPLETE, status = QsoStatus.COMPLETE,
lotwConfirmed = true, lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50") vuccGrids = listOf("OM60", "OM50"),
theirVuccGrids = listOf("EN52", "EN53")
) )
@Test @Test
@@ -73,12 +74,24 @@ class LogbookMergeTest {
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey()) assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
} }
@Test
fun confirmationLookupKey_groupsMfskAndFt4Spellings() {
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
// the same mode and must resolve to the same contact.
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
assertTrue(sameContactIdentity(plainMfsk, ft4))
}
@Test @Test
fun withConfirmation_marksConfirmedAndMergesVuccGrids() { fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed) val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed) assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived) assertTrue(merged.lotwReceived)
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids) assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
// The opposite station's grid set arrives with the confirmation too.
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
// Local myCallsign is kept when present; only blanks are backfilled. // Local myCallsign is kept when present; only blanks are backfilled.
assertEquals("ba7opf", merged.myCallsign) assertEquals("ba7opf", merged.myCallsign)
} }
@@ -97,13 +110,16 @@ class LogbookMergeTest {
cqz = 24, cqz = 24,
state = "GD", state = "GD",
myGrid = "OM60", myGrid = "OM60",
myGrids = setOf("OM60", "OM50") myGrids = setOf("OM60", "OM50"),
theirGrids = listOf("EN52", "EN53")
) )
val record = qso.toConfirmedRecord("ba7opf") val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign) assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign) assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid) assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids) assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("EN52", record.theirGrid)
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertTrue(record.lotwConfirmed) assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite) assertTrue(record.isSatellite)
assertEquals("70CM", record.band) assertEquals("70CM", record.band)
@@ -198,6 +214,30 @@ class LogbookMergeTest {
assertEquals(1, pairs.first().confirmationIndex) assertEquals(1, pairs.first().confirmationIndex)
} }
@Test
fun splitConfirmationPairs_foldsTwoConfirmedRowsAndKeepsTheRicher() {
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
// created (no frequencies, report-only fills) plus the app row that was confirmed on
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
// they stay doubled in the logbook forever.
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 2L, lotwConfirmed = true)
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
assertEquals(1, pairs.size)
// The richer row (frequencies + consistent bands + upload state) is the survivor.
assertEquals(1, pairs.first().localIndex)
assertEquals(0, pairs.first().confirmationIndex)
}
@Test
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
}
@Test @Test
fun splitConfirmationPairs_keepsUnrelatedRowsApart() { fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L) val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Test
class LogbookResubmitTest {
private fun record(
id: Long,
status: QsoStatus = QsoStatus.COMPLETE,
uploaded: Boolean = false,
confirmed: Boolean = false
) = QsoRecord(
id = id,
startUtcMillis = 1_760_000_000_000L + id,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
mode = "FM",
satelliteName = "SO-50",
status = status,
lotwUploaded = uploaded,
lotwConfirmed = confirmed
)
@Test
fun keepsUploadedAndConfirmedRecords() {
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun dropsRecordsOutsideTheSelection() {
val records = listOf(record(1), record(2), record(3))
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
}
@Test
fun dropsIncompleteRecords() {
val records = listOf(
record(1, status = QsoStatus.DRAFT),
record(2, status = QsoStatus.ABORTED),
record(3, status = QsoStatus.COMPLETE)
)
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun emptySelectionYieldsNothing() {
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
}
}
@@ -276,6 +276,7 @@ class LoTWGridSyncTest {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO() override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO() override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -0,0 +1,56 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoTWPositionWarningTest {
@Test
fun coveredPositionProducesNoWarning() {
assertNull(positionWarning("OL62aa", listOf("OL62")))
}
@Test
fun positionInsideAnyStationGridIsCovered() {
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
}
@Test
fun positionOutsideTheStationWarns() {
val warning = positionWarning("om91", listOf("OL62"))
assertEquals("OM91", warning?.currentGrid)
assertEquals(listOf("OL62"), warning?.stationGrids)
}
@Test
fun stationGridsAreNormalizedForComparison() {
assertNull(positionWarning("OL62", listOf("ol62aa")))
assertNull(positionWarning("ol62", listOf("OL62")))
}
@Test
fun unknownPositionOrStationProducesNoWarning() {
assertNull(positionWarning(null, listOf("OL62")))
assertNull(positionWarning("", listOf("OL62")))
assertNull(positionWarning("OM91", emptyList()))
assertNull(positionWarning("OM91", listOf("")))
}
}
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
controller: SwipeController, controller: SwipeController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp, revealWidth: Dp = 64.dp,
gesturesEnabled: Boolean = true,
revealAction: () -> Unit, revealAction: () -> Unit,
revealButton: @Composable () -> Unit = { revealButton: @Composable () -> Unit = {
Box( Box(
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { translationX = offsetX } .graphicsLayer { translationX = offsetX }
.pointerInput(revealPx) { .pointerInput(revealPx, gesturesEnabled) {
// Selection mode turns reveals off; rows must not shift under the checkboxes.
if (!gesturesEnabled) return@pointerInput
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { settleJob?.cancel() }, onDragStart = { settleJob?.cancel() },
onHorizontalDrag = { change, dragAmount -> onHorizontalDrag = { change, dragAmount ->
@@ -0,0 +1,42 @@
/*
* 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.presentation
import java.util.Locale
/**
* Grid sets abbreviated the way operators read them: a grid is shortened to its
* last two characters while its field (first two characters) stays the same as
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
* Used for the station grids a QSO was uploaded under (logbook rows and the
* radar log page) and for the opposite station's confirmed grid set.
*/
fun gridsLabel(grids: List<String>): String {
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (clean.isEmpty()) return ""
return buildString {
append(clean.first())
for (index in 1 until clean.size) {
val grid = clean[index]
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
else append('/').append(grid)
}
}
}
@@ -0,0 +1,62 @@
/*
* 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.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
/**
* Pre-upload position check: the operator's current position grid falls outside the station
* location the prepared batch would be signed with — the fingerprint of operating while
* roaming with a station location that was never updated. The operator either jumps to the
* station location to fix it, or ignores the warning and proceeds to the normal preview.
*/
@Composable
fun LoTWPositionWarningDialog(
warning: LoTWPositionWarning,
onFixStation: () -> Unit,
onIgnore: () -> Unit
) {
AlertDialog(
onDismissRequest = onIgnore,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
text = {
Text(
text = stringResource(
R.string.lotw_upload_grid_mismatch,
warning.currentGrid,
gridsLabel(warning.stationGrids)
),
fontSize = 14.sp
)
},
confirmButton = {
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
},
dismissButton = {
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
}
)
}
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">Actualización completada con éxito</string> <string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -111,6 +111,8 @@
<string name="prefs_data_update_success">Обновление прошло успешно</string> <string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_sources_title">Свои источники данных</string> <string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string> <string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -142,6 +142,8 @@
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string> <string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string> <string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">Оновлення успішне</string> <string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -197,6 +197,15 @@
<string name="prefs_logbook_upload">上传</string> <string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string> <string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string> <string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string> <string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string> <string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string> <string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
@@ -232,6 +241,8 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string> <string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string> <string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_restore">恢复默认数据源</string> <string name="prefs_data_sources_restore">恢复默认数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string> <string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string> <string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
@@ -406,6 +417,9 @@
<string name="qso_edit_comment">备注</string> <string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</string> <string name="qso_edit_save">保存</string>
<string name="qso_edit_cancel">取消</string> <string name="qso_edit_cancel">取消</string>
<string name="log_out_window_title">不在过境窗口</string>
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
<string name="log_out_window_confirm">录入</string>
<string name="qso_edit_err_callsign">呼号不能为空</string> <string name="qso_edit_err_callsign">呼号不能为空</string>
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string> <string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string> <string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
@@ -226,6 +226,15 @@
<string name="prefs_logbook_upload">Upload</string> <string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string> <string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string> <string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</string>
<string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string> <string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string> <string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string> <string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
@@ -260,6 +269,8 @@
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Orbital data takes the freshest elements; this order only affects satellite names</string>
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
<string name="prefs_data_sources_restore">Restore default sources</string> <string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string> <string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
@@ -434,6 +445,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
<string name="qso_edit_comment">Comment</string> <string name="qso_edit_comment">Comment</string>
<string name="qso_edit_save">Save</string> <string name="qso_edit_save">Save</string>
<string name="qso_edit_cancel">Cancel</string> <string name="qso_edit_cancel">Cancel</string>
<string name="log_out_window_title">Outside pass window</string>
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
<string name="log_out_window_confirm">Log it</string>
<string name="qso_edit_err_callsign">Callsign is required</string> <string name="qso_edit_err_callsign">Callsign is required</string>
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string> <string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string> <string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.ve3nea.morse_expert.MainActivity import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("CW Settings") }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("CW Settings") },
text = { text = {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -93,10 +93,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import org.osmdroid.tileprovider.MapTileProviderBasic import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
@@ -676,7 +679,9 @@ private fun WorkedGridQsoDialog(
if (showMarkInput) { if (showMarkInput) {
androidx.compose.material3.AlertDialog( androidx.compose.material3.AlertDialog(
onDismissRequest = { showMarkInput = false }, onDismissRequest = { showMarkInput = false },
title = { Text(stringResource(R.string.grid_mark_station)) }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
text = { text = {
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
value = callInput, value = callInput,
@@ -725,11 +730,12 @@ private fun MarkedStationRow(
) )
// "标记" column: pin icon (only when the grid has multiple marks) sits // "标记" column: pin icon (only when the grid has multiple marks) sits
// left of the label; pinned (first) row's icon is grey, others pale // left of the label; pinned (first) row's icon is grey, others pale
// yellow — tapping moves that mark to the top. // yellow — tapping moves that mark to the top. Wrap-content (no weight),
// same as the count column of the QSO rows, so the callsign column next
// to it takes the leftover width while these glyphs stay row-centred.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center
modifier = Modifier.weight(1f)
) { ) {
if (showPin) { if (showPin) {
Icon( Icon(
@@ -802,8 +808,13 @@ private fun WorkedGridCallRow(
else stringResource(R.string.grid_qso_count_many, callQsos.size), else stringResource(R.string.grid_qso_count_many, callQsos.size),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center
modifier = Modifier.weight(1f) // No weight: the count column wraps its content, so the callsign
// column (weight 1f, opposite the date column) absorbs the slack
// that used to sit left of the centred count text. The count text
// keeps its exact on-screen position: with equal weights on the
// outer columns the wrap-content middle column starts where the
// centred text used to start, i.e. its centre stays at row centre.
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -115,6 +115,7 @@ class FakeSettingsRepo(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO() override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO() override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -60,14 +62,18 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.QsoEditDialog import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
@Composable @Composable
fun LogPage( fun LogPage(
uiState: RadarState, uiState: RadarState,
logViewModel: LogViewModel, logViewModel: LogViewModel,
onFixGrid: (List<String>) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle() val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
@@ -100,6 +106,12 @@ fun LogPage(
if (catnum != 0) logViewModel.selectSatellite(catnum) if (catnum != 0) logViewModel.selectSatellite(catnum)
} }
// Refresh the roaming hint whenever the page re-enters composition (e.g. returning
// from the station-location fix jump).
LaunchedEffect(Unit) {
logViewModel.refreshPositionHint()
}
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() } val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() } val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
val repeaterTxHz = voiceRepeater?.uplinkHz() val repeaterTxHz = voiceRepeater?.uplinkHz()
@@ -139,14 +151,28 @@ fun LogPage(
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
}) })
) )
Button( Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) }, onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank() enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") } ) { Text("Log") }
} }
logUiState.stationMismatch?.let { mismatch ->
Text(
text = "⚠ " + stringResource(
R.string.lotw_upload_pos_hint,
mismatch.currentGrid,
gridsLabel(mismatch.stationGrids)
),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.clickable { onFixGrid(listOf(mismatch.currentGrid)) }
)
}
if (isLinear) { if (isLinear) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("CW", "SSB", "FT4").forEach { candidate -> listOf("CW", "SSB", "FT4").forEach { candidate ->
@@ -178,7 +204,7 @@ fun LogPage(
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton( OutlinedButton(
onClick = { logViewModel.generatePost(satName, maxElev) }, onClick = { logViewModel.generatePost(satName, maxElev, passWindow) },
enabled = recent.isNotEmpty(), enabled = recent.isNotEmpty(),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { Text("生成通联记录", fontSize = 13.sp) } ) { Text("生成通联记录", fontSize = 13.sp) }
@@ -217,15 +243,29 @@ fun LogPage(
) )
} }
logUiState.preview?.let { preview -> logUiState.uploadPositionWarning?.let { warning ->
UploadPreviewDialog( LoTWPositionWarningDialog(
preview = preview, warning = warning,
busy = logUiState.busy, onFixStation = {
onConfirm = logViewModel::confirmUpload, logViewModel.abandonForGridFix()
onDismiss = logViewModel::dismissPreview onFixGrid(listOf(warning.currentGrid))
},
onIgnore = logViewModel::ignorePositionWarning
) )
} }
// The preview only opens once the position check is out of the way.
if (logUiState.uploadPositionWarning == null) {
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
}
if (logUiState.message.isNotBlank()) { if (logUiState.message.isNotBlank()) {
AlertDialog( AlertDialog(
onDismissRequest = logViewModel::clearMessage, onDismissRequest = logViewModel::clearMessage,
@@ -239,6 +279,34 @@ fun LogPage(
) )
} }
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
// would never show in the window-filtered list. Confirm first, then store it at the pass
// midpoint (there is always an edit dialog afterwards for the exact time).
logUiState.outOfWindowLog?.let { pending ->
val midpointText = remember(pending.midpointUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(pending.midpointUtcMillis))
}
AlertDialog(
onDismissRequest = logViewModel::dismissOutOfWindowLog,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
confirmButton = {
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
Text(stringResource(R.string.log_out_window_confirm))
}
},
dismissButton = {
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
editTarget?.let { target -> editTarget?.let { target ->
QsoEditDialog( QsoEditDialog(
record = target, record = target,
@@ -264,6 +332,8 @@ private fun LogRecordRow(
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis)) }.format(java.util.Date(record.startUtcMillis))
} }
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
// 右划露出删除按钮(短信式);整行点击不再删除。 // 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow( SwipeRevealRow(
key = record.id.toString(), key = record.id.toString(),
@@ -289,6 +359,14 @@ private fun LogRecordRow(
maxLines = 1 maxLines = 1
) )
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1) Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
if (ownGrids.isNotBlank()) {
Text(
text = " $ownGrids",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
} }
when { when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
@@ -25,8 +25,10 @@ import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +40,23 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
/**
* A contact waiting for the operator to confirm logging while the clock is outside the
* current pass window (after LOS, before AOS, or against a pass card that is not the one
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
* so the window-filtered log page can show them; the exact time can be re-dated from the
* edit dialog afterwards.
*/
data class OutOfWindowLog(
val callsign: String,
val satName: String,
val mode: String,
val txHz: Long?,
val rxHz: Long?,
val myGrid: String,
val midpointUtcMillis: Long
)
data class LogUiState( data class LogUiState(
val callsignInput: String = "", val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */ /** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
@@ -49,10 +68,18 @@ data class LogUiState(
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */ /** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(), val satelliteCatalog: List<String> = emptyList(),
val preview: LoTWUploadPreview? = null, val preview: LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val uploadPositionWarning: LoTWPositionWarning? = null,
/** Roaming hint on the log page: current position grid outside the station grids
* (null when covered or unknown). Tapping it jumps to the station location. */
val stationMismatch: LoTWPositionWarning? = null,
/** User-facing upload / record message ("" when none). */ /** User-facing upload / record message ("" when none). */
val message: String = "", val message: String = "",
val postText: String? = null, val postText: String? = null,
val busy: Boolean = false val busy: Boolean = false,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
) )
class LogViewModel( class LogViewModel(
@@ -85,6 +112,10 @@ class LogViewModel(
) )
} }
} }
// The roaming hint tracks the freshest position data: refresh on init and every fix.
viewModelScope.launch {
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
}
} }
fun updateCallsign(value: String) = fun updateCallsign(value: String) =
@@ -101,19 +132,64 @@ class LogViewModel(
settingsRepo.setSatelliteMode(catnum, mode) settingsRepo.setSatelliteMode(catnum, mode)
} }
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) { fun record(
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
passWindow: ClosedRange<Long>?
) {
val call = _uiState.value.callsignInput.trim() val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the // Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited. // minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
val now = System.currentTimeMillis() / 60_000L * 60_000L val now = System.currentTimeMillis() / 60_000L * 60_000L
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
// or against a pass card that is not the one being worked): the log page only shows
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
// operator first; the confirmed contact is stored at the pass midpoint (always inside
// the window) and can be re-dated later from the edit dialog.
if (passWindow != null && now !in passWindow) {
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
.let { it / 60_000L * 60_000L }
.coerceIn(passWindow.start, passWindow.endInclusive)
_uiState.update {
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
}
return
}
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
}
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
fun confirmOutOfWindowLog() {
val pending = _uiState.value.outOfWindowLog ?: return
storeRecord(
pending.callsign, pending.satName, pending.mode,
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
)
_uiState.update { it.copy(outOfWindowLog = null) }
}
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
private fun storeRecord(
call: String,
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
timeMillis: Long
) {
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export. // kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
val tx = txHz?.let { (it + 500L) / 1000L * 1000L } val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L } val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord( val record = QsoRecord(
startUtcMillis = now, startUtcMillis = timeMillis,
endUtcMillis = now, endUtcMillis = timeMillis,
theirCallsign = call, theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign, myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US), myGrid = myGrid.take(6).uppercase(Locale.US),
@@ -142,16 +218,16 @@ class LogViewModel(
*/ */
fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) } fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) }
/** English social post from the recorded QSOs of the current satellite (last 24h). */ /** English social post from the recorded QSOs of the current satellite.
fun generatePost(satName: String, maxElev: Double) { * Same window logic as the on-screen list: only the current pass (aos..los). */
fun generatePost(satName: String, maxElev: Double, passWindow: ClosedRange<Long>?) {
viewModelScope.launch { viewModelScope.launch {
val now = System.currentTimeMillis()
// Records carry the ARRL name while the pass carries the tracker's name, so the // Records carry the ARRL name while the pass carries the tracker's name, so the
// operator's own contacts are collected by identity, not by the raw name. // operator's own contacts are collected by identity, not by the raw name.
val identity = satelliteIdentity(satName) val identity = satelliteIdentity(satName)
val list = qsoRepository.records.first() val list = qsoRepository.records.first()
.filter { satelliteIdentity(it.satelliteName) == identity } .filter { satelliteIdentity(it.satelliteName) == identity }
.filter { it.startUtcMillis > now - 24 * 3_600_000L } .filter { passWindow?.contains(it.startUtcMillis) == true }
.sortedBy { it.startUtcMillis } .sortedBy { it.startUtcMillis }
if (list.isEmpty()) return@launch if (list.isEmpty()) return@launch
val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US) val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
@@ -200,7 +276,13 @@ class LogViewModel(
// Only the records that actually made it into the TQ8 may be // Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list. // marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) } _uiState.update {
it.copy(
busy = false,
preview = preview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: LoTWOperationException) { } catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) { } catch (_: Exception) {
@@ -215,8 +297,9 @@ class LogViewModel(
_uiState.update { it.copy(busy = true) } _uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id) val result = lotwUploadRepository.upload(preview.id)
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) { if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed). // Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
qsoRepository.markUploaded(lastUploadedIds) // the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
lastUploadedIds = emptyList() lastUploadedIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -231,6 +314,34 @@ class LogViewModel(
fun dismissPreview() = _uiState.update { it.copy(preview = null) } fun dismissPreview() = _uiState.update { it.copy(preview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() }
private suspend fun refreshPositionHintNow() {
val station = runCatching { lotwUploadRepository.station() }.getOrNull()
if (station == null) {
_uiState.update { it.copy(stationMismatch = null) }
return
}
val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank)
_uiState.update {
it.copy(
stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids),
stationGrid = station.grid
)
}
}
fun clearMessage() = _uiState.update { it.copy(message = "") } fun clearMessage() = _uiState.update { it.copy(message = "") }
companion object { companion object {
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
} }
@Composable @Composable
fun RadarDestination(navigateUp: () -> Unit) { fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
onOpenLoTWStation()
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = { RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) }, onFixGrid = openLoTWStationForGridFix)
} }
@Composable @Composable
@@ -228,7 +234,8 @@ private fun RadarScreen(
navigateUp: () -> Unit, navigateUp: () -> Unit,
mutualData: MutualPassData, mutualData: MutualPassData,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮) // 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
@@ -308,16 +315,17 @@ private fun RadarScreen(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
} }
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
} }
} }
} }
@@ -338,6 +346,7 @@ private fun RadarFillArea(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -351,7 +360,7 @@ private fun RadarFillArea(
verticalArrangement = Arrangement.spacedBy(ROW_GAP) verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) { ) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth()) RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
} }
} else { } else {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -361,6 +370,7 @@ private fun RadarFillArea(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
panelHeight = pagerPanelHeight, panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
panelHeight: Dp, panelHeight: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(panelHeight), .height(panelHeight),
@@ -453,7 +465,8 @@ private fun PagerCard(
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startPage: RadarPage? = null, startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null onCollapse: (() -> Unit)? = null,
onFixGrid: (List<String>) -> Unit = {}
) { ) {
val pages = rememberRadarPages(uiState) val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@@ -515,7 +528,8 @@ private fun PagerCard(
) )
RadarPage.Log -> LogPage( RadarPage.Log -> LogPage(
uiState = uiState, uiState = uiState,
logViewModel = logViewModel logViewModel = logViewModel,
onFixGrid = onFixGrid
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
@@ -9,7 +9,9 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -26,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -33,6 +36,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +52,8 @@ import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -82,24 +89,39 @@ fun LogbookDialog(
uploadBusy: Boolean, uploadBusy: Boolean,
uploadMessage: String, uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
onEdit: (QsoRecord) -> Unit, onEdit: (QsoRecord) -> Unit,
onUpload: () -> Unit, onUpload: () -> Unit,
onConfirmUpload: () -> Unit, onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit, onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit onDismissMessage: () -> Unit,
onIgnorePositionWarning: () -> Unit = {},
onFixGrid: (List<String>) -> Unit = {},
selectionMode: Boolean = false,
selectedIds: Set<Long> = emptySet(),
onStartSelection: (Long) -> Unit = {},
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {}
) { ) {
val swipeController = rememberSwipeController() val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
// Record currently open in the edit dialog (null when closed). // Record currently open in the edit dialog (null when closed).
var editTarget by remember { mutableStateOf<QsoRecord?>(null) } var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
SharedDialog( SharedDialog(
title = stringResource(R.string.prefs_logbook_title), title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
onCancel = onDismiss, onCancel = if (selectionMode) onExitSelection else onDismiss,
onAccept = onUpload, onAccept = if (selectionMode) onResubmitSelected else onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload), acceptText = if (selectionMode) {
acceptEnabled = !uploadBusy stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
} else {
stringResource(R.string.prefs_logbook_upload)
},
acceptEnabled = if (selectionMode) selectedIds.isNotEmpty() && !uploadBusy else !uploadBusy
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
@@ -108,6 +130,21 @@ fun LogbookDialog(
if (records.isEmpty()) { if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp) Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else { } else {
// Selection mode shows the checked count; otherwise a hint that long-press
// opens it (the path to resubmitting already-uploaded records).
if (selectionMode) {
Text(
text = stringResource(R.string.prefs_logbook_selected, selectedIds.size),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(
text = stringResource(R.string.prefs_logbook_select_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp), modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -115,9 +152,16 @@ fun LogbookDialog(
items(records, key = { it.id }) { record -> items(records, key = { it.id }) { record ->
LogbookRow( LogbookRow(
record = record, record = record,
selectionMode = selectionMode,
selected = record.id in selectedIds,
swipeController = swipeController, swipeController = swipeController,
onDelete = { onDelete(record.id) }, onDelete = { onDelete(record.id) },
onClick = { editTarget = record } onClick = {
if (selectionMode) onToggleSelection(record.id) else editTarget = record
},
onLongClick = {
if (selectionMode) onToggleSelection(record.id) else onStartSelection(record.id)
}
) )
} }
} }
@@ -125,7 +169,15 @@ fun LogbookDialog(
} }
} }
if (preview != null) { if (positionWarning != null) {
// Roaming guard: the current position sits outside the station location this batch
// would be signed with — confirm the station location before the preview opens.
LoTWPositionWarningDialog(
warning = positionWarning,
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
onIgnore = onIgnorePositionWarning
)
} else if (preview != null) {
LogbookUploadPreviewDialog( LogbookUploadPreviewDialog(
preview = preview, preview = preview,
busy = uploadBusy, busy = uploadBusy,
@@ -194,6 +246,15 @@ private fun LogbookUploadPreviewDialog(
} }
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error) Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
} }
if (preview.resubmit) {
// LoTW treats an identical contact as an update, except grids already
// locked in by award credits — say so before the operator commits.
Text(
stringResource(R.string.prefs_logbook_resubmit_note),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
} }
}, },
@@ -208,12 +269,19 @@ private fun LogbookUploadPreviewDialog(
) )
} }
/** Confirmed-grid colour: the map's worked-grid green, so "confirmed" reads the same everywhere. */
private val ConfirmedGreen = Color(0xFF4CD964)
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun LogbookRow( private fun LogbookRow(
record: QsoRecord, record: QsoRecord,
selectionMode: Boolean,
selected: Boolean,
swipeController: SwipeController, swipeController: SwipeController,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit onClick: () -> Unit,
onLongClick: () -> Unit
) { ) {
val time = remember(record.startUtcMillis) { val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply { SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
@@ -222,11 +290,13 @@ private fun LogbookRow(
} }
val satShort = record.satelliteName.substringBefore('(').trim() val satShort = record.satelliteName.substringBefore('(').trim()
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) } val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
// 右划露出删除按钮(短信式);整行点击不再删除。 val haptics = LocalHapticFeedback.current
// 右划露出删除按钮(短信式);整行点击不再删除。选择模式下右划禁用、长按进入多选。
SwipeRevealRow( SwipeRevealRow(
key = record.id.toString(), key = record.id.toString(),
controller = swipeController, controller = swipeController,
revealAction = onDelete, revealAction = onDelete,
gesturesEnabled = !selectionMode,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
// Row style mirrors the worked-grid QSO details on the map: callsign in // Row style mirrors the worked-grid QSO details on the map: callsign in
@@ -234,10 +304,30 @@ private fun LogbookRow(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .combinedClickable(
onClick = onClick,
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}
)
.padding(horizontal = 4.dp, vertical = 8.dp) .padding(horizontal = 4.dp, vertical = 8.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Checkbox slot: only while picking records for a resubmit.
if (selectionMode) {
Text(
text = if (selected) "✓" else "○",
fontSize = 15.sp,
fontFamily = FontFamily.Monospace,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
modifier = Modifier.padding(end = 6.dp)
)
}
Text( Text(
text = record.theirCallsign, text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -247,14 +337,30 @@ private fun LogbookRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Confirmed contacts show the opposite station's grid set from the LoTW
// report (single or multi-grid, abbreviated) in the worked-grid green
// instead of the word QSL; without any grid the QSL text stays.
if (record.lotwConfirmed) { if (record.lotwConfirmed) {
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) val oppositeGrids = gridsLabel(
record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) }
)
if (oppositeGrids.isNotEmpty()) {
Text(text = oppositeGrids, fontSize = 13.sp, color = ConfirmedGreen, fontFamily = FontFamily.Monospace)
} else {
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
}
} else if (record.lotwUploaded) { } else if (record.lotwUploaded) {
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
} }
} }
Text( Text(
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "), // Own station grid: the set the upload was stamped with, or — for rows from
// before the stamp existed — the grid the QSO was logged under, so every
// stored contact shows where the operator was.
text = listOf(
satShort, record.displayMode, band, time,
gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -469,6 +469,7 @@ fun DataSourcesDialog(
sourceSection( sourceSection(
sectionKey = "sat", sectionKey = "sat",
labelResId = R.string.prefs_data_sources_satellites_label, labelResId = R.string.prefs_data_sources_satellites_label,
hintResId = R.string.prefs_data_sources_sat_hint,
urls = satUrls, urls = satUrls,
listState = listState, listState = listState,
draggedId = satDraggedId, draggedId = satDraggedId,
@@ -483,6 +484,7 @@ fun DataSourcesDialog(
sourceSection( sourceSection(
sectionKey = "tx", sectionKey = "tx",
labelResId = R.string.prefs_data_sources_transceivers_label, labelResId = R.string.prefs_data_sources_transceivers_label,
hintResId = R.string.prefs_data_sources_transceivers_hint,
urls = txUrls, urls = txUrls,
listState = listState, listState = listState,
draggedId = txDraggedId, draggedId = txDraggedId,
@@ -501,6 +503,7 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection( private fun LazyListScope.sourceSection(
sectionKey: String, sectionKey: String,
labelResId: Int, labelResId: Int,
hintResId: Int,
urls: List<Pair<Long, String>>, urls: List<Pair<Long, String>>,
listState: LazyListState, listState: LazyListState,
draggedId: MutableState<Long>, draggedId: MutableState<Long>,
@@ -523,6 +526,15 @@ private fun LazyListScope.sourceSection(
) )
IconCard(action = onAdd, resId = R.drawable.ic_add) IconCard(action = onAdd, resId = R.drawable.ic_add)
} }
// States what the order below actually decides: for satellites the elements come from
// whichever enabled source has the freshest epoch, so the order only picks the name.
Text(
text = stringResource(hintResId),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, bottom = 8.dp)
)
} }
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) -> itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
@@ -94,14 +94,29 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context)) val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder) // Station-grid prefill for the "fix it" jump from the logbook grid check (single-use,
// mirrors the Grid Finder's "Set as LoTW station" hand-off).
val pendingStationGrid by container.pendingLoTWStationGrid.collectAsStateWithLifecycle()
SettingsScreen(
uiState = uiState,
onAction = viewModel::onAction,
onOpenGridFinder = onOpenGridFinder,
pendingStationGrid = pendingStationGrid.orEmpty(),
onFixStationGrid = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
},
onClearStationGridPrefill = { container.setPendingLoTWStationGrid(null) }
)
} }
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
uiState: SettingsState, uiState: SettingsState,
onAction: (SettingsAction) -> Unit, onAction: (SettingsAction) -> Unit,
onOpenGridFinder: () -> Unit onOpenGridFinder: () -> Unit,
pendingStationGrid: String = "",
onFixStationGrid: (List<String>) -> Unit = {},
onClearStationGridPrefill: () -> Unit = {}
) { ) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) } var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) } var showMapSettings by rememberSaveable { mutableStateOf(false) }
@@ -277,13 +292,33 @@ private fun SettingsScreen(
uploadBusy = uiState.logbookUploadBusy, uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage, uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview, preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false }, positionWarning = uiState.logbookPositionWarning,
onDismiss = {
// Leaving the logbook resets the resubmit selection: re-entering starts
// clean — the operator long-presses again to pick records.
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
},
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) }, onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) }, onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) }, onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) }, onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) }, onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) } onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
onFixGrid = { grids ->
onAction(SettingsAction.AbandonLogbookForGridFix)
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
onFixStationGrid(grids)
dialogs.lotwUpload = true
},
selectionMode = uiState.logbookSelectionMode,
selectedIds = uiState.logbookSelectedIds,
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
) )
} }
if (dialogs.lotwUpload) { if (dialogs.lotwUpload) {
@@ -294,7 +329,11 @@ private fun SettingsScreen(
busy = uiState.lotwUploadBusy, busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError, error = uiState.lotwUploadError,
errorDetail = uiState.lotwUploadErrorDetail, errorDetail = uiState.lotwUploadErrorDetail,
onDismiss = { dialogs.lotwUpload = false }, initialGrid = pendingStationGrid,
onDismiss = {
dialogs.lotwUpload = false
onClearStationGridPrefill()
},
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) }, onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) } onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
@@ -85,9 +85,16 @@ data class SettingsState(
val lotwUploadErrorDetail: String = "", val lotwUploadErrorDetail: String = "",
/** One-click logbook upload: prepared preview awaiting confirmation. */ /** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null, val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
val logbookUploadBusy: Boolean = false, val logbookUploadBusy: Boolean = false,
/** User-facing upload message shown inside the logbook dialog ("" = none). */ /** User-facing upload message shown inside the logbook dialog ("" = none). */
val logbookUploadMessage: String = "", val logbookUploadMessage: String = "",
/** Logbook multi-select mode (entered by long-pressing a row); checked records can be
* re-uploaded so a corrected station location updates them on LoTW (resubmit). */
val logbookSelectionMode: Boolean = false,
val logbookSelectedIds: Set<Long> = emptySet(),
/** 指南针校准精度等级 (校准对话框进度条). */ /** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE, val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */ /** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -160,6 +167,16 @@ sealed interface SettingsAction {
data object PrepareLogbookUpload : SettingsAction data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction data object DismissLogbookPreview : SettingsAction
/** The operator acknowledged the position mismatch and wants to upload anyway. */
data object IgnoreLogbookPositionWarning : SettingsAction
/** The operator chose to fix the station location first; the preview is discarded. */
data object AbandonLogbookForGridFix : SettingsAction
/** Long-press on a row: enter selection mode with that record checked. */
data class StartLogbookSelection(val id: Long) : SettingsAction
data class ToggleLogbookSelection(val id: Long) : SettingsAction
data object ExitLogbookSelection : SettingsAction
/** Re-upload the checked records — uploaded/confirmed rows included (resubmit). */
data object ResubmitSelectedLogbook : SettingsAction
data object ClearLogbookMessage : SettingsAction data object ClearLogbookMessage : SettingsAction
// LoTW upload configuration (certificate + station) // LoTW upload configuration (certificate + station)
@@ -34,10 +34,13 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -233,6 +236,12 @@ class SettingsViewModel(
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id)
SettingsAction.ExitLogbookSelection -> exitLogbookSelection()
SettingsAction.ResubmitSelectedLogbook -> resubmitSelectedLogbook()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage() SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration // LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus() SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
@@ -396,7 +405,13 @@ class SettingsViewModel(
// Only the records that actually made it into the TQ8 may be // Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list. // marked uploaded later — never the whole candidate list.
lastLogbookUploadIds = preview.submittedIds lastLogbookUploadIds = preview.submittedIds
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) } _uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") } _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) { } catch (_: Exception) {
@@ -410,8 +425,9 @@ class SettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) } _uiState.update { it.copy(logbookUploadBusy = true) }
val result = lotwUploadRepository.upload(preview.id) val result = lotwUploadRepository.upload(preview.id)
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) { val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
qsoRepository.markUploaded(lastLogbookUploadIds) if (accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
lastLogbookUploadIds = emptyList() lastLogbookUploadIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -420,12 +436,87 @@ class SettingsViewModel(
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry" com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again" com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
} }
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) } _uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
logbookUploadMessage = msg,
// An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
logbookSelectedIds = if (accepted) emptySet() else it.logbookSelectedIds
)
}
} }
} }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) } private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
private fun startLogbookSelection(id: Long) = _uiState.update {
it.copy(logbookSelectionMode = true, logbookSelectedIds = setOf(id))
}
private fun toggleLogbookSelection(id: Long) = _uiState.update {
val selection = it.logbookSelectedIds.toMutableSet().apply { if (!add(id)) remove(id) }
it.copy(logbookSelectedIds = selection)
}
private fun exitLogbookSelection() = _uiState.update {
it.copy(logbookSelectionMode = false, logbookSelectedIds = emptySet())
}
/**
* Re-upload the checked records — already-uploaded and confirmed rows included — so a
* corrected station location reaches LoTW and the server updates the existing contacts.
* The rest is the normal prepare → preview → upload cycle, resubmit flag included.
*/
private fun resubmitSelectedLogbook() {
if (_uiState.value.logbookUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = lotwUploadRepository.prepare(selected, true)
if (preview.count == 0) {
// Every checked record proved un-signable — say why instead of opening
// a dead preview whose POST could only expire.
val message = unavailableUploadSummary(
preview.unavailableSkipped, preview.unavailableReasons, duplicates = preview.duplicateSkipped
).ifBlank { "No QSOs to resubmit" }
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = message) }
return@launch
}
lastLogbookUploadIds = preview.submittedIds
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
}
}
}
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") } private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
private fun loadLoTWUploadStatus() { private fun loadLoTWUploadStatus() {
@@ -81,7 +81,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@@ -591,7 +594,9 @@ private fun AmSatUploadConfirmDialog(
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) } val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.amsat_upload_confirm_title)) },
text = { text = {
Text( Text(
text = stringResource( text = stringResource(
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "548" appVersionCode = "560"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.2.3" appVersionName = "4.4.8-ba7opf.6"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry