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