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:
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user