feat(aprs): 4.5.4 APRS-IS network reporting (ported from APRSdroid 1.6.3d)

Core logic extracted from APRSdroid 1.6.3d (jadx reverse-engineered,
verified against APRS spec):
- core/domain/aprs/AprsPacket.kt: passcode algorithm (0x73E2 XOR),
  login line, uncompressed/compressed position encoding (ab0oo)
- core/data/aprs/AprsIsClient.kt: APRS-IS TCP client (login + packet
  lines, 30s reconnect strategy)
- core/data/aprs/AprsReporter.kt: periodic reporting scheduler
  (default 5 min, manual trigger support)
- core/data/aprs/AprsStore.kt: SharedPreferences persistence
  (fill once, saved)
- app/AprsForegroundService.kt: foreground service with notification
  (keeps APRS alive across pages), START_STICKY, last-known position
  provider
- feature/settings/AprsCard.kt: APRS card between DataCard and
  OutputCard, gear button opens settings dialog (server/port/
  callsign/ssid/passcode/interval/status/symbol), manual report button
- Manifest: service registration + FOREGROUND_SERVICE(_DATA_SYNC) +
  POST_NOTIFICATIONS
- Strings in EN/ZH/TR/IN/ID (23 keys x 5)
- Version 4.5.4/454 + What's new 5 languages
This commit is contained in:
mckero committed 2026-08-05 02:07:11 +00:00
1 parent 72f507163a
commit 518d9a4d22
15 files changed
+931 -8

No files matched your search

@@ -0,0 +1,75 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP 客户端(从 APRSdroid TcpUploader.scala 逆向移植)。
* 纯文本协议:登录一行 + 每行一个包;断线 30 秒重连。
*/
class AprsIsClient(
private val host: String,
private val port: Int,
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** 建立连接 + 登录(同步阻塞,调用方放后台线程) */
@Throws(Exception::class)
fun connect() {
disconnect()
val s = Socket()
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// 登录行
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
}
/** 发送一个 APRS 包(一行) */
fun sendPacket(packetLine: String): Boolean {
synchronized(lock) {
val w = writer ?: return false
w.println(packetLine)
return !w.checkError()
}
}
/** 读一行(服务器响应,超时抛异常) */
fun readLine(): String? {
return reader?.readLine()
}
fun disconnect() {
synchronized(lock) {
runCatching { writer?.close() }
runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
socket = null
}
}
}
@@ -0,0 +1,125 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS 连接状态 */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS 配置(SharedPreferences 持久化,填入即保存) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
val port: Int = 14580,
val callsign: String = "",
val ssid: String = "",
val passcode: String = "",
val intervalMin: Int = 5,
val statusText: String = "Look4Sat APRS",
val symbolTable: String = "/",
val symbolCode: String = ">",
val includeCourseSpeed: Boolean = true,
val includeAltitude: Boolean = true
)
/** APRS 上报结果 */
data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
)
/** 上报调度器(周期上报 + 手动触发),连接管理放在前台服务 */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: AprsIsClient? = null
private var job: Job? = null
private var manualJob: Job? = null
val isRunning: Boolean get() = job?.isActive == true
/** 启动周期上报(前台服务调用) */
fun start() {
stop()
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) {
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
}
fun stop() {
job?.cancel()
job = null
runCatching { client?.disconnect() }
client = null
onState(AprsState.Idle)
}
/** 手动触发一次(立即上报,不等待周期) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
}
private suspend fun reportOnce() {
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull() ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val ok = c.sendPacket(packetLine)
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok,
if (ok) "OK" else "send failed"))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
}
}
/** 构造位置包:BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<状态文本 */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
}
}
@@ -0,0 +1,60 @@
package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS 配置存取 + 服务 action 常量(被 feature/settings 和 app 服务共用,
* 避免 feature 反向依赖 app)。
*/
object AprsStore {
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
private const val PREFS = "aprs_config"
private const val KEY_ENABLED = "enabled"
private const val KEY_SERVER = "server"
private const val KEY_PORT = "port"
private const val KEY_CALLSIGN = "callsign"
private const val KEY_SSID = "ssid"
private const val KEY_PASSCODE = "passcode"
private const val KEY_INTERVAL = "interval"
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
/** 读取配置(填入即保存,无需每次打开重填) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
enabled = p.getBoolean(KEY_ENABLED, false),
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
port = p.getInt(KEY_PORT, 14580),
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
ssid = p.getString(KEY_SSID, "") ?: "",
passcode = p.getString(KEY_PASSCODE, "") ?: "",
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
)
}
/** 保存配置 */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
.putString(KEY_SERVER, cfg.server)
.putInt(KEY_PORT, cfg.port)
.putString(KEY_CALLSIGN, cfg.callsign)
.putString(KEY_SSID, cfg.ssid)
.putString(KEY_PASSCODE, cfg.passcode)
.putInt(KEY_INTERVAL, cfg.intervalMin)
.putString(KEY_STATUS, cfg.statusText)
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
.apply()
}
}