diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a57d1d1c..74d5b407 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,7 +14,10 @@
-
+
+
+
+
diff --git a/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt b/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt
new file mode 100644
index 00000000..b9a5aeca
--- /dev/null
+++ b/app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt
@@ -0,0 +1,152 @@
+package com.rtbishop.look4sat.app
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import com.rtbishop.look4sat.core.presentation.R
+import com.rtbishop.look4sat.core.data.aprs.AprsConfig
+import com.rtbishop.look4sat.core.data.aprs.AprsStore
+import com.rtbishop.look4sat.core.data.aprs.AprsReporter
+import com.rtbishop.look4sat.core.data.aprs.AprsState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+/**
+ * APRS 前台服务:系统通知保活,切换页面不断线。
+ * START_STICKY:被系统杀掉后自动重启(APRSdroid 同款策略)。
+ */
+class AprsForegroundService : Service() {
+
+ companion object {
+ const val ACTION_START = AprsStore.ACTION_START
+ const val ACTION_STOP = AprsStore.ACTION_STOP
+ const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
+ const val CHANNEL_ID = "aprs_service"
+ const val NOTIF_ID = 101
+ }
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var reporter: AprsReporter? = null
+ private var lastState: AprsState = AprsState.Idle
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> stopReporting()
+ ACTION_REPORT_NOW -> reporter?.reportNow()
+ else -> startReporting()
+ }
+ return START_STICKY
+ }
+
+ private fun startReporting() {
+ val cfg = AprsStore.loadConfig(this)
+ if (!cfg.enabled || cfg.callsign.isBlank()) {
+ stopSelf()
+ return
+ }
+ startForegroundWithNotification(cfg)
+ val rep = AprsReporter(
+ configProvider = { AprsStore.loadConfig(this) },
+ positionProvider = { lastKnownPosition() },
+ onState = { lastState = it },
+ onReport = { updateNotification(cfg) }
+ )
+ reporter = rep
+ rep.start()
+ }
+
+ private fun stopReporting() {
+ reporter?.stop()
+ reporter = null
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ private fun startForegroundWithNotification(cfg: AprsConfig) {
+ val notif = buildNotification(cfg)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ startForeground(NOTIF_ID, notif)
+ }
+ }
+
+ private fun buildNotification(cfg: AprsConfig): Notification {
+ val pi = PendingIntent.getActivity(
+ this, 0, packageManager.getLaunchIntentForPackage(packageName),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val stopPi = PendingIntent.getService(
+ this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val stateText = when (lastState) {
+ AprsState.Connected -> getString(R.string.aprs_notif_connected)
+ AprsState.Error -> getString(R.string.aprs_notif_error)
+ else -> getString(R.string.aprs_notif_running)
+ }
+ return Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_radios)
+ .setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
+ .setContentText(stateText)
+ .setContentIntent(pi)
+ .setOngoing(true)
+ .addAction(0, getString(R.string.aprs_notif_stop), stopPi)
+ .build()
+ }
+
+ private fun updateNotification(cfg: AprsConfig) {
+ val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.notify(NOTIF_ID, buildNotification(cfg))
+ }
+
+ /** 取最近已知位置(APRSdroid PeriodicGPS 同思路,无权限时返回 null) */
+ private fun lastKnownPosition(): Pair? {
+ return runCatching {
+ val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
+ val providers = listOf(
+ android.location.LocationManager.GPS_PROVIDER,
+ android.location.LocationManager.NETWORK_PROVIDER
+ )
+ for (p in providers) {
+ val loc = lm.getLastKnownLocation(p) ?: continue
+ if (loc.latitude != 0.0 || loc.longitude != 0.0) {
+ return Pair(loc.latitude, loc.longitude)
+ }
+ }
+ null
+ }.getOrNull()
+ }
+
+ private fun createChannel() {
+ val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID, getString(R.string.aprs_notif_channel),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ nm.createNotificationChannel(channel)
+ }
+
+ override fun onDestroy() {
+ reporter?.stop()
+ reporter = null
+ super.onDestroy()
+ }
+}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClient.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClient.kt
new file mode 100644
index 00000000..81971c94
--- /dev/null
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClient.kt
@@ -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
+ }
+ }
+}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt
new file mode 100644
index 00000000..0a2ef159
--- /dev/null
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsReporter.kt
@@ -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? = { 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}"
+ }
+}
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt
new file mode 100644
index 00000000..456de041
--- /dev/null
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/aprs/AprsStore.kt
@@ -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()
+ }
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt
new file mode 100644
index 00000000..bdf72dd9
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt
@@ -0,0 +1,116 @@
+package com.rtbishop.look4sat.core.domain.aprs
+
+import kotlin.math.abs
+import kotlin.math.round
+
+/**
+ * APRS-IS 协议核心(纯 Kotlin,无 Android 依赖)。
+ * 从 APRSdroid 1.6.3d 逆向移植:AprsPacket$.scala + ab0oo Position.java。
+ */
+object AprsPacket {
+
+ /** APRS-IS passcode 算法(标准):0x73E2 初值,呼号大写+\0,每对字符 XOR */
+ fun passcode(callsign: String): Int {
+ val s = callsign.split("-")[0].uppercase() + "\u0000"
+ var hash = 29666 // 0x73E2
+ var i = 0
+ while (i <= s.length - 2) {
+ hash = hash xor (s[i].code * 256 + s[i + 1].code)
+ i += 2
+ }
+ return hash and 0x7FFF
+ }
+
+ /** 登录行: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) */
+ fun formatCallSsid(callsign: String, ssid: String): String {
+ if (ssid.isNullOrEmpty()) return callsign
+ return "$callsign-$ssid"
+ }
+
+ /** 可选距离过滤: 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(英尺) */
+ fun formatAltitude(altitudeMeters: Double?): String {
+ if (altitudeMeters == null) return ""
+ return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
+ }
+
+ /** 航速航向扩展(节/度) */
+ fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
+ if (speedMps == null || bearing == null) return ""
+ val knots = (speedMps * 1.94384449).toInt()
+ return String.format("/%03d/%03d", bearing.toInt(), knots)
+ }
+}
+
+/**
+ * APRS 位置编码(ab0oo Position.java 逆向移植)。
+ * 非压缩:DDMM.MMN/DDDMM.MME;压缩:base91。
+ */
+class AprsPosition(
+ val latitude: Double,
+ val longitude: Double,
+ val symbolTable: Char,
+ val symbolCode: Char,
+ val positionAmbiguity: Int = 0
+) {
+
+ /** 非压缩格式(APRS-IS 默认上报格式) */
+ fun toUncompressedString(): String {
+ val lat = getDMS(latitude, true)
+ val lon = getDMS(longitude, false)
+ return "$lat$symbolTable$lon$symbolCode"
+ }
+
+ /** 压缩格式(base91,标准 APRS 算法) */
+ fun toCompressedString(): String {
+ val jRound = round((90.0 - latitude) * 380926.0).toLong()
+ val j = jRound / 753571 + 33
+ val j2 = jRound % 753571
+ val j3 = j2 / 8281 + 33
+ val j4 = j2 % 8281
+ val i = (j4 % 91).toInt() + 33
+ val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
+ val j5 = jRound2 / 753571 + 33
+ val j6 = jRound2 % 753571
+ val j7 = 33 + j6 / 8281
+ val j8 = j6 % 8281
+ return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
+ ((j4 / 91).toInt() + 33).toChar() + i.toChar() +
+ j5.toInt().toChar() + j7.toInt().toChar() +
+ ((j8 / 91).toInt() + 33).toChar() +
+ ((j8 % 91).toInt() + 33).toChar() + symbolCode
+ }
+
+ /** 单轴 DMS 编码(百分位) */
+ private fun getDMS(value: Double, isLat: Boolean): String {
+ var iRound = round(value * 6000.0).toInt()
+ if (iRound < 0) iRound = -iRound
+ val degrees = iRound / 6000
+ val minutes = (iRound / 100) % 60
+ val hundredths = iRound % 100
+ val frac = when (positionAmbiguity) {
+ 1 -> " . "
+ 2 -> String.format("%d . ", minutes / 10)
+ 3 -> String.format("%02d. ", minutes)
+ 4 -> String.format("%02d.%d ", minutes, hundredths / 10)
+ else -> String.format("%02d.%02d", minutes, hundredths)
+ }
+ return if (isLat) {
+ val ns = if (value >= 0) 'N' else 'S'
+ String.format("%02d%s%c", degrees, frac, ns)
+ } else {
+ val ew = if (value >= 0) 'E' else 'W'
+ String.format("%03d%s%c", degrees, frac, ew)
+ }
+ }
+}
diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml
index da6da14c..fe8396d3 100644
--- a/core/presentation/src/main/res/values-id/strings.xml
+++ b/core/presentation/src/main/res/values-id/strings.xml
@@ -12,6 +12,29 @@
Dekoder CW
Peta
Pengaturan
+ Pelaporan APRS
+ Aktifkan pelaporan APRS
+ Server: %1$s:%2$d Panggilan: %3$s
+ Lapor Sekarang
+ Pengaturan APRS
+ Server
+ Port
+ Tanda panggilan
+ SSID
+ Kode sandi
+ Kosong = otomatis dari panggilan
+ Interval (menit)
+ Teks status
+ Tabel simbol
+ Kode simbol
+ Simpan
+ Batal
+ Layanan APRS
+ APRS: %1$s
+ Pelaporan berjalan
+ Terhubung ke APRS-IS
+ Kesalahan koneksi
+ Berhenti
Lainnya
Log
AMSAT
@@ -66,7 +89,8 @@
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
* Pesan kegagalan jelas saat database satelit gagal diunduh
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
- * Perbaikan bahasa Indonesia (values-id)
+ * Perbaikan bahasa Indonesia (values-id)
+ * BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur)
Kembali
Beri tahu
Azimuth
diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml
index dd01248a..9ac8d4e2 100644
--- a/core/presentation/src/main/res/values-in/strings.xml
+++ b/core/presentation/src/main/res/values-in/strings.xml
@@ -12,6 +12,29 @@
Dekoder CW
Peta
Pengaturan
+ Pelaporan APRS
+ Aktifkan pelaporan APRS
+ Server: %1$s:%2$d Panggilan: %3$s
+ Lapor Sekarang
+ Pengaturan APRS
+ Server
+ Port
+ Tanda panggilan
+ SSID
+ Kode sandi
+ Kosong = otomatis dari panggilan
+ Interval (menit)
+ Teks status
+ Tabel simbol
+ Kode simbol
+ Simpan
+ Batal
+ Layanan APRS
+ APRS: %1$s
+ Pelaporan berjalan
+ Terhubung ke APRS-IS
+ Kesalahan koneksi
+ Berhenti
Lainnya
Log
AMSAT
@@ -65,7 +88,8 @@
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
* Pesan kegagalan jelas saat database satelit gagal diunduh
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
- * Perbaikan bahasa Indonesia (values-id)
+ * Perbaikan bahasa Indonesia (values-id)
+ * BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur)
Kembali
Beri tahu
Azimuth
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index 6a924337..7eccd32a 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -13,6 +13,29 @@
CW Dekoder
Harita
Ayarlar
+ APRS Raporlama
+ APRS raporlamayı etkinleştir
+ Sunucu: %1$s:%2$d Çağrı: %3$s
+ Şimdi Raporla
+ APRS Ayarları
+ Sunucu
+ Port
+ Çağrı işareti
+ SSID
+ Parola
+ Parola boş = çağrıdan otomatik hesaplanır
+ Aralık (dakika)
+ Durum metni
+ Sembol tablosu
+ Sembol kodu
+ Kaydet
+ İptal
+ APRS hizmeti
+ APRS: %1$s
+ Raporlama çalışıyor
+ APRS-IS bağlı
+ Bağlantı hatası
+ Durdur
Daha Fazla
Günlük
AMSAT
@@ -70,7 +93,8 @@
* GPS artık yalnızca gerçek konum alındığında başarı gösterir (15 sn zaman aşımı, izin kontrolü)
* Uydu veritabanı indirilemediğinde net hata mesajı gösterilir
* Günlük frekansları Doppler panelini gerçek zamanlı takip eder; kaydırarak silme görselleri düzeltildi
- * Endonezya dili düzeltmesi (values-id)
+ * Endonezya dili düzeltmesi (values-id)
+ * YENİ: APRS raporlama (APRS-IS ağı, bildirimli ön plan hizmeti, ayarlanabilir aralık)
Geri
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index ae2b403d..388c1083 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -14,6 +14,29 @@
CW解码
地图
设置
+ APRS 上报
+ 启用 APRS 上报
+ 服务器: %1$s:%2$d 呼号: %3$s
+ 立即上报
+ APRS 设置
+ 服务器
+ 端口
+ 呼号
+ SSID
+ 验证码
+ 验证码留空 = 按呼号自动计算
+ 周期(分钟)
+ 状态文本
+ 符号表
+ 符号码
+ 保存
+ 取消
+ APRS 服务
+ APRS: %1$s
+ 上报运行中
+ 已连接 APRS-IS
+ 连接错误
+ 停止
更多
日志
AMSAT
@@ -60,7 +83,8 @@
* GPS 真正定位成功才会提示成功(15 秒超时, 权限检查)
* 卫星数据库下载失败时有明确失败提示
* 日志频率实时跟随多普勒面板; 左滑删除视觉恢复
- * 印尼语识别修复(values-id)
+ * 印尼语识别修复(values-id)
+ * 新增: APRS 网络上报(APRS-IS,前台服务+通知保活,周期可调)
后退
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index d802234d..d063bf89 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -15,6 +15,29 @@
CW Decoder
Map
Settings
+ APRS Reporting
+ Enable APRS reporting
+ Server: %1$s:%2$d Callsign: %3$s
+ Report Now
+ APRS Settings
+ Server
+ Port
+ Callsign
+ SSID
+ Passcode
+ Passcode empty = auto-computed from callsign
+ Interval (min)
+ Status text
+ Symbol table
+ Symbol code
+ Save
+ Cancel
+ APRS service
+ APRS: %1$s
+ Reporting running
+ Connected to APRS-IS
+ Connection error
+ Stop
More
Log
AMSAT
@@ -72,7 +95,8 @@
* GPS now only reports success when a real fix is acquired (15s timeout, permission check)
* Data update shows a clear failure message when the satellite database cannot be downloaded
* Log frequencies follow the Doppler panel in real time; swipe-to-delete visuals restored
- * Indonesian locale fix (values-id)
+ * Indonesian locale fix (values-id)
+ * NEW: APRS reporting (APRS-IS network, foreground service with notification, configurable interval)
Back
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index a0e10483..bca2769b 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.settings"
}
+
+dependencies {
+ implementation(project(":core:data"))
+}
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt
new file mode 100644
index 00000000..778dc152
--- /dev/null
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt
@@ -0,0 +1,262 @@
+package com.rtbishop.look4sat.feature.settings
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rtbishop.look4sat.core.data.aprs.AprsConfig
+import com.rtbishop.look4sat.core.data.aprs.AprsStore
+import com.rtbishop.look4sat.core.presentation.CardButton
+import com.rtbishop.look4sat.core.presentation.R
+
+/** APRS 卡片:卫星数据容器与数据输出容器之间;设置键弹窗收纳,不挤 */
+@Composable
+fun AprsCard() {
+ val context = LocalContext.current
+ var config by remember { mutableStateOf(AprsStore.loadConfig(context)) }
+ var showDialog by remember { mutableStateOf(false) }
+
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ // 标题行:标题 + 设置键(弹窗,省空间)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(id = R.string.prefs_aprs_title),
+ color = MaterialTheme.colorScheme.primary
+ )
+ IconButton(onClick = { showDialog = true }) {
+ Text(
+ text = "\u2699", // ⚙
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 18.sp
+ )
+ }
+ }
+ // 启用开关
+ AprsSwitchRow(R.string.prefs_aprs_enable, config.enabled) { enabled ->
+ config = config.copy(enabled = enabled)
+ AprsStore.saveConfig(context, config)
+ val intent = Intent()
+ .setClassName(context.packageName, AprsStore.SERVICE_CLASS)
+ .setAction(if (enabled) AprsStore.ACTION_START else AprsStore.ACTION_STOP)
+ context.startService(intent)
+ }
+ // 简洁信息行(不挤)
+ Text(
+ text = stringResource(
+ id = R.string.prefs_aprs_summary,
+ config.server, config.port.toString(),
+ if (config.callsign.isBlank()) "-" else config.callsign
+ ),
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ // 手动上报按钮
+ CardButton(
+ onClick = {
+ val intent = Intent()
+ .setClassName(context.packageName, AprsStore.SERVICE_CLASS)
+ .setAction(AprsStore.ACTION_REPORT_NOW)
+ context.startService(intent)
+ },
+ text = stringResource(id = R.string.prefs_aprs_report_now),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ if (showDialog) {
+ AprsSettingsDialog(
+ config = config,
+ onDismiss = { showDialog = false },
+ onSave = { newConfig ->
+ config = newConfig
+ AprsStore.saveConfig(context, newConfig)
+ // 已启用时应用新配置(重启服务)
+ if (newConfig.enabled) {
+ context.startService(
+ Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
+ .setAction(AprsStore.ACTION_STOP)
+ )
+ context.startService(
+ Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
+ .setAction(AprsStore.ACTION_START)
+ )
+ }
+ showDialog = false
+ }
+ )
+ }
+}
+
+/** APRS 设置弹窗(空间充裕,配置项齐全) */
+@Composable
+private fun AprsSettingsDialog(
+ config: AprsConfig,
+ onDismiss: () -> Unit,
+ onSave: (AprsConfig) -> Unit
+) {
+ var server by remember { mutableStateOf(config.server) }
+ var port by remember { mutableStateOf(config.port.toString()) }
+ var callsign by remember { mutableStateOf(config.callsign) }
+ var ssid by remember { mutableStateOf(config.ssid) }
+ var passcode by remember { mutableStateOf(config.passcode) }
+ var interval by remember { mutableStateOf(config.intervalMin.toString()) }
+ var status by remember { mutableStateOf(config.statusText) }
+ var symbolTable by remember { mutableStateOf(config.symbolTable) }
+ var symbolCode by remember { mutableStateOf(config.symbolCode) }
+ val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(id = R.string.prefs_aprs_settings_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ OutlinedTextField(
+ value = server,
+ onValueChange = { server = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_server)) },
+ singleLine = true,
+ textStyle = textStyle
+ )
+ OutlinedTextField(
+ value = port,
+ onValueChange = { port = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_port)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ textStyle = textStyle
+ )
+ OutlinedTextField(
+ value = callsign,
+ onValueChange = { callsign = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_callsign)) },
+ singleLine = true,
+ textStyle = textStyle
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = ssid,
+ onValueChange = { ssid = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_ssid)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ textStyle = textStyle
+ )
+ OutlinedTextField(
+ value = passcode,
+ onValueChange = { passcode = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_passcode)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ textStyle = textStyle
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = interval,
+ onValueChange = { interval = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_interval)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ textStyle = textStyle
+ )
+ OutlinedTextField(
+ value = status,
+ onValueChange = { status = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_status)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ textStyle = textStyle
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = symbolTable,
+ onValueChange = { symbolTable = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ textStyle = textStyle
+ )
+ OutlinedTextField(
+ value = symbolCode,
+ onValueChange = { symbolCode = it },
+ label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ textStyle = textStyle
+ )
+ }
+ Text(
+ text = stringResource(id = R.string.prefs_aprs_passcode_hint),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ onSave(
+ config.copy(
+ server = server.ifBlank { "euro.aprs2.net" },
+ port = port.toIntOrNull() ?: 14580,
+ callsign = callsign.trim().uppercase(),
+ ssid = ssid.trim(),
+ passcode = passcode.trim(),
+ intervalMin = interval.toIntOrNull() ?: 5,
+ statusText = status,
+ symbolTable = symbolTable.ifBlank { "/" },
+ symbolCode = symbolCode.ifBlank { ">" }
+ )
+ )
+ }) { Text(stringResource(id = R.string.prefs_aprs_save)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.prefs_aprs_cancel)) }
+ }
+ )
+}
+
+/** 本文件内开关行(与 SettingsScreen 的私有 SwitchRow 一致) */
+@Composable
+private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = labelResId))
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+}
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index f5a33c29..656ef04d 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -381,6 +381,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
showDataSourcesDialog = { dialogs.dataSources = true }
)
}
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ AprsCard()
+ }
item(span = { GridItemSpan(maxLineSpan) }) {
OutputCard(
onNetworkClick = permissions.launchNetwork,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 41c552b9..afe393a3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
-appVersionCode = "453"
+appVersionCode = "454"
#noinspection UnusedVersionCatalogEntry
-appVersionName = "4.5.3"
+appVersionName = "4.5.4"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry