chore(i18n): translate all code comments to English

All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
This commit is contained in:
mckero committed 2026-08-05 07:59:35 +00:00
1 parent 19926c58ac
commit c9cab8457d
65 files changed
+443 -443

No files matched your search

@@ -24,8 +24,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* APRS 前台服务:系统通知保活,切换页面不断线。
* START_STICKY:被系统杀掉后自动重启(APRSdroid 同款策略)。
* APRS foreground service: kept alive by a system notification; keeps working across pages.
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
*/
class AprsForegroundService : Service() {
@@ -53,7 +53,7 @@ class AprsForegroundService : Service() {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// 服务没在跑:先启动(未配置会 Toast 提示)
// Service not running: start it first (Toast hint when not configured)
startReporting()
}
reporter?.reportNow()
@@ -80,7 +80,7 @@ class AprsForegroundService : Service() {
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// 上报结果必达:成功 Toast 短显,失败 Toast 长显+原因
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
@@ -112,7 +112,7 @@ class AprsForegroundService : Service() {
startForeground(NOTIF_ID, notif)
}
} catch (e: Exception) {
// 厂商 ROM / 旧系统兼容兜底:启动前台失败只停服务,不崩进程
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
stopSelf()
}
}
@@ -146,9 +146,9 @@ class AprsForegroundService : Service() {
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** 上报位置:优先用设置里的站位(用户拍板);站位无效时实时 GPS 兜底 */
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
private fun stationPosition(): Pair<Double, Double>? {
// ① 站位(设置页设置的位置)
// 1. Station QTH (position set in settings)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
@@ -156,7 +156,7 @@ class AprsForegroundService : Service() {
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// ② 兜底:实时 GPS 最后位置
// 2. Fallback: last live GPS position
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
@@ -30,7 +30,7 @@ class MainApplication : Application(), IContainerProvider {
private lateinit var container: IMainContainer
/** 全局崩溃捕获:堆栈写入 files/crash_log.txt,重启后可查看(用户要求错误报告) */
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
private fun installCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -148,10 +148,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
@@ -159,8 +159,8 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
@@ -171,7 +171,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// 更多菜单打开时拦截返回: 先关菜单
// Intercept Back while the More menu is open: close the menu first
BackHandler(enabled = moreExpanded) { moreExpanded = false }
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
@@ -212,7 +212,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
)
}
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
if (moreNavItems.isNotEmpty()) {
item(
icon = {
@@ -346,7 +346,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
@@ -1,9 +1,9 @@
/*
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
*
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
* (MainScreen 的 AnimatedVisibility + spring)驱动。
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
* (MainScreen's AnimatedVisibility + spring).
*/
package com.rtbishop.look4sat
@@ -9,8 +9,8 @@ import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP 客户端(从 APRSdroid TcpUploader.scala 逆向移植)。
* 纯文本协议:登录一行 + 每行一个包;断线 30 秒重连。
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
*/
class AprsIsClient(
private val host: String,
@@ -30,7 +30,7 @@ class AprsIsClient(
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** 建立连接 + 登录(同步阻塞,调用方放后台线程) */
/** Connect + login (synchronous/blocking; call from a background thread) */
@Throws(Exception::class)
fun connect() {
disconnect()
@@ -43,10 +43,10 @@ class AprsIsClient(
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// 登录行
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// 读登录响应(aprsc 返回 # logresp ... verified/unverified)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
@@ -54,14 +54,14 @@ class AprsIsClient(
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// 恢复超时
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
}
/**
* 发送一个 APRS 包(一行)并尝试读服务器确认。
* 返回 null=发送失败;Pair(ok, detail)=发送结果(服务器错误原文在 detail)
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
@@ -69,7 +69,7 @@ class AprsIsClient(
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// 尝试读服务器响应(短超时 3s;APRS-IS 对格式错误会回错误行)
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
@@ -88,7 +88,7 @@ class AprsIsClient(
}.getOrElse { Pair(true, "OK") }
}
/** 读一行(服务器响应,超时抛异常) */
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
}
@@ -10,10 +10,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS 连接状态 */
/** APRS connection state */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS 配置(SharedPreferences 持久化,填入即保存) */
/** APRS config (persisted in SharedPreferences, saved as filled) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
@@ -29,7 +29,7 @@ data class AprsConfig(
val includeAltitude: Boolean = true
)
/** APRS 上报结果 */
/** APRS report result */
data class AprsReport(
val timestamp: Long,
val packet: String,
@@ -37,7 +37,7 @@ data class AprsReport(
val detail: String
)
/** 上报调度器(周期上报 + 手动触发),连接管理放在前台服务 */
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
@@ -51,7 +51,7 @@ class AprsReporter(
val isRunning: Boolean get() = job?.isActive == true
/** 启动周期上报(前台服务调用) */
/** Start periodic reporting (called by the foreground service) */
fun start() {
stop()
val cfg = configProvider()
@@ -75,7 +75,7 @@ class AprsReporter(
onState(AprsState.Idle)
}
/** 手动触发一次(立即上报,不等待周期) */
/** Trigger one report manually (immediately, without waiting for the cycle) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
@@ -112,7 +112,7 @@ class AprsReporter(
}
}
/** 构造位置包:BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<状态文本 */
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
@@ -3,8 +3,8 @@ package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS 配置存取 + 服务 action 常量(被 feature/settings 和 app 服务共用,
* 避免 feature 反向依赖 app)。
* APRS config storage + service action constants (shared by feature/settings and the app service,
* so feature never depends on app).
*/
object AprsStore {
@@ -28,7 +28,7 @@ object AprsStore {
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** 读取配置(填入即保存,无需每次打开重填) */
/** Read config (saved as filled; no need to re-enter each time) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
@@ -45,7 +45,7 @@ object AprsStore {
)
}
/** 上次上报结果(供设置卡片显示) */
/** Last report result (shown on the settings card) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
@@ -65,7 +65,7 @@ object AprsStore {
.apply()
}
/** 保存配置 */
/** Save config */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
@@ -103,7 +103,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
@@ -5,7 +5,7 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
/** AMSAT status repository: fetch HTML -> parse -> SatStatusPage */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
override suspend fun fetchStatus(): SatStatusPage? {
@@ -69,7 +69,7 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
}.filterValues { it.isNotBlank() }
@@ -81,7 +81,7 @@ class DatabaseRepo(
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
@@ -187,9 +187,9 @@ class SettingsRepo(
return true
}
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
override suspend fun setStationPosition(): Boolean {
// 权限前置: 无定位权限直接失败(不吞异常)
// Permission gate: fail fast without location permission (no swallowed exceptions)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
@@ -202,7 +202,7 @@ class SettingsRepo(
val signal = android.os.CancellationSignal()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val executor = java.util.concurrent.Executor { handler.post(it) }
// 15 秒超时
// 15 s timeout
val timeout = handler.postDelayed({
signal.cancel()
if (cont.isActive) cont.resume(false) { }
@@ -432,8 +432,8 @@ class SettingsRepo(
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
@@ -8,17 +8,17 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import java.util.regex.Pattern
/**
* AMSAT 卫星状态页解析器(https://amsat.org/status/)
* AMSAT satellite status page parser (https://amsat.org/status/)
*
* 页面结构(静态 HTML, 2026-08 实测):
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
* docTips.show('id') 链接
* - 报告详情在页面内嵌 JS:
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
* Page structure (static HTML, verified 2026-08):
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
* docTips.show('id') link
* - Report details live in the page's inline JS:
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
*
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
*/
object AmSatParser {
@@ -38,14 +38,14 @@ object AmSatParser {
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
)
/** 解析完整页面 */
/** Parse the full page */
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
val reports = parseReports(html)
val statuses = parseStatusTable(html, reports)
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
}
/** 提取全部报告(tips.* JS 数组) */
/** Extract all reports (tips.* JS arrays) */
fun parseReports(html: String): Map<String, SatReport> {
val map = mutableMapOf<String, SatReport>()
val m = tipRe.matcher(html)
@@ -64,7 +64,7 @@ object AmSatParser {
return map
}
/** 解析状态表(48 行卫星) */
/** Parse the status table (48 satellite rows) */
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
val result = mutableListOf<SatStatus>()
val tables = extractTables(html)
@@ -79,7 +79,7 @@ object AmSatParser {
val cellList = mutableListOf<String>()
while (cells.find()) cellList.add(cells.group(1))
if (rowIndex == 0) {
// 表头: Name + 6 天(每个 colspan=12)
// Header: Name + 6 days (each colspan=12)
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
rowIndex++
continue
@@ -1,7 +1,7 @@
/* LotwSatellitesRepo.kt — LoTW 卫星列表运行时刷新(4.5.5)。
* 下载 ARRL 官方 config.tq6(gzip XML), 解析 <satellite name="...">,
* 更新 LotwSatellites 动态表 + SharedPreferences 持久化(重启不丢)。
* 触发: 设置页 WaveLog 区「更新卫星列表」按钮(用户拍板: 手动触发)。
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
*/
package com.rtbishop.look4sat.core.data.wavelog
@@ -20,7 +20,7 @@ class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
/** 从 SharedPreferences 恢复上次下载的表(进程启动时调用) */
/** Restore the last downloaded list from SharedPreferences (call at process start) */
override fun restore() {
val saved = prefs.getString(KEY_NAMES, null)
if (!saved.isNullOrBlank()) {
@@ -28,7 +28,7 @@ class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
}
}
/** 下载 config.tq6 → 解析 → 更新内存 + 持久化。返回结果供 UI 提示 */
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
try {
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
@@ -39,7 +39,7 @@ class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
val raw = conn.getInputStream()
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
val text = reader.use { it.readText() }
// 解析 <satellite name="XXX" ...> — 格式固定(ARRL 官方 XML)
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
.map { it.groupValues[1].uppercase() }
.toSet()
@@ -102,7 +102,7 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
@@ -4,12 +4,12 @@ import kotlin.math.abs
import kotlin.math.round
/**
* APRS-IS 协议核心(纯 Kotlin,无 Android 依赖)。
* 从 APRSdroid 1.6.3d 逆向移植:AprsPacket$.scala + ab0oo Position.java。
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
*/
object AprsPacket {
/** APRS-IS passcode 算法(标准):0x73E2 初值,呼号大写+\0,每对字符 XOR */
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
fun passcode(callsign: String): Int {
val s = callsign.split("-")[0].uppercase() + "\u0000"
var hash = 29666 // 0x73E2
@@ -21,30 +21,30 @@ object AprsPacket {
return hash and 0x7FFF
}
/** 登录行:user CALL-SSID pass XXXX vers XXXX */
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** 呼号-SSID 拼接(BG7NTA + 5 → BG7NTA-5) */
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
return "$callsign-$ssid"
}
/** 可选距离过滤: filter r/lat/lon/dist */
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/** 高度扩展 /A=00000(英尺) */
/** Altitude extension /A=00000 (feet) */
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
}
/** 航速航向扩展(节/度) */
/** Speed/course extension (knots/degrees) */
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt()
@@ -53,8 +53,8 @@ object AprsPacket {
}
/**
* APRS 位置编码(ab0oo Position.java 逆向移植)。
* 非压缩:DDMM.MMN/DDDMM.MME;压缩:base91。
* APRS position encoding (reverse-ported from ab0oo Position.java).
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
*/
class AprsPosition(
val latitude: Double,
@@ -64,14 +64,14 @@ class AprsPosition(
val positionAmbiguity: Int = 0
) {
/** 非压缩格式(APRS-IS 默认上报格式) */
/** Uncompressed format (APRS-IS default reporting format) */
fun toUncompressedString(): String {
val lat = getDMS(latitude, true)
val lon = getDMS(longitude, false)
return "$lat$symbolTable$lon$symbolCode"
}
/** 压缩格式(base91,标准 APRS 算法) */
/** Compressed format (base91, standard APRS algorithm) */
fun toCompressedString(): String {
val jRound = round((90.0 - latitude) * 380926.0).toLong()
val j = jRound / 753571 + 33
@@ -91,7 +91,7 @@ class AprsPosition(
((j8 % 91).toInt() + 33).toChar() + symbolCode
}
/** 单轴 DMS 编码(百分位) */
/** Single-axis DMS encoding (hundredths) */
private fun getDMS(value: Double, isLat: Boolean): String {
var iRound = round(value * 6000.0).toInt()
if (iRound < 0) iRound = -iRound
@@ -1,6 +1,6 @@
package com.rtbishop.look4sat.core.domain.model
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
@@ -10,26 +10,26 @@ data class SatReport(
val timeUtc: String // 2:46-:59 UTC
)
/** 单个 2 小时槽的状态 */
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** 卫星一天的状态(12 个 2 小时槽) */
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** 单个卫星 6 天状态 */
/** One satellite, 6 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
)
/** 页面整体解析结果 */
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
@@ -63,13 +63,13 @@ data class OtherSettings(
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
val hiddenScreens: List<String> = emptyList(),
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
val screenOrder: List<String> = emptyList(),
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
val subMenuOrder: List<String> = emptyList(),
// WaveLog 日志系统(4.5.2): 服务器配置
// WaveLog logging (4.5.2): server config
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
@@ -1,9 +1,9 @@
/* QrzGridClient.kt — QRZ 呼号网格爬虫(4.5.5, domain 层纯 JVM)。
* 原理(已实测): 带用户 QRZ 登录 Cookie GET https://www.qrz.com/db/{呼号},
* 页面 Detail 表格里有 <td class="dh">Grid Square</td><td class="di">XXX</td>。
* 匿名无 Cookie 拿不到 Detail(已确认); 对方没填网格时表格无此行(返回 null)。
* Cookie 由用户在设置页粘贴(EditThisCookie 导出的 JSON 数组 或 原始 Cookie 串),
* 绝不内置/进构建。
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
@@ -13,11 +13,11 @@ import java.net.URL
object QrzGridClient {
/** 解析用户粘贴的 Cookie(兼容两种格式): EditThisCookie JSON 数组 或 原始 "k=v; k=v" 串 */
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON 数组格式: [{"name":"qz_userid","value":"1266043",...}, ...]
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
if (text.startsWith("[")) {
return try {
val arr = org.json.JSONArray(text)
@@ -34,7 +34,7 @@ object QrzGridClient {
return text
}
/** 检测 cookies 对应的登录呼号(请求 db 首页, 提取账号菜单呼号)。失败返回 null */
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (cookieHeader.isBlank()) return@withContext null
try {
@@ -45,13 +45,13 @@ object QrzGridClient {
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// 登录账号菜单(实测结构): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** 查询呼号的网格。返回 null = 未填/查不到(静默, 不阻塞录入) */
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
@@ -62,7 +62,7 @@ object QrzGridClient {
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Detail 表格 Grid Square 行(已实测格式)
// Detail table Grid Square row (verified format)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
@@ -2,8 +2,8 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT 卫星状态数据源 */
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -43,7 +43,7 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
// WaveLog 日志(4.5.2)
// WaveLog logging (4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
@@ -45,7 +45,7 @@ interface ISettingsRepo {
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
suspend fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
@@ -23,6 +23,6 @@ interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
/** Fetch AMSAT status page HTML (with UA; null = failure) */
suspend fun getStatusHtml(): String?
}
@@ -18,7 +18,7 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
@@ -20,6 +20,6 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast {
operator fun invoke(message: String)
/** 按资源 ID 显示(四语文案) */
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
}
@@ -118,7 +118,7 @@ fun qthToSquare(locator: String): String {
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
* Mirrors the neighbor logic decompiled from the QTH Locator app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
@@ -1,5 +1,5 @@
/* ILotwSatellitesRepo.kt — LoTW 卫星列表刷新接口(domain 层, 4.5.5)。
* 实现: core/data 的 LotwSatellitesRepo(下载 ARRL config.tq6)。
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -1,7 +1,7 @@
/* LotwSatellites.kt — LoTW 支持的卫星名列表(112 个, 静态内置)。
* 来源: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, ARRL 官方!)
* WaveLog 用同一来源更新 satellite 表 lotw 字段(Update_model.php lotw_sats())。
* 运行时可由 LotwSatellitesRepo 手动刷新(设置页 WaveLog 区按钮触发)。
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -11,11 +11,11 @@ object LotwSatellites {
@Volatile
private var dynamicNames: Set<String> = emptySet()
/** 当前生效的卫星名: 静态内置 ∪ 运行时更新 */
/** Effective satellite names: static embedded set union runtime updates */
val names: Set<String>
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
/** 运行时更新(设置页按钮 → LotwSatellitesRepo.refresh() 后调用) */
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
fun updateNames(newNames: Set<String>) {
if (newNames.isNotEmpty()) dynamicNames = newNames
}
@@ -1,12 +1,12 @@
/*
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
*
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
* 策略: 优先 v2, 404 自动降级 v1。
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
* Strategy: try v2 first, auto-fallback to v1 on 404.
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -20,7 +20,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** 站点信息(GET /api/v2/station/{id} 结果) */
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
val id: Int,
val name: String,
@@ -37,7 +37,7 @@ object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
@@ -46,7 +46,7 @@ object WaveLogApi {
return u
}
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
@@ -55,7 +55,7 @@ object WaveLogApi {
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
@@ -66,7 +66,7 @@ object WaveLogApi {
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// 无 index.php 的 v1 尝试
// v1 attempt without index.php
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
@@ -79,7 +79,7 @@ object WaveLogApi {
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
@@ -102,19 +102,19 @@ object WaveLogApi {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
}
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// 匹配逻辑(仿 WaveLog 卫星表 name/displayname 匹配 + LoTW 列表):
// 1. 已在 LoTW 列表(常见 TLE 名 == 通用名) → 原样返回
// 2. 不在 → 查 Celestrak 别名映射(SAUDISAT 1C → SO-50 等) → 映射后必须在 LoTW 列表
// 3. 仍不匹配 → 原样返回(不阻塞上传, QSO 仍保存)
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
val commonName = mapOf(
"ZARYA" to "ARISS",
"ARISS" to "ARISS",
@@ -128,7 +128,7 @@ object WaveLogApi {
return if (candidate in LotwSatellites.names) candidate else main
}
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
url: String,
apiKey: String,
@@ -141,7 +141,7 @@ object WaveLogApi {
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso(JSON 字段)
// v2: POST /index.php/api/v2/qso (JSON fields)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
@@ -160,7 +160,7 @@ object WaveLogApi {
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso(key 在 body + ADIF)
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
@@ -170,14 +170,14 @@ object WaveLogApi {
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
// 无 index.php 的 v1
// v1 without index.php
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
}
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
@@ -204,7 +204,7 @@ object WaveLogApi {
}
}
/** 通用 HTTP 请求(返回 code + body) */
/** Generic HTTP request (returns code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
@@ -230,7 +230,7 @@ object WaveLogApi {
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML 错误页
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
@@ -1,22 +1,22 @@
/*
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
* 由 core/data 用 SharedPreferences 实现。
* 队列上限 500 条(超出丢最旧)。
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** 存储抽象(SharedPreferences 实现见 core/data) */
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
@@ -73,18 +73,18 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
save(all().filter { it.id !in ids })
}
/** 标记为已上传(保留在队列, 表格打勾) */
/** Mark as uploaded (kept in the queue; checkmark in the table) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** 更新某条 QSO 的对方网格(QRZ 爬虫异步回填, 4.5.5) */
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
/** 移除所有已上传条目(可选项, 保持队列精简) */
/** Remove all uploaded entries (optional; keeps the queue lean) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
@@ -1,10 +1,10 @@
/*
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
*
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
* → POST /api/v2/qso → 成功移出队列。
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
* 确认后带 force=true 重试本批。
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
* -> POST /api/v2/qso -> success removes from the queue.
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
* retrying the batch with force=true once confirmed.
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -26,10 +26,10 @@ class WavelogUploader(
private val queue: WavelogQueue
) {
// 台站网格缓存(每次上传前刷新; 失败用旧值)
// Station grid cache (refreshed before each upload; stale value kept on failure)
private var cachedStationGrid: String? = null
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
@@ -39,14 +39,14 @@ class WavelogUploader(
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. 网格检测: 云端站点网格前 4 位 vs 当前站位网格前 4 位
// (防站点配置错误; 与 QSO 对方网格无关 — 用户澄清)
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
if (!force) {
val userGrid = userQthGrid()
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
@@ -54,7 +54,7 @@ class WavelogUploader(
}
}
// 3. 逐条上传。ADIF 网格字段 = 对方网格(通联对象), 爬虫接入前留空
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
val entries = queue.all()
var ok = 0
var fail = 0
@@ -85,7 +85,7 @@ class WavelogUploader(
return cachedStationGrid
}
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
@@ -87,7 +87,7 @@ class QthConverterTest {
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH定位器 screenshot: OL42
// Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
@@ -54,10 +54,10 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
}
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
// UI settings: default main-menu page order (5 bottom-bar slots)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
// UI settings: default More-menu order (pages behind "More")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
@Serializable
@@ -6,11 +6,11 @@ import com.ve3nea.morse_expert.MainActivity;
import H2.b;
/**
* 照搬自 Morse Expert 1.15 B.RunnableC0001b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 5 参 (Object,Object,int,int,boolean),
* 字段 = i(Object)/h(Object)/g(int)/f(int); run() 仅保留 case 4(f==2 对应 pswitch_4:
* 解码文本 UI 更新, H2.b 调用), 其余 case(权限/Intent/通知等库代码)已裁剪。
* 注: jadx 4 参构造为错误还原, 已按 smali 修正。
* Ported from Morse Expert 1.15 B.RunnableC0001b; obfuscated class name kept.
* R8-merged Runnable surgery: constructor per the real smali 5-arg signature (Object,Object,int,int,boolean),
* fields = i(Object)/h(Object)/g(int)/f(int); run() keeps only case 4 (f==2 maps to pswitch_4:
* decode-text UI update, invoked by H2.b); other cases (permission/Intent/notification library code) were trimmed.
* Note: jadx's 4-arg constructor was a wrong reconstruction; fixed per smali.
*/
public final class RunnableC0001b implements Runnable {
public final /* synthetic */ int f;
+6 -6
View File
@@ -7,11 +7,11 @@ import java.util.Locale;
import d1.AbstractC1518b;
/**
* 照搬自 Morse Expert 1.15 B0.b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 (H2/b, String, [I) -> f=4;
* 只保留 case 4(状态栏更新: dBFS/Hz/dB/WPM + ScaleView 频率标记, H2.b 调用),
* 其余 case(WorkManager/动画等库代码)已裁剪。
* r3 = scaleView.getHeight()(smali 证实, jadx 寄存器未展开)。
* Ported from Morse Expert 1.15 B0.b; obfuscated class name kept.
* R8-merged Runnable surgery: constructor per the real smali signature (H2/b, String, [I) -> f=4;
* keeps only case 4 (status-bar update: dBFS/Hz/dB/WPM + ScaleView frequency markers, invoked by H2.b),
* other cases (WorkManager/animation library code) were trimmed.
* r3 = scaleView.getHeight() (smali-verified; jadx never expanded the registers).
*/
public final class b implements Runnable {
public final /* synthetic */ int f;
@@ -50,7 +50,7 @@ public final class b implements Runnable {
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
int i4 = iArr[1];
scaleView.f11041g = iArr[5];
// r3 = scaleView.getHeight()(smali 证实)
// r3 = scaleView.getHeight() (smali-verified)
scaleView.f = (scaleView.getHeight() - 1) - Math.round(((Math.round(i4 * 0.128f) - H2.b.f606z) * scaleView.getHeight()) / H2.b.f603B);
scaleView.invalidate();
return;
+3 -3
View File
@@ -6,9 +6,9 @@ import android.opengl.GLUtils;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
/**
* 照搬自 Morse Expert 1.15, 类名保持混淆原名。
* R8 合并 Runnable 手术: 只保留 case 1(瀑布图频谱->OpenGL 纹理, H2.b 调用),
* 其余 case(库/广告)已裁剪。
* Ported from Morse Expert 1.15; obfuscated class name kept.
* R8-merged Runnable surgery: keeps only case 1 (waterfall spectrum -> OpenGL texture, invoked by H2.b),
* other cases (library/ads) were trimmed.
*/
public final /* synthetic */ class n implements Runnable {
public final /* synthetic */ int f;
+5 -5
View File
@@ -7,9 +7,9 @@ import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.ScaleView;
/**
* 照搬自 Morse Expert 1.15 (com.ve3nea.morse_expert), 类名保持混淆原名。
* R8 合并类手术: 只保留 case 1(瀑布图触摸选频率=应用逻辑),
* 其余 case(广告 P4 / appcompat 焦点 / 弹窗)为库代码, 已裁剪。
* Ported from Morse Expert 1.15 (com.ve3nea.morse_expert); obfuscated class name kept.
* R8-merged class surgery: keeps only case 1 (waterfall touch-to-select frequency = app logic),
* other cases (ad P4 / appcompat focus / dialogs) are library code, trimmed.
*/
public final class g implements View.OnTouchListener {
public final /* synthetic */ int f;
@@ -29,8 +29,8 @@ public final class g implements View.OnTouchListener {
if (i4 == 1) {
MainActivity mainActivity = (MainActivity) obj;
if (motionEvent.getActionMasked() == 0 && mainActivity.f11037F != null) {
// smali 证实: p1 先被重赋值为 f11035D.h(ScaleView)再 getHeight;
// 公式 = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
// smali-verified: p1 is reassigned to f11035D.h (ScaleView) before getHeight;
// formula = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
float y3 = motionEvent.getY();
int round = Math.round((H2.b.f606z + ((((scaleView.getHeight() - 1) - y3) * H2.b.f603B) / scaleView.getHeight())) / 0.128f);
+1 -1
View File
@@ -35,7 +35,7 @@ public final class a implements ActionMode.Callback {
@Override // android.view.ActionMode.Callback
public final boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
this.f564a.f11032n = true;
// 原: if (menu.findItem(R.id.shareText) == null) 检查(shareText 为原 APK 资源, 简化后始终添加)
// Original: if (menu.findItem(R.id.shareText) == null) check (shareText is an original APK resource; always added after simplification)
menu.add(0, 74, 0, "Share").setIcon(R.drawable.ic_baseline_share_24);
menu.add(0, 73, 0, "Save").setIcon(R.drawable.ic_baseline_save_24);
return true;
+4 -4
View File
@@ -467,7 +467,7 @@ public final class b {
fArr3[i11 - i10] = f4;
i11++;
} else {
// smali: 循环内无 try; 录音写入 try 在循环后(见下)
// smali: no try inside the loop; the recording-write try sits after the loop (see below)
break;
}
}
@@ -522,7 +522,7 @@ public final class b {
}
tNativeDecoder.setHamMode(this.f623s.get());
} catch (Exception e5) {
// jadx 幻觉区(空循环/空 if 为 catch 错位还原)已裁剪; 原 smali: 记录异常后跳回正常流程尾部
// jadx phantom zones (empty loops/ifs from misplaced catch reconstruction) trimmed; original smali: logs the exception, then jumps back to the normal flow tail
exc = e5;
bVar = this;
Log.e("Audio Processor", "Exception", exc);
@@ -544,7 +544,7 @@ public final class b {
try {
handler.post(new RunnableC0001b(bVar, spannableStringBuilder, tNativeDecoder.getDeleteCount(), 2, false));
} catch (Exception e6) {
// jadx 幻觉区已裁剪(同 catch e5); 原 smali: 记录异常后跳回正常流程尾部
// jadx phantom zone trimmed (same catch e5); original smali: logs the exception, then jumps back to the normal flow tail
exc = e6;
Log.e("Audio Processor", "Exception", exc);
}
@@ -552,7 +552,7 @@ public final class b {
bVar = this;
}
} catch (Exception e7) {
// jadx 幻觉(e 变量)已裁剪
// jadx phantom (the e variable) trimmed
bVar = this;
}
i4 = f603B;
+6 -6
View File
@@ -1,11 +1,11 @@
package I0;
/**
* 照搬自 Morse Expert 1.15 I0.d, 类名保持混淆原名。
* R8 合并类手术: 只保留 (i3.d, int, int, float[]) FFT 任务构造;
* run() 按 smali 原样还原: R8 已将任务体优化为死代码(aget 后 throw null,
* 永不执行), 保留原语义(行为与原 APK 完全一致)。
* SystemForegroundService(work 库)case 已裁剪。
* Ported from Morse Expert 1.15 I0.d; obfuscated class name kept.
* R8-merged class surgery: keeps only the (i3.d, int, int, float[]) FFT task constructor;
* run() restored verbatim per smali: R8 already optimized the body into dead code (aget then throw null,
* never executed); original semantics kept (behavior identical to the original APK).
* The SystemForegroundService (work library) case was trimmed.
*/
public final class d implements Runnable {
public final /* synthetic */ int f = 0;
@@ -37,6 +37,6 @@ public final class d implements Runnable {
return;
}
float f = ((float[]) this.f655i)[i7 * 2];
// R8 原代码: aget 后 throw null(死代码, 永不执行) —— 照搬语义, 不执行任何计算
// Original R8 code: aget then throw null (dead code, never runs) - semantics ported, no computation executes
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ public final class a {
int i11 = (i9 >> 8) & 255;
int i12 = i9 & 255;
float f4 = i8;
// r10 = iArr2[i7] (下一个锚点色, smali: aget v10, v4, v8)
// r10 = iArr2[i7] (next anchor color, smali: aget v10, v4, v8)
float f5 = (((iArr2[i7] >> 16) & 255) - i10) / f4;
float f6 = (((iArr2[i7] >> 8) & 255) - i11) / f4;
float f7 = ((iArr2[i7] & 255) - i12) / f4;
+3 -3
View File
@@ -1,9 +1,9 @@
package K1;
/**
* 照搬自 Morse Expert 1.15 K1.a 精简版: 仅保留 k3 FFT 库引用的内存统计静态字段
* (K1.a.c 长整型记账)。原 K1.a(828 行)的其余方法为状态栏/库代码, 已裁剪
* (状态栏逻辑已在 MainActivity 内联)。
* Ported from Morse Expert 1.15 K1.a (slimmed): keeps only the memory-accounting static fields the k3 FFT library references
* (K1.a.c long accounting). The rest of the original K1.a (828 lines) was status-bar/library code and was trimmed
* (status-bar logic is inlined in MainActivity).
*/
public class a {
public static long c = 0;
@@ -1,17 +1,17 @@
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
* CwDecodeScreen.kt - Look4Sat CW decode page (Compose shell).
*
* 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类):
* - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/
* Ported from Morse Expert 1.15 com.ve3nea.morse_expert.MainActivity (converted to a plain controller class):
* - AndroidView embeds the original activity_main.xml (ConstraintLayout root, with decodedTextView/scaleView/
* statusTextView/verticalLayout/waterfallView);
* - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致
* (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy);
* - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()),
* 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E);
* - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。
* - Lifecycle onCreate/onResume/onPause/onDestroy fully delegated to the controller, same order as the original Activity
* (onCreate must run before onResume; onDispose runs onPause then onDestroy);
* - Mic permission (RECORD_AUDIO) is managed on the Compose side; onPermissionGranted() (= v()) is called once granted,
* an initialized flag guarantees it runs only once (v() news an a() which overwrites f11036E);
* - The original options_menu Clear/Pause/Save/Record/Settings became a top button row (icons from the module's original drawables).
*
* 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为
* decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。
* Note: setImmersive(window, false) inside the controller's onCreate restores the host window to
* decorFitsSystemWindows(true) (ported original behavior; affects the whole host Activity).
*/
package com.rtbishop.look4sat.feature.cw
@@ -67,9 +67,9 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val activity = remember(context) {
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
}
// 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例
// Ported controller (plain class, not an Activity); new instance per page entry
val controller = remember { MainActivity() }
// 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图
// Inflate the original layout early so AndroidView and the controller's onCreate share one root view
val rootView = remember(context) {
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
}
@@ -81,11 +81,11 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
// v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次
// v() news an a() which overwrites f11036E; must run exactly once
var initialized by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
// 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy
// Lifecycle: onCreate before onResume; onDispose runs onPause then onDestroy
DisposableEffect(Unit) {
controller.onCreate(activity, rootView)
controller.onResume()
@@ -95,12 +95,12 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
}
}
// 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里
// Start the decode core after permission granted; both "already granted on entry" and "granted via dialog" go here
LaunchedEffect(permissionGranted) {
if (permissionGranted && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
// v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音
// (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等)
// v() only creates the core; the page is already resumed, so run onResume again to start recording immediately
// (AudioRecord is freshly created so startRecording won't double-start; GLSurfaceView.onResume is idempotent)
controller.onResume()
initialized = true
}
@@ -114,7 +114,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
}
// 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出
// Original tap_back_again_to_close: single Back shows a hint; press again within 2 s to exit
BackHandler {
if (!controller.handleBackPress()) navigateUp()
}
@@ -128,7 +128,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
.fillMaxSize()
.systemBarsPadding()
) {
// 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮)
// Original options_menu: Pause/Clear/Save (original icons) + Record/Settings (text buttons)
Row(
modifier = Modifier
.fillMaxWidth()
@@ -154,7 +154,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
contentDescription = stringResource(R.string.save_text)
)
}
// recordSignals 需要解码核心(f11037F), 未初始化时禁用
// recordSignals needs the decode core (f11037F); disabled until initialized
TextButton(
onClick = { if (initialized) controller.recordSignals() },
enabled = initialized
@@ -172,7 +172,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
modifier = Modifier.fillMaxSize()
)
if (!permissionGranted) {
// 未授予麦克风权限: 遮罩 + 提示文字 + 请求按钮
// Mic permission not granted: overlay + hint text + request button
Box(
modifier = Modifier
.fillMaxSize()
@@ -193,7 +193,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
Text(stringResource(R.string.cw_grant_permission))
}
if (permanentlyDenied) {
// 勾选了"不再询问": 引导去系统设置开启
// "Never ask again" checked: guide user to system settings
TextButton(onClick = {
activity.startActivity(
Intent(
@@ -47,7 +47,7 @@ import androidx.compose.ui.unit.dp
import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt
/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */
/** Keys/defaults ported from the original Morse Expert (root_preferences.xml + SettingsActivity logic). */
private const val KEY_MESSAGE_TYPE = "message_type"
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
private const val DEFAULT_MESSAGE_TYPE = "general_text"
@@ -55,7 +55,7 @@ private const val DEFAULT_TEXT_FONT_SIZE = 18
private const val MIN_FONT_SIZE = 7
private const val MAX_FONT_SIZE = 99
/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */
/** Preset palette (9 colors, 3x3 grid); the original ColorPreferenceCompat used a third-party colorpicker, not imported. */
private val PaletteColors = listOf(
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
@@ -63,11 +63,11 @@ private val PaletteColors = listOf(
)
/**
* CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。
* 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色
* (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。
* 存储: SharedPreferences(getPackageName() + "_preferences")。
* 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。
* CW settings dialog (replaces the original Morse Expert SettingsActivity).
* Items and keys ported from the original APK: message_type / text_font_size / 9 colors
* (I2.b.f663b keys, I2.b.f664d defaults, I2.b.c English titles).
* Storage: SharedPreferences(getPackageName() + "_preferences").
* OK saves and calls controller.onResume() to take effect at once; Cancel/outside-tap only closes without saving.
*/
@Composable
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
@@ -142,7 +142,7 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
editor.putInt(I2.b.f663b[i], colorValues[i])
}
editor.apply()
// 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位
// The original re-read all settings in MainActivity.onResume() when SettingsActivity returned; ported logic in place
controller.onResume()
onDismiss()
}) { Text("OK") }
@@ -1,8 +1,8 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Cleaner(照搬 Morse Expert 1.15 k3 FFT 库的清理回调,
* 实际 FFT 1024 点不触发 native 路径)。
* Compile-time stub: Android has no sun.misc.Cleaner (ported from Morse Expert 1.15's k3 FFT library cleanup callback,
* the real 1024-point FFT never hits the native path).
*/
public class Cleaner {
public static Cleaner create(Object referent, Runnable thunk) {
@@ -1,8 +1,8 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Unsafe(运行时通过反射获取,失败时 k3.r.f12109a 为 null)。
* 照搬自 Morse Expert 1.15(k3 FFT 库的大数组 native 路径 >1GB 不触发,实际 FFT 走 float[] 分支)。
* Compile-time stub: Android has no sun.misc.Unsafe (obtained via reflection at runtime; k3.r.f12109a is null on failure).
* Ported from Morse Expert 1.15 (the k3 FFT library's >1GB native path never triggers; real FFT uses the float[] branch).
*/
public class Unsafe {
public long allocateMemory(long bytes) {
@@ -21,9 +21,9 @@ import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 照搬自 Morse Expert 1.15 DecodedTextView, 类名保持混淆原名。
* 适配: 原 extends l.C1702e0(appcompat 混淆类) -> 直接 extends TextView
* (C1702e0 仅提供 TextView 兼容功能, 本类未使用其特有方法)。
* Ported from Morse Expert 1.15 DecodedTextView; obfuscated class name kept.
* Adaptation: originally extends l.C1702e0 (obfuscated appcompat class) -> now extends TextView directly
* (C1702e0 only provided TextView-compatible behavior; this class never used its specific methods).
*/
public class DecodedTextView extends TextView {
@@ -52,8 +52,8 @@ public class MainActivity {
}
/**
* applyImmersive=false 用于嵌入小容器(如转发器 CW 面板):
* 不触碰宿主窗口的系统栏标志(避免影响整个 Activity)。
* applyImmersive=false is used when embedding in a small container (e.g. the transponder CW panel):
* does not touch the host window's system-bar flags (avoids affecting the whole Activity).
*/
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout, boolean applyImmersive) {
this.mActivity = activity;
@@ -1,7 +1,7 @@
package d1;
/* renamed from: d1.b, reason: case insensitive filesystem */
/* 照搬自 Morse Expert 1.15, 混淆名保持。b(Parcel)/c(y2.d) 为库工具已裁剪(L1/y2 库依赖)。 */
/* Ported from Morse Expert 1.15; obfuscated names kept. b(Parcel)/c(y2.d) were library utilities, trimmed (L1/y2 library deps). */
public abstract /* synthetic */ class AbstractC1518b {
public static float a(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f - f4);
+8 -8
View File
@@ -455,13 +455,13 @@ public abstract class c {
if (z3) {
i4 ^= 2;
}
// smali 证实: f() = sin(象限 switch!):
// smali-verified: f() = sin (quadrant switch!):
// case 0 -> g(d5,0)(sin(0-π/2))
// case 1 -> c(d5,0)(sin(π/2-π) = cos(余角))
// case 2 -> -g(d5,0)(sin(π-3π/2) = -sin(余角))
// case 3 -> -c(d5,0)(sin(3π/2-2π) = -cos(余角))
// jadx 畸形嵌套 if 把 case 0 还原成 return NaN(小角度全 NaN!),
// 且 case 1/2/3 全走 g() —— 旋转因子表 329 个 NaN + 频谱错乱的根源!
// case 1 -> c(d5,0) (sin(pi/2-pi) = cos of the complement)
// case 2 -> -g(d5,0) (sin(pi-3pi/2) = -sin of the complement)
// case 3 -> -c(d5,0) (sin(3pi/2-2pi) = -cos of the complement)
// jadx's malformed nested if reconstructed case 0 as return NaN (all small angles NaN!),
// and routed cases 1/2/3 all through g() - root cause of 329 NaNs in the twiddle table and the scrambled spectrum!
if (i4 == 0) {
return g(d5, d7);
}
@@ -474,8 +474,8 @@ public abstract class c {
return -c(d5, d7);
}
d7 = d6;
// jadx 还原不完整(smali 650 行, jadx 只还原 63 行); f() 未被任何代码调用(死代码),
// 按 smali 尾部语义补 return(cond_0: v22 = v22 + v14; return v22)
// jadx's reconstruction is incomplete (650 smali lines vs 63 reconstructed); f() is dead code called by nothing,
// return restored per smali tail semantics (cond_0: v22 = v22 + v14; return v22)
return d7;
}
+2 -2
View File
@@ -1213,8 +1213,8 @@ public final class d {
}
}
} else {
// smali cond_22(a5==0, k=1 时运行时路径!): 单线程 FFT + 后处理
// jadx 原把这段错塞进 if(i8!=1) 的 else(死代码), a5==0 的 else 是幻觉(throw null)
// smali cond_22 (a5==0, the k=1 runtime path!): single-thread FFT + post-processing
// jadx wrongly stuffed this into the else of if(i8!=1) (dead code); the a5==0 else is a phantom (throw null)
int i26 = this.f11700a;
if (i26 > 4) {
j3.c.q(i26, fArr, i4, this.c, this.f11704g, this.f11703e);
+3 -3
View File
@@ -8,9 +8,9 @@ import com.ve3nea.morse_expert.ScaleView;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
/**
* 照搬自 Morse Expert 1.15 j1.C1646n, 类名/字段名保持混淆原名。
* R8 合并类手术: 只保留视图持有器(6 参构造 + 5 个 View 字段);
* 其余 158-1278 行(广告/计费合并方法)已整段裁剪(用户要求广告不迁移)。
* Ported from Morse Expert 1.15 j1.C1646n; obfuscated class/field names kept.
* R8-merged class surgery: keeps only the view holder (6-arg constructor + 5 View fields);
* the remaining lines 158-1278 (ads/billing merged methods) were removed wholesale (user: no ad migration).
*/
public final class C1646n {
+6 -6
View File
@@ -9,12 +9,12 @@ import k3.d;
import k3.s;
/**
* 照搬自 Morse Expert 1.15 j3.c (R8 合并类), 类名保持混淆原名。
* 仅保留 k3 FFT 库实际调用的静态 FFT 方法 (u/v/w/x/y/z/E/F/g/h/q/r 及其辅助
* A/B/C/D/G/H/i/j/k/l/m/n/o/p/s/t/d/J), 其余 R8 合并的 billing/ads/TextView
* 兼容代码及 M.Z 接口实现已裁剪。依据: apktool smali (j3/c.smali) 静态调用关系。
* d 方法中 K1.a.z 的 Guava lenientFormat 以 String.format 等价替换
* (K1.a 已裁剪为仅保留内存统计字段)。
* Ported from Morse Expert 1.15 j3.c (R8-merged class); obfuscated class name kept.
* Keeps only the static FFT methods the k3 FFT library actually calls (u/v/w/x/y/z/E/F/g/h/q/r and their helpers
* A/B/C/D/G/H/i/j/k/l/m/n/o/p/s/t/d/J); the rest of the R8-merged billing/ads/TextView
* compatibility code and M.Z interface implementations were trimmed. Based on apktool smali (j3/c.smali) static call analysis.
* In method d, K1.a.z's Guava lenientFormat is replaced with an equivalent String.format
* (K1.a was trimmed to memory-accounting fields only).
*/
public final class c {
public static void A(int i4, int i5, int i6, float[] fArr, float[] fArr2) {
+1 -1
View File
@@ -34,7 +34,7 @@ public abstract class c {
}
public static void b(Future[] futureArr) throws InterruptedException, java.util.concurrent.ExecutionException {
// smali: 无 catch 直接向上抛 -> Java 声明 throws(忠实表达)
// smali: no catch, thrown straight up -> Java declares throws (faithful)
for (Future future : futureArr) {
future.get();
}
+4 -4
View File
@@ -1,10 +1,10 @@
package k3;
/**
* R8 生成的伪 enum(k3 FFT 库的数组类型枚举)。
* smali 中 q 的直接子类:l(LONG/5)、m(FLOAT/6)、n(DOUBLE/7),
* 其余常量(BYTE/UNSIGNED_BYTE/SHORT/INT/COMPLEX_FLOAT/COMPLEX_DOUBLE/STRING/OBJECT)
* 直接以 q 实例表达。
* R8-generated pseudo-enum (the k3 FFT library's array-type enum).
* Direct q subclasses in smali: l (LONG/5), m (FLOAT/6), n (DOUBLE/7),
* the other constants (BYTE/UNSIGNED_BYTE/SHORT/INT/COMPLEX_FLOAT/COMPLEX_DOUBLE/STRING/OBJECT)
* are expressed directly as q instances.
*/
public class q {
public static final l f;
+2 -2
View File
@@ -19,8 +19,8 @@ public abstract class r {
declaredField.setAccessible(true);
obj = declaredField.get(cls);
} catch (ClassNotFoundException | IllegalAccessException | IllegalArgumentException | NoSuchFieldException | SecurityException ignored) {
// Android 适配: sun.misc.Unsafe 可能不可用(隐藏 API), 静默降级为 null;
// 大数组 native 路径(j4 > 1GB)不会在实际解码中触发
// Android adaptation: sun.misc.Unsafe may be unavailable (hidden API); silently degrades to null;
// the large-array native path (j4 > 1GB) never triggers in real decoding
}
f12109a = (Unsafe) obj;
}
@@ -1,11 +1,11 @@
/*
* LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
* LogTab.kt - "Log" tab of the radar page (4.5.2 WaveLog logging).
*
* 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
* 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
* 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
* Layout: transponder list (tap to select, watch frequency while typing) -> callsign input, Enter saves locally ->
* local records list below (time/freq/callsign). Records support custom swipe-to-delete:
* yellow trash icon shows while swiping; past 75% it becomes an undo key; not undone within 5 s means deleted.
*
* 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
* Enter only saves locally (WavelogQueue); upload is triggered by the 10-min cycle or manually (avoids mis-copied calls).
*/
package com.rtbishop.look4sat.feature.radar
@@ -116,8 +116,8 @@ fun LogTab(
)
}
// 转发器选择(下拉选择框): 选中后底下出现输入区
// 选中项按 uuid 从 transceivers 实时派生(多普勒频率每秒更新, 频率显示随之刷新)
// Transponder picker (dropdown): input area appears below once selected
// Selected item derived live from transceivers by uuid (Doppler freq updates every second, display refreshes with it)
var menuExpanded by remember { mutableStateOf(false) }
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
ExposedDropdownMenuBox(
@@ -157,7 +157,7 @@ fun LogTab(
}
}
// 选中转发器后: 输入区(实时频率 + 呼号 + 模式)
// After selecting a transponder: input area (live freq + callsign + mode)
selectedRadio?.let { radio ->
ExpandedLogInput(
radio = radio,
@@ -171,7 +171,7 @@ fun LogTab(
)
}
// 本地记录列表(时间/频率/呼号)
// Local records list (time / freq / callsign)
Text(
text = stringResource(id = R.string.wavelog_saved),
fontSize = 12.sp,
@@ -185,9 +185,9 @@ fun LogTab(
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
// 按场次分组(sessionId = 卫星名-AOS 时间戳), 组间粗线分隔
// Grouped by pass session (sessionId = satName-AOS timestamp), thick line between groups
entries.groupBy { it.sessionId.ifBlank { "un" } }.forEach { (sessionId, groupEntries) ->
// 组标题 + 分隔线
// Group title + divider
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.primary)
Text(
text = if (sessionId == "un") stringResource(id = R.string.wavelog_ungrouped)
@@ -237,7 +237,7 @@ fun LogTab(
}
}
/** 转发器展开输入区: 实时频率 + 呼号(回车存本地) + 模式修正 */
/** Expanded transponder input: live freq + callsign (Enter saves locally) + mode override */
@Composable
private fun ExpandedLogInput(
radio: SatRadio,
@@ -259,7 +259,7 @@ private fun ExpandedLogInput(
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
// 频率直接取转发器栏的数字(radio 每秒多普勒修正, 回车那一秒的值)
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val qsoId = UUID.randomUUID().toString()
@@ -278,7 +278,7 @@ private fun ExpandedLogInput(
callsign = ""
onSaved()
showToast(savedMsg)
// QRZ 对方网格异步回填(4.5.5): 设置页填了 Cookie 才查, 失败静默
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
scope.launch {
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
val rawCookie = prefs.getString("cookie", "") ?: ""
@@ -291,7 +291,7 @@ private fun ExpandedLogInput(
}
}
// 频率显示与转发器栏完全一致: TX 行 + RX 行(radio 来自每秒多普勒重算, 实时刷新)
// Freq display matches the transponder bar: TX row + RX row (radio from per-second Doppler recompute, live)
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val upLow = radio.uplinkLow
@@ -300,7 +300,7 @@ private fun ExpandedLogInput(
val downHigh = radio.downlinkHigh
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
// TX 行
// TX row
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "TX: ",
@@ -321,7 +321,7 @@ private fun ExpandedLogInput(
)
}
}
// RX 行(与转发器栏 RX 一致)
// RX row (same as transponder bar RX)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "RX: ",
@@ -364,9 +364,9 @@ private fun ExpandedLogInput(
}
/**
* 自绘左滑删除行(用户定制交互):
* 左滑 → 右侧黄色垃圾桶; 滑过 75% → 变撤销键(待删除, 不立即删);
* 5 秒倒计时内点撤销恢复, 不点自动删除。
* Custom swipe-to-delete row (user-requested interaction):
* swipe left -> yellow trash on the right; past 75% -> becomes undo key (pending delete, not immediate);
* tap undo within the 5 s countdown to restore; otherwise auto-deleted.
*/
@Composable
internal fun SwipeDeleteRow(
@@ -379,14 +379,14 @@ internal fun SwipeDeleteRow(
var rowWidth by remember { mutableIntStateOf(0) }
val threshold = rowWidth * 0.75f
// 平滑弹回/滑入(动画)
// Smooth spring back / slide in (animation)
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
label = "swipeOffset"
)
// 倒计时: 进入待删除后 5 秒自动删
// Countdown: auto-delete 5 s after entering pending state
LaunchedEffect(pending) {
if (pending) {
countdown = 5
@@ -407,7 +407,7 @@ internal fun SwipeDeleteRow(
.fillMaxWidth()
.onSizeChanged { rowWidth = it.width }
) {
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
// Background layer (right-side icons: trash / undo + countdown)
Box(
modifier = Modifier
.matchParentSize()
@@ -442,7 +442,7 @@ internal fun SwipeDeleteRow(
)
}
} else {
// 垃圾桶(滑动过程中一直显示, 黄色)
// Trash icon (always visible while swiping, yellow)
Text(
text = "🗑",
fontSize = 20.sp,
@@ -450,8 +450,8 @@ internal fun SwipeDeleteRow(
)
}
}
// 内容层(跟手滑动): 背景在 offset 内侧(随内容移动)!
// 平时盖住垃圾桶, 左滑时内容带背景移开 → 右侧露出黄色垃圾桶/撤销倒计时
// Content layer (follows finger): background sits inside the offset (moves with content)!
// Normally covers the trash; swiping moves content+background away -> yellow trash / undo countdown revealed
Box(
modifier = Modifier
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
@@ -486,7 +486,7 @@ private fun formatLocalTime(utcMs: Long): String {
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
}
/** 场次 ID: 卫星名-AOS 时间戳(UTC yyyyMMdd-HHmm); 无 AOS 时用回车时刻 */
/** Pass session ID: satName-AOS timestamp (UTC yyyyMMdd-HHmm); falls back to Enter time without AOS */
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
@@ -124,17 +124,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
// WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
val wavelogUploader = remember { container.provideWavelogUploader() }
LaunchedEffect(Unit) {
while (true) {
delay(10 * 60 * 1000L)
val s = container.settingsRepo.otherSettings.value
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
// 网格不一致时静默跳过(留给手动上传确认), 其余失败自动下次重试
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
val outcome = wavelogUploader.uploadQueue()
if (outcome is UploadOutcome.NeedConfirm) {
// 跳过本次; 用户可在设置页手动上传并确认
// Skip this run; the user can upload and confirm manually in settings
}
}
}
@@ -1,8 +1,8 @@
/*
* WavelogLogScreen.kt — 「日志」页面(更多菜单, 4.5.2 修复)。
* WavelogLogScreen.kt - the Log page (More menu, 4.5.2 fix).
*
* 表格形式展示本地存储的日志: 时间 | 频率 | 卫星 | 呼号 | 已上传(✓)。
* 完整格子线(表头横线 + 行横线 + 列竖线 + 外边框); 行复用左滑删除。
* Shows locally stored logs as a table: time | freq | satellite | callsign | uploaded (✓).
* Full grid lines (header rule + row rules + column rules + outer border); rows reuse swipe-to-delete.
*/
package com.rtbishop.look4sat.feature.radar
@@ -59,13 +59,13 @@ fun WavelogLogScreen(
.verticalScroll(rememberScrollState())
.padding(8.dp)
) {
// 表格外边框(左右 + 上下由表头/行底线构成)
// Table outer border (left/right; top/bottom formed by header/row bottom rules)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
// 表头
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -82,9 +82,9 @@ fun WavelogLogScreen(
GridVLine()
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
}
// 表头横线
// Header rule
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
// 表头与数据区留白
// Space between header and data area
Box(modifier = Modifier.height(8.dp))
if (entries.isEmpty()) {
@@ -95,13 +95,13 @@ fun WavelogLogScreen(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
)
} else {
// 按场次分组(sessionId = 卫星名-AOS 时间戳); 空 sessionId 归"未分组"放最后
// Grouped by pass session (sessionId = satName-AOS timestamp); empty sessionId goes to "Uncategorized" last
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
.toSortedMap(compareByDescending<String> { id ->
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
}.thenByDescending { it })
groups.forEach { (sessionId, groupEntries) ->
// 组标题(场次名 + 本地时间)
// Group title (session name + local time)
val (satPart, timePart) = parseSession(sessionId)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -113,7 +113,7 @@ fun WavelogLogScreen(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
// 组间横线(md --- 效果)
// Rule between groups (md --- effect)
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
groupEntries.forEach { entry ->
SwipeDeleteRow(
@@ -148,7 +148,7 @@ fun WavelogLogScreen(
}
}
}
// 行分隔线
// Row divider
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
}
}
@@ -159,7 +159,7 @@ fun WavelogLogScreen(
}
}
/** 列竖线 */
/** Column rule */
@Composable
private fun GridVLine() {
Box(
@@ -196,7 +196,7 @@ private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
private const val UNGROUPED = "__ungrouped__"
/** 解析场次 ID: "ASRTU-1-20260804-2014" → (卫星名, "20260804-2014") */
/** Parse session ID: "ASRTU-1-20260804-2014" -> (sat name, "20260804-2014") */
private fun parseSession(sessionId: String): Pair<String, String> {
val parts = sessionId.split('-')
if (parts.size < 3) return sessionId to ""
@@ -205,7 +205,7 @@ private fun parseSession(sessionId: String): Pair<String, String> {
return satPart to timePart
}
/** 场次时间(UTC "20260804-2014") → 本地 "MM-dd HH:mm" */
/** Session time (UTC "20260804-2014") -> local "MM-dd HH:mm" */
private fun formatSessionTime(timePart: String): String {
if (timePart.length != 13 || timePart[8] != '-') return timePart
return try {
@@ -73,19 +73,19 @@ import java.util.Date
import java.util.Locale
/**
* 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。
* UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改)
* 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听
* 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。
* Roaming page - ported verbatim from QTH Locator 2.0 (com.us1pm.gridsquarelocator).
* UI: res/layout/main.xml (structure/size/colors copied item by item, no changes)
* Logic: MainActivity.showLocation() / checkEnabled() / live GPS listening
* Hard rule: UI and logic unchanged, pixel-aligned with the original screenshots.
*/
@Composable
fun RoamingScreen() {
val context = LocalContext.current
var state by remember { mutableStateOf(RoamingState()) }
// 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟)
// Ported from onCreate: fixed date text + hour prefix (minutes appended on locate)
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
var hourPrefix by remember { mutableStateOf("") }
// 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏
// Ported from onCreate: initial checkEnabled() -> red dot + settings button, date hidden
var hasPermission by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -102,25 +102,25 @@ fun RoamingScreen() {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
)
} else {
// 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏
// Ported from onResume: green dot + progress + hint + date shown, red dot/button hidden
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
}
}
// 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米
// Ported from onResume/onPause: GPS + network listeners, 10 s / 10 m
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
DisposableEffect(locationManager, hasPermission) {
if (hasPermission) {
@SuppressLint("MissingPermission")
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// 照搬 showLocation 开头: 只接受 gps / network provider
// Ported from showLocation start: accept only gps / network providers
if (location.provider != "gps" && location.provider != "network") return
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
.copy(date = dateText)
}
override fun onProviderDisabled(provider: String) {
// 照搬 checkEnabled: 红点+按钮, 日期隐藏
// Ported from checkEnabled: red dot + button, date hidden
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
}
@@ -141,15 +141,15 @@ fun RoamingScreen() {
onDispose { }
}
}
// 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权
// 按用户要求: 上下往内缩进系统栏安全区, 避免内容被状态栏/导航栏遮挡
// Ported from res/layout/main.xml: light page, blue info area, 43sp locator, 3x3 grid, bottom credit
// Per user request: inset from system bars top/bottom so content is not covered
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.systemBars)
) {
// relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright
// relativeLayout7: top GPS status bar, 25dp high, holo_blue_bright
Box(
modifier = Modifier
.fillMaxWidth()
@@ -158,16 +158,16 @@ fun RoamingScreen() {
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// tvTitleGPS: "GPS" 14sp 黑, 左 8dp
// tvTitleGPS: "GPS" 14sp black, left 8dp
Text(
text = "GPS",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml)
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (original main.xml)
Spacer(modifier = Modifier.width(15.dp))
// imGps: 绿点 (15dp, 主题色 primary -> 夜间滤镜下可见)
// imGps: green dot (15dp, theme primary -> visible under night filter)
if (state.gpsOn) {
Box(
modifier = Modifier
@@ -175,7 +175,7 @@ fun RoamingScreen() {
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
}
// imGpsOff: 红点叠层 (15dp, 主题色 error)
// imGpsOff: red dot overlay (15dp, theme error)
if (state.gpsOff) {
Box(
modifier = Modifier
@@ -184,12 +184,12 @@ fun RoamingScreen() {
)
}
Spacer(modifier = Modifier.weight(1f))
// tvDate: 14sp 黑, 居中
// tvDate: 14sp black, centered
if (state.showDate) {
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.weight(1f))
// tvTime: 14sp 黑, 右对齐
// tvTime: 14sp black, right-aligned
Text(
text = state.time,
fontSize = 14.sp,
@@ -197,8 +197,8 @@ fun RoamingScreen() {
modifier = Modifier.padding(end = 8.dp)
)
}
// btnLocationSettings: 黄色按钮, 居中
// btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中
// btnLocationSettings: yellow button, centered
// btnLocationSettings: original #faf8f54a translucent yellow, 14sp black text, centered
if (state.showSettings) {
Box(
modifier = Modifier
@@ -212,27 +212,27 @@ fun RoamingScreen() {
}
}
}
// linearLayout4: 经纬度区, holo_blue_bright
// linearLayout4: lat/lon area, holo_blue_bright
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp
// Lat row: tvLati 16sp black left 8dp | tvLat right-aligned 3dp
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
// 经度行
// Lon row
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.weight(1f))
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
}
}
// relativeLayout6: 定位码区, 43sp bold 黑居中
// relativeLayout6: locator area, 43sp bold black centered
Box(
modifier = Modifier
.fillMaxWidth()
@@ -247,7 +247,7 @@ fun RoamingScreen() {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// progressBar: 加载中, 居中
// progressBar: loading, centered
if (state.showProgress) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
@@ -255,7 +255,7 @@ fun RoamingScreen() {
strokeWidth = 2.dp
)
}
// tvNotice: 提示文字, 居中顶部
// tvNotice: hint text, centered top
if (state.showNotice) {
Text(
text = "请稍等,加载可能需要几分钟的时间。",
@@ -266,19 +266,19 @@ fun RoamingScreen() {
)
}
}
// relativeLayout: 九宫格, 占满剩余, 底部 15dp
// 用 Box 等价 RelativeLayout 布局: 左格贴左 / 中格水平居中 / 右格贴右 (成品 main.xml)
// relativeLayout: 3x3 grid, fills remaining space, bottom 15dp
// Box as RelativeLayout equivalent: left cell left / middle cell centered / right cell right (original main.xml)
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
// 行1: relativeLayout5 (上排: 纬度 +1)
// Row 1: relativeLayout5 (top row: lat +1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行2: relativeLayout4, 205dp 高 (中排: 中央)
// Row 2: relativeLayout4, 205dp high (middle row: center)
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
// 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上
// Center column: tv22 (100x80dp centerInParent) overlaid on linearLayout3 (200x200dp)
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
Box(
@@ -288,7 +288,7 @@ fun RoamingScreen() {
.size(width = 200.dp, height = 200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
// iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位
// iv: red dot 10x10dp, top|left, leftMargin/topMargin from lookup tables
Image(
painter = painterResource(R.drawable.roam_pnt),
contentDescription = null,
@@ -297,7 +297,7 @@ fun RoamingScreen() {
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
)
}
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白)
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight (white)
Text(
text = state.grids[4],
fontSize = 30.sp,
@@ -311,14 +311,14 @@ fun RoamingScreen() {
}
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
// 行3: relativeLayout3 (下排: 纬度 -1)
// Row 3: relativeLayout3 (bottom row: lat -1)
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
}
}
// tv4: 底部版权, 10sp 黑 (往上拉, 底部预留空间避免被导航栏遮挡)
// tv4: bottom credit, 10sp black (raised; bottom space kept clear of nav bar)
Text(
text = "制作:US1PM 汉化:BA7LCE",
fontSize = 10.sp,
@@ -329,7 +329,7 @@ fun RoamingScreen() {
}
}
// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33)
// Surrounding cells: 60/200/60dp, #0BACF1, bold centered, 2dp margin (ported tv11/tv13/tv21/tv23/tv31/tv33)
@Composable
private fun GridCell(text: String, modifier: Modifier) {
Box(
@@ -349,8 +349,8 @@ private fun GridCell(text: String, modifier: Modifier) {
}
/**
* 照搬 MainActivity.showLocation() 的状态。
* 网格/九宫格/红点查表与成品逐行一致。
* Ported state from MainActivity.showLocation().
* Grid / 3x3 / red-dot lookup tables match the original line by line.
*/
data class RoamingState(
val date: String = "",
@@ -361,9 +361,9 @@ data class RoamingState(
val lonValue: String = "",
val loc: String = "",
val grids: List<String> = List(9) { "" },
/** 红点 leftMargin(dp), 经度第 3 对字符查表 */
/** Red dot leftMargin (dp), looked up by the 3rd char pair of longitude */
val markerLeft: Int = 0,
/** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */
/** Red dot topMargin (dp), looked up by the 3rd char pair of latitude (screen Y inverted) */
val markerTop: Int = 0,
val gpsOn: Boolean = false,
val gpsOff: Boolean = true,
@@ -374,16 +374,16 @@ data class RoamingState(
)
/**
* 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。
* Ported full showLocation() computation: time / lat-lon DMS / 8-char grid / 3x3 / red-dot lookup.
*/
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
// 照搬: tvTime = 缓存小时前缀 + 定位时间分钟
// Ported: tvTime = cached hour prefix + locate time minutes
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
val time = hourPrefix + minute
// 方向字符
// Direction chars
val ew = if (lon >= 0.0) "E" else "W"
val ns = if (lat >= 0.0) "N" else "S"
// 照搬: 纬度 DMS (String.valueOf 无补零)
// Ported: latitude DMS (String.valueOf, no zero padding)
val latAbs = if (lat < 0.0) -lat else lat
val latDeg = latAbs.toInt()
val latMinFull = (latAbs - latDeg) * 60.0
@@ -391,7 +391,7 @@ fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix
val latSec = ((latMinFull - latMin) * 60.0).toInt()
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
val latValue = "$lat° "
// 照搬: 经度 DMS
// Ported: longitude DMS
val lonAbs = if (lon < 0.0) -lon else lon
val lonDeg = lonAbs.toInt()
val lonMinFull = (lonAbs - lonDeg) * 60.0
@@ -399,13 +399,13 @@ fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
val lonValue = "$lon° "
// 照搬: 8 位网格编码 (区间查表)
// Ported: 8-char grid encoding (range lookup)
val (str, str2, str3, str4) = encodeLon(lon)
val (str5, str6, str38, str9) = encodeLat(lat)
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
// 照搬: 九宫格 parseInt3 五分支
// Ported: 3x3 parseInt3 five-branch logic
val grids = buildGrids(str, str5, str2, str6, str3, str38)
// 照搬: 红点查表 (第 3 对字符)
// Ported: red-dot lookup (3rd char pair)
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
val markerTop = latMargin[str38.firstOrNull()] ?: 0
return RoamingState(
@@ -427,21 +427,21 @@ fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix
)
}
// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp
// Red-dot leftMargin lookup (MainActivity lines 1286-1386): lon 3rd pair a..x -> -2..190 dp
private val lonMargin = mapOf(
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
)
// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转)
// Red-dot topMargin lookup (MainActivity lines 1315-1363): lat 3rd pair a..x -> 190..-2 dp (screen Y inverted)
private val latMargin = mapOf(
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
)
// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字)
// Ported lines 311-611: longitude -> (20 deg zone letter, 2 deg digit, 2' letter, 30" digit)
private fun encodeLon(inputLon: Double): List<String> {
var longitude = inputLon
var d = 0.0
@@ -522,7 +522,7 @@ private fun encodeLon(inputLon: Double): List<String> {
return listOf(str, str2, str3, str4)
}
// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字)
// Ported lines 612-914: latitude -> (10 deg zone letter, 1 deg digit, 1' letter, 15" digit)
private fun encodeLat(inputLat: Double): List<String> {
var latitude = inputLat
var d2 = 0.0
@@ -605,8 +605,8 @@ private fun encodeLat(inputLat: Double): List<String> {
}
/**
* 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。
* 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。
* Ported lines 917-1283: 3x3 parseInt3 five-branch (interior / west / east / north / south edges + corners).
* Returns 9 cell texts: [top-left, top-center, top-right, mid-left, center, mid-right, bottom-left, bottom-center, bottom-right].
*/
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
val parseInt = str2.toInt()
@@ -618,7 +618,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
val g = MutableList(9) { "" }
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 内部: 数字对 ±1
// Interior: digit pairs +-1
val i7 = parseInt3 + 1
val i8 = i7 - 10
val valueOf9 = fmt2(i8)
@@ -642,7 +642,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 西边界: 经度 0 -> 9, 经度字母 -1
// West edge: lon digit 0 -> 9, lon letter -1
val str43 = (str[0] - 1).toString() + str5
val str44 = "9" + (parseInt2 + 1)
val i12 = parseInt3 + 1
@@ -664,7 +664,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str39 + valueOf8
g[8] = str39 + str15
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
// 东边界: 经度 9 -> 0, 经度字母 +1
// East edge: lon digit 9 -> 0, lon letter +1
val str47 = (str[0] + 1).toString() + str5
val i14 = parseInt3 + 1
val valueOf21 = (i14 - 10).toString()
@@ -686,7 +686,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str39 + valueOf25
g[8] = str47 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
// 北边界: 纬度 9 -> 0, 纬度字母 +1
// North edge: lat digit 9 -> 0, lat letter +1
val str50 = str + (str5[0] + 1).toString()
val i16 = parseInt3 - 9
val i17 = i16 - 10
@@ -711,7 +711,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str39 + str14
g[8] = str39 + str15
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
// 南边界: 纬度 0 -> 9, 纬度字母 -1
// South edge: lat digit 0 -> 9, lat letter -1
val str52 = str + (str5[0] - 1).toString()
val i21 = parseInt3 + 1
val i22 = i21 - 10
@@ -735,7 +735,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str52 + str14
g[8] = str52 + str15
} else if (parseInt3 == 0) {
// 四角 00: 经纬度数字均 -1 进位
// Corner 00: both digits -1 with carry
val v32 = (str[0] - 1).toString()
val v33 = (str5[0] - 1).toString()
g[0] = v32 + str5 + "91"
@@ -748,7 +748,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str + v33 + "09"
g[8] = str + v33 + "19"
} else if (parseInt3 == 9) {
// 四角 09: 经度 -1, 纬度 +1
// Corner 09: lon -1, lat +1
val v34 = (str[0] - 1).toString()
val v35 = (str5[0] + 1).toString()
g[0] = v34 + v35 + "90"
@@ -761,7 +761,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = v34 + str5 + "08"
g[8] = v34 + str5 + "18"
} else if (parseInt3 == 99) {
// 四角 99: 经纬度数字均 +1 进位
// Corner 99: both digits +1 with carry
val v36 = (str[0] + 1).toString()
val v37 = (str5[0] + 1).toString()
g[0] = str + v37 + "80"
@@ -774,7 +774,7 @@ private fun buildGrids(str: String, str5: String, str2: String, str6: String, st
g[7] = str39 + "98"
g[8] = v36 + str5 + "08"
} else if (parseInt3 == 90) {
// 四角 90: 经度 +1, 纬度 -1
// Corner 90: lon +1, lat -1
val v38 = (str[0] + 1).toString()
val v39 = (str5[0] - 1).toString()
g[0] = str39 + "81"
@@ -22,7 +22,7 @@ import org.junit.Test
class RoamingStateTest {
// 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575
// User-verified coordinates (QTH Locator 2.0 screenshot): 22.314066 / 108.706575
private fun stateOf(lat: Double, lon: Double) =
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
@@ -35,7 +35,7 @@ class RoamingStateTest {
@Test
fun `red marker from ih pair matches reference lookup`() {
val state = stateOf(22.314066, 108.706575)
// 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp
// Original lookup: lon 3rd pair 'i' -> leftMargin 65dp, lat 3rd pair 'h' -> topMargin 137dp
assertEquals(65, state.markerLeft)
assertEquals(137, state.markerTop)
}
@@ -70,43 +70,43 @@ class RoamingStateTest {
@Test
fun `west edge borrows lon letter`() {
// 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0
// lon 0.5 lat 22.314066: digit pair 02, west edge parseInt==0
val state = stateOf(22.314066, 0.5)
assertEquals(8, state.loc.length)
// 左列经度 0->9, 经度字母 J-1=I: IL92
// Left column lon 0->9, lon letter J-1=I: IL92
assertEquals("IL92", state.grids[3])
}
@Test
fun `north edge carries lat letter`() {
// 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界
// lat 29.5 lon 108.706575: digit pair 49? -> lat digit 9 -> north edge
val state = stateOf(29.5, 108.706575)
assertEquals(8, state.loc.length)
// 上排纬度 +1 -> 0, 纬度字母 +1 (M)
// Top row lat +1 -> 0, lat letter +1 (M)
assertEquals("OM40", state.grids[1])
}
@Test
fun `east edge carries lon letter`() {
// 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界
// lon 119.5 lat 22.314066: lon digit 9 -> east edge
val state = stateOf(22.314066, 119.5)
assertEquals(8, state.loc.length)
// 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03
// Top-right lon 9->0, lon letter O+1=P, lat 2+1=3: PL03
assertEquals("PL03", state.grids[2])
}
@Test
fun `south edge borrows lat letter`() {
// 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界
// lat 0.5 lon 108.706575: lat digit 0 -> south edge
val state = stateOf(0.5, 108.706575)
assertEquals(8, state.loc.length)
// 下中 纬度 0->9, 纬度字母 J-1=I: OI49
// Bottom-center lat 0->9, lat letter J-1=I: OI49
assertEquals("OI49", state.grids[7])
}
@Test
fun `boundary values wrap like reference`() {
// (90, 180) -> RR99xx99 (成品区间查表行为)
// (90, 180) -> RR99xx99 (original range-lookup behavior)
val state = stateOf(90.0, 180.0)
assertEquals("RR99xx99", state.loc)
}
@@ -38,7 +38,7 @@ import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R
/** APRS 卡片:卫星数据容器与数据输出容器之间;设置键弹窗收纳,不挤 */
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
@Composable
fun AprsCard() {
val context = LocalContext.current
@@ -63,7 +63,7 @@ fun AprsCard() {
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// 标题行:标题 + 设置键(弹窗,省空间)
// Title row: title + gear key (dialog, space-saving)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -80,7 +80,7 @@ fun AprsCard() {
)
}
}
// 启用开关
// Enable switch
AprsSwitchRow(R.string.prefs_aprs_enable, config.enabled) { enabled ->
config = config.copy(enabled = enabled)
AprsStore.saveConfig(context, config)
@@ -90,7 +90,7 @@ fun AprsCard() {
.setAction(if (enabled) AprsStore.ACTION_START else AprsStore.ACTION_STOP)
if (enabled) context.startForegroundService(intent) else context.startService(intent)
}
// 简洁信息行(不挤)
// Compact info row (not crowded)
Text(
text = stringResource(
id = R.string.prefs_aprs_summary,
@@ -100,7 +100,7 @@ fun AprsCard() {
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 上次上报状态行
// Last-report status row
val lr = lastReport
if (lr.time > 0L) {
val timeStr = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
@@ -117,7 +117,7 @@ fun AprsCard() {
else MaterialTheme.colorScheme.error
)
}
// 手动上报按钮
// Manual report button
CardButton(
onClick = {
val intent = Intent()
@@ -131,7 +131,7 @@ fun AprsCard() {
)
}
}
// 上报后延迟刷新状态行(等结果落盘)
// Refresh the status row after reporting (wait for the result to persist)
androidx.compose.runtime.LaunchedEffect(reportPing) {
if (reportPing > 0) {
kotlinx.coroutines.delay(6000)
@@ -145,7 +145,7 @@ fun AprsCard() {
onSave = { newConfig ->
config = newConfig
AprsStore.saveConfig(context, newConfig)
// 已启用时应用新配置(重启服务)
// Apply new config when enabled (restart the service)
if (newConfig.enabled) {
context.startService(
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
@@ -162,7 +162,7 @@ fun AprsCard() {
}
}
/** APRS 设置弹窗(空间充裕,配置项齐全) */
/** APRS settings dialog (plenty of room, full config) */
@Composable
private fun AprsSettingsDialog(
config: AprsConfig,
@@ -207,7 +207,7 @@ private fun AprsSettingsDialog(
singleLine = true,
textStyle = textStyle
)
// 计算验证码按钮(用户要求:让用户算出结果)
// Passcode compute button (user request: let the user see the computed result)
TextButton(
onClick = {
val call = callsign.trim().uppercase()
@@ -302,7 +302,7 @@ private fun AprsSettingsDialog(
)
}
/** 本文件内开关行(与 SettingsScreen 的私有 SwitchRow 一致) */
/** Switch row local to this file (same as SettingsScreen's private SwitchRow) */
@Composable
private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
Row(
@@ -121,7 +121,7 @@ fun SettingsDestination() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo())
// WaveLog 网格不一致确认弹窗(4.5.2)
// WaveLog grid mismatch confirmation dialog (4.5.2)
val gridConfirm = viewModel.gridConfirm
if (gridConfirm != null) {
AlertDialog(
@@ -149,7 +149,7 @@ fun SettingsDestination() {
)
}
// WaveLog 错误详情弹窗(可一键复制)
// WaveLog error detail dialog (one-tap copy)
val wavelogError = viewModel.wavelogError
if (wavelogError != null) {
AlertDialog(
@@ -628,11 +628,11 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
}
/**
* UI 设置卡片: 控制底部菜单栏各页面显示/隐藏。
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
* "设置"页固定显示(防止失去设置入口)。
* UI settings card: controls which pages appear in the bottom menu bar.
* Order is fixed (Satellites/Passes/Radar/Mutual/Roaming/Map/Settings); turning one off makes the rest close up;
* the Settings page is always shown (so the settings entry can't be lost).
*/
/** WaveLog 日志服务器设置卡片(4.5.2): 照用户截图布局(深色卡片/标签左输入右/按钮行) */
/** WaveLog log server settings card (4.5.2): matches the user's screenshot (dark card / label left, input right / button row) */
@Composable
private fun WavelogCard(
settings: OtherSettings,
@@ -813,7 +813,7 @@ private fun UiSettingsCard(
R.string.nav_map to "Map",
R.string.nav_prefs to "Settings"
) // name 必须与 Screen.screenId 一致 (R8 安全)
// 主菜单项: 排除更多菜单项, Settings 固定最后
// Main menu items: exclude sub-menu items; Settings pinned last
val mainItems = remember(screens, screenOrder, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
screens.map { it.second }
@@ -827,7 +827,7 @@ private fun UiSettingsCard(
}
.sortedWith(compareBy { if (it == "Settings") 1 else 0 })
}
// 更多菜单项
// More-menu items
val subItems = remember(screens, subMenuOrder) {
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
sub.filter { s -> screens.any { (_, name) -> name == s } }
@@ -843,7 +843,7 @@ private fun UiSettingsCard(
)
screens.forEach { (labelRes, name) ->
if (name == "Settings") {
// 设置页固定显示, 开关禁用
// Settings always shown; its switch is disabled
SwitchRow(labelRes, true, null)
} else {
SwitchRow(labelRes, name !in hiddenScreens) {
@@ -855,7 +855,7 @@ private fun UiSettingsCard(
text = stringResource(id = R.string.prefs_ui_order_title),
color = MaterialTheme.colorScheme.primary
)
// 主菜单区(底部栏, 最多 5 项, 设置锁定最后)
// Main menu area (bottom bar, max 5 items, Settings locked last)
Text(
text = stringResource(id = R.string.prefs_ui_main_title),
fontSize = 12.sp,
@@ -875,7 +875,7 @@ private fun UiSettingsCard(
},
onReorder = { main -> onUpdateMenu(main, subMenuOrder) }
)
// 更多菜单区(进「更多」的页面)
// More-menu area (pages behind "More")
Text(
text = stringResource(id = R.string.prefs_ui_sub_title),
fontSize = 12.sp,
@@ -885,7 +885,7 @@ private fun UiSettingsCard(
items = subItems,
labels = screens,
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
// 按钮常显; 主菜单满 5 时自动让位(最后一个非"设置"项移入更多, 交换)
// Buttons always visible; when the main menu hits 5, the last non-Settings item moves to More (swap)
moveEnabled = { true },
onMove = { name ->
val mainWithoutSettings = mainItems.filter { it != "Settings" }
@@ -909,7 +909,7 @@ private fun UiSettingsCard(
}
}
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
/** Page-order drag list: handle on each row's right; drag up/down to reorder live; persists on release */
@Composable
private fun DragOrderList(
items: List<String>,
@@ -927,7 +927,7 @@ private fun DragOrderList(
var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableStateOf(0f) }
// 固定高度: 嵌套在 LazyVerticalGrid 内必须给有界高度, 否则无限高度约束崩溃
// Fixed height: nested inside LazyVerticalGrid it needs a bounded height or infinite constraints crash
LazyColumn(
state = listState,
modifier = Modifier
@@ -956,7 +956,7 @@ private fun DragOrderList(
}
}
if (isLocked) {
// 锁定行(设置页): 无抓手, 固定最后
// Locked row (Settings): no handle, pinned last
Text(
text = stringResource(id = R.string.prefs_ui_order_locked),
fontSize = 11.sp,
@@ -995,7 +995,7 @@ private fun DragOrderList(
)
}
) {
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
// Dot handle (touch area padding 20dp centered -> 8dp dot)
Box(
modifier = Modifier
.fillMaxSize()
@@ -1065,7 +1065,7 @@ private fun CardCredits(modifier: Modifier = Modifier) {
Text(
text = stringResource(id = R.string.prefs_outro_thanks)
)
// 感谢列表与保修声明之间保留约两行间距 (用户要求)
// Keep roughly two lines of spacing between the thanks list and the license block (user request)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.prefs_outro_license),
@@ -64,25 +64,25 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
// UI 设置: 切换底部导航栏页面的隐藏状态(Screen simpleName)
// UI settings: toggle a bottom-nav page's hidden state (Screen simpleName)
data class ToggleScreen(val screenName: String) : SettingsAction
// UI 设置: 更新页面顺序
// UI settings: update page order
data class ReorderScreens(val order: List<String>) : SettingsAction
// UI 设置: 重置为默认顺序
// UI settings: reset to default order
data object ResetScreenOrder : SettingsAction
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
// UI settings: update main + More menu order (4.5.1 foldable menu)
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
// UI 设置: 重置主菜单 + 更多菜单顺序
// UI settings: reset main + More menu order
data object ResetMenuOrder : SettingsAction
// WaveLog 日志(4.5.2): 更新服务器配置
// WaveLog logging (4.5.2): update server config
data class UpdateWavelogSettings(
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
) : SettingsAction
// WaveLog: 测试连接
// WaveLog: test connection
data object TestWavelogConnection : SettingsAction
// WaveLog: 手动上传队列
// WaveLog: upload queue manually
data object UploadWavelogQueue : SettingsAction
// WaveLog: 更新 LoTW 卫星列表
// WaveLog: update LoTW satellite list
data object UpdateLotwSatellites : SettingsAction
// Remote control
@@ -146,7 +146,7 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// WaveLog 日志(4.5.2)
// WaveLog logging (4.5.2)
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
it.copy(
wavelogUrl = action.url,
@@ -244,13 +244,13 @@ class SettingsViewModel(
// region WaveLog(4.5.2)
// 由 SettingsScreen 注入的共享 uploader(容器级单例)
// Shared uploader injected by SettingsScreen (container-level singleton)
var pendingUploader: WavelogUploader? = null
// 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
// Grid-mismatch confirmation state (UI observes it and shows an AlertDialog)
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
// WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制)
// WaveLog error-detail dialog (UI observes it; one-tap copy)
var wavelogError by mutableStateOf<String?>(null)
data class GridConfirmData(val stationGrid: String, val userGrid: String)
@@ -296,7 +296,7 @@ class SettingsViewModel(
}
}
/** 网格确认结果: 忽略并上传 / 取消 */
/** Grid-confirm result: ignore and upload / cancel */
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
val confirm = gridConfirm ?: return
gridConfirm = null
@@ -55,7 +55,7 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.presentation.R
import java.util.Calendar
// ========== 官网状态色(amsat.org/status 原色) ==========
// ========== Official status colors (amsat.org/status originals) ==========
val ActiveBlue = Color(0xFF648FFF)
val TlmOrange = Color(0xFFFFB000)
val NotHeardPink = Color(0xFFDC267F)
@@ -73,7 +73,7 @@ fun SatStatusScreen(container: IMainContainer) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp)) {
// 顶部: 图例 + 刷新
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
@@ -96,10 +96,10 @@ fun SatStatusScreen(container: IMainContainer) {
}
}
else -> {
// 表头
// Header
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
// 行
// Row
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.statuses, key = { it.name }) { status ->
StatusRow(
@@ -122,7 +122,7 @@ fun SatStatusScreen(container: IMainContainer) {
}
}
/** 顶部: 更新时间 + 刷新按钮(加载中旋转动画) */
/** Top: update time + refresh button (spinner while loading) */
@Composable
private fun StatusHeader(
fetchedAtUtcMs: Long,
@@ -168,7 +168,7 @@ private fun StatusHeader(
}
}
/** 图例(官网四色) */
/** Legend (official four colors) */
@Composable
private fun LegendRow() {
val legend = listOf(
@@ -196,7 +196,7 @@ private fun LegendRow() {
}
}
/** 表头: 卫星名 + 6 天日期 */
/** Header: satellite name + 6 day dates */
@Composable
private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
@@ -224,7 +224,7 @@ private fun HeaderRow(statuses: List<SatStatus>) {
}
}
/** 卫星行: 名称 + 6 天色块(官网色 + 报告数) */
/** Satellite row: name + 6 day color blocks (official colors + report counts) */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
Row(
@@ -251,7 +251,7 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
}
}
/** 单天色块: 取当天 12 槽中最新的有报告状态; 无报告则灰 */
/** Day block: newest reported status among the day's 12 slots; gray when none */
@Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor)
@@ -274,7 +274,7 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
}
}
/** 报告详情弹窗(3 级: 呼号/日期/时间/网格) */
/** Report detail dialog (3 levels: callsign/date/time/grid) */
@Composable
private fun ReportDialog(
statusName: String,