fix(aprs): upload feedback, manual report reliability, station position

User testing round 3 (4.5.4): no success feedback on upload, aprs.fi
shows nothing, passcode calculator OK, notification present.

- Manual report now works even when service not running: ACTION_REPORT_NOW
  starts the service first (Toast "not configured" if missing callsign)
- Upload result feedback guaranteed: Toast always shows (short OK /
  long fail+reason), last result persisted (time/ok/detail) and shown
  in the settings card "Last report: HH:mm:ss OK/failed - detail"
- Position source: station position from settingsRepo (user decision)
  with live GPS last-known as fallback
- sendPacket reads the server confirmation line (short 3s timeout);
  server error text (Invalid/error) surfaces in Toast + card
- Strings EN/ZH/TR/IN/ID +4 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
This commit is contained in:
mckero committed 2026-08-05 03:29:36 +00:00
1 parent 012ea1eeb6
commit c9d87be289
10 files changed
+131 -19

No files matched your search

@@ -12,6 +12,7 @@ import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.data.aprs.AprsStore
@@ -50,7 +51,13 @@ class AprsForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> reporter?.reportNow()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// 服务没在跑:先启动(未配置会 Toast 提示)
startReporting()
}
reporter?.reportNow()
}
else -> startReporting()
}
return START_STICKY
@@ -59,24 +66,29 @@ class AprsForegroundService : Service() {
private fun startReporting() {
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
}
stopSelf()
return
}
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
positionProvider = { lastKnownPosition() },
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// 手动上报/失败时给即时反馈(前台可见)
if (report.timestamp > System.currentTimeMillis() - 60_000L) {
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching { Toast.makeText(this, msg, Toast.LENGTH_LONG).show() }
// 上报结果必达:成功 Toast 短显,失败 Toast 长显+原因
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
}
)
@@ -134,8 +146,17 @@ class AprsForegroundService : Service() {
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** 取最近已知位置(APRSdroid PeriodicGPS 同思路,无权限时返回 null) */
private fun lastKnownPosition(): Pair<Double, Double>? {
/** 上报位置:优先用设置里的站位(用户拍板);站位无效时实时 GPS 兜底 */
private fun stationPosition(): Pair<Double, Double>? {
// ① 站位(设置页设置的位置)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
}.getOrNull()
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// ② 兜底:实时 GPS 最后位置
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
@@ -59,13 +59,33 @@ class AprsIsClient(
}
}
/** 发送一个 APRS 包(一行) */
fun sendPacket(packetLine: String): Boolean {
/**
* 发送一个 APRS 包(一行)并尝试读服务器确认。
* 返回 null=发送失败;Pair(ok, detail)=发送结果(服务器错误原文在 detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return false
val w = writer ?: return null
w.println(packetLine)
return !w.checkError()
if (w.checkError()) return Pair(false, "write failed")
}
// 尝试读服务器响应(短超时 3s;APRS-IS 对格式错误会回错误行)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}
/** 读一行(服务器响应,超时抛异常) */
@@ -99,9 +99,10 @@ class AprsReporter(
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"))
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
@@ -24,6 +24,9 @@ object AprsStore {
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** 读取配置(填入即保存,无需每次打开重填) */
fun loadConfig(context: Context): AprsConfig {
@@ -42,6 +45,26 @@ object AprsStore {
)
}
/** 上次上报结果(供设置卡片显示) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return LastReport(
time = p.getLong(KEY_LAST_TIME, 0L),
ok = p.getBoolean(KEY_LAST_OK, false),
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
)
}
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
.putBoolean(KEY_LAST_OK, ok)
.putString(KEY_LAST_DETAIL, detail)
.apply()
}
/** 保存配置 */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
@@ -38,6 +38,10 @@
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
@@ -38,6 +38,10 @@
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
@@ -39,6 +39,10 @@
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<string name="nav_amsat">AMSAT</string>
@@ -40,6 +40,10 @@
<string name="prefs_aprs_calc_passcode">计算验证码</string>
<string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string>
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<string name="nav_amsat">AMSAT</string>
@@ -41,6 +41,10 @@
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
<string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">failed - %1$s</string>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
@@ -44,6 +44,8 @@ fun AprsCard() {
val context = LocalContext.current
var config by remember { mutableStateOf(AprsStore.loadConfig(context)) }
var showDialog by remember { mutableStateOf(false) }
var lastReport by remember { mutableStateOf(AprsStore.loadLastReport(context)) }
var reportPing by remember { mutableStateOf(0) }
val notifPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { }
@@ -98,6 +100,23 @@ fun AprsCard() {
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 上次上报状态行
val lr = lastReport
if (lr.time > 0L) {
val timeStr = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
.format(java.util.Date(lr.time))
Text(
text = stringResource(
id = R.string.prefs_aprs_last_report,
timeStr,
if (lr.ok) stringResource(R.string.prefs_aprs_last_ok)
else stringResource(R.string.prefs_aprs_last_fail, lr.detail)
),
fontSize = 13.sp,
color = if (lr.ok) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
}
// 手动上报按钮
CardButton(
onClick = {
@@ -105,12 +124,20 @@ fun AprsCard() {
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
.setAction(AprsStore.ACTION_REPORT_NOW)
context.startForegroundService(intent)
reportPing++
},
text = stringResource(id = R.string.prefs_aprs_report_now),
modifier = Modifier.fillMaxWidth()
)
}
}
// 上报后延迟刷新状态行(等结果落盘)
androidx.compose.runtime.LaunchedEffect(reportPing) {
if (reportPing > 0) {
kotlinx.coroutines.delay(6000)
lastReport = AprsStore.loadLastReport(context)
}
}
if (showDialog) {
AprsSettingsDialog(
config = config,