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

+4
View File
@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.settings"
}
dependencies {
implementation(project(":core:data"))
}
@@ -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)
}
}
@@ -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,