fix: native decoder crash on 64-bit devices; restore calculator layout

This commit is contained in:
atsunatsu committed 2026-08-07 11:25:42 +08:00
1 parent ddecaa6017
commit dbf9a2a65f
3 files changed
+424 -157

No files matched your search

+15 -3
View File
@@ -3,10 +3,22 @@ plugins {
} }
android { android {
// PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a. namespace = libs.versions.packageName.get()
defaultConfig { defaultConfig {
ndk { applicationId = "cn.ba7opf.look4sat"
abiFilters += listOf("armeabi-v7a") ndk { abiFilters.add("armeabi-v7a") }
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
storePassword = "look4sat123"
keyAlias = "look4sat"
keyPassword = "look4sat123"
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
} }
} }
} }
@@ -16,20 +16,28 @@ import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
* *
* For a linear (passband) transponder, uplink and downlink frequencies are * The full physical path:
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* *
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) * TX→RX (uplink → downlink):
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) * ① 地面发射 f_tx
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
*
* RX→TX (downlink → uplink):
* ④ 地面听到 f_rx
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
* ② 卫星收到 = 逆passband映射(③)
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
* *
* Addresses GitHub issue #91 (Custom frequency Doppler correction). * Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/ */
object DopplerFrequencyCalculator { object DopplerFrequencyCalculator {
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency. * Given a downlink frequency (what the user hears), compute the
* Returns null if the transponder is not a linear passband type. * uplink frequency the user should transmit.
* Full path: ④→③→②→①
*/ */
fun computeUplinkFromDownlink( fun computeUplinkFromDownlink(
downlinkHz: Long, downlinkHz: Long,
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率
return orbitalPos.getUplinkFreq(baseUplink) val satTx = orbitalPos.getUplinkFreq(downlinkHz)
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given a downlink frequency, compute the Doppler-corrected uplink frequency * Given a downlink frequency (what the user hears), compute the
* with an offset applied to the downlink (in Hz). * uplink frequency the user should transmit, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ④→③→②→①
* *
* The user-entered downlink frequency already includes the offset, so subtract * The user-entered downlink frequency already includes the offset, so subtract
* it before mapping the downlink passband position back to the uplink. * it before the inverse downlink Doppler.
*/ */
fun computeUplinkFromDownlinkWithOffset( fun computeUplinkFromDownlinkWithOffset(
downlinkHz: Long, downlinkHz: Long,
@@ -56,13 +69,20 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null // ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
return orbitalPos.getUplinkFreq(baseUplink) val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
// ③ 去掉 offset(offset 在卫星本地频率域)
val satTx = satTxWithOffset - offsetHz
// ③→② 逆 passband 映射
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
// ②→① 逆上行多普勒:地面应发射的频率
return orbitalPos.getUplinkFreq(satRx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency. * Given an uplink frequency (what the user transmits), compute the
* Returns null if the transponder is not a linear passband type. * downlink frequency the user will hear.
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplink( fun computeDownlinkFromUplink(
uplinkHz: Long, uplinkHz: Long,
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
orbitalPos: OrbitalPos orbitalPos: OrbitalPos
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx)
} }
/** /**
* Given an uplink frequency, compute the Doppler-corrected downlink frequency * Given an uplink frequency (what the user transmits), compute the
* with an offset applied to the downlink (in Hz). * downlink frequency the user will hear, with an offset applied
* Returns null if the transponder is not a linear passband type. * to the downlink (in Hz).
* Full path: ①→②→③→④
*/ */
fun computeDownlinkFromUplinkWithOffset( fun computeDownlinkFromUplinkWithOffset(
uplinkHz: Long, uplinkHz: Long,
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
offsetHz: Long offsetHz: Long
): Long? { ): Long? {
if (!isLinearTransponder(transponder)) return null if (!isLinearTransponder(transponder)) return null
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null // ①→② 上行多普勒:卫星收到的频率
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz) val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
// ②→③ passband 映射
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
// ③ 加上 offset(offset 在卫星本地频率域)
// ③→④ 下行多普勒:地面听到的
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
} }
/** True if this transponder supports linear passband mapping. */ /** True if this transponder supports linear passband mapping. */
@@ -23,14 +23,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -41,6 +45,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -49,22 +54,30 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -76,6 +89,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -146,9 +160,7 @@ fun CalculatorPage(
} }
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
if (calculatorTransceivers.size > 1) { if (calculatorTransceivers.size > 1) {
@@ -532,6 +544,8 @@ private fun ExpandedRadioControl(
} }
} }
private enum class EditedField { TX, PASSBAND, RX }
@Composable @Composable
private fun DopplerFrequencyCalculator( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
@@ -540,143 +554,350 @@ private fun DopplerFrequencyCalculator(
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember { mutableStateOf("") } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var rxInputMHz by remember { mutableStateOf("") } var txFrequencyHz by remember { mutableStateOf(0L) }
var rxFrequencyHz by remember { mutableStateOf(0L) }
var passbandPosition by remember { mutableStateOf(0.5f) }
var offsetKHz by remember { mutableStateOf("") } var offsetKHz by remember { mutableStateOf("") }
var lastEditedBy by remember { mutableStateOf(EditedField.TX) } var stepSizeKHz by remember { mutableIntStateOf(1) }
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
// Real-time refresh: when orbitalPos changes (1Hz), recompute the opposite field val txLow = transponder.uplinkLow ?: return
val txHigh = transponder.uplinkHigh ?: return
val rxLow = transponder.downlinkLow ?: return
val rxHigh = transponder.downlinkHigh ?: return
val txRange = txHigh - txLow
val rxRange = rxHigh - rxLow
if (txRange <= 0L || rxRange <= 0L) return
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
}
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
LaunchedEffect(orbitalPos) { LaunchedEffect(orbitalPos) {
if (lastEditedBy == EditedField.TX) { if (lastEditedField == EditedField.TX) {
val txMHz = txInputMHz.toDoubleOrNull() // TX 是锚点,不变;重算 RX
if (txMHz != null && txMHz > 0) { rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
val txHz = (txMHz * 1_000_000).toLong() txFrequencyHz, rawTransponder, orbitalPos, offsetHz
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( ) ?: rxFrequencyHz
txHz, transponder, orbitalPos, offsetHz } else if (lastEditedField == EditedField.RX) {
) // RX 是锚点,不变;重算 TX
if (rxHz != null) { txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
} }
} }
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
passbandPosition
} else { } else {
val rxMHz = rxInputMHz.toDoubleOrNull() ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
if (txHz != null) {
txInputMHz = String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0)
} }
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} }
} }
} }
Column( Column(
modifier = modifier, modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
// 左半边: TX + RX 按钮
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
}
// 右半边: Offset 输入框(带细线边框)
Box(
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = { offsetKHz = it },
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
// TX 行
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("−", fontSize = 14.sp) }
Slider(
value = txSliderValue,
onValueChange = { norm ->
val rawFreq = txLow + (txRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = stringResource(R.string.radar_doppler_calc), text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 14.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Text( Text(
text = stringResource(R.string.radar_doppler_info), text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) )
// Offset input // RX 行
OutlinedTextField( Column(modifier = Modifier.fillMaxWidth()) {
value = offsetKHz, Row(verticalAlignment = Alignment.CenterVertically) {
onValueChange = { newVal -> OutlinedButton(
offsetKHz = newVal onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
// Trigger recompute based on last edited field modifier = Modifier.width(32.dp).height(28.dp),
if (lastEditedBy == EditedField.TX) { contentPadding = PaddingValues(0.dp),
val txMHz = txInputMHz.toDoubleOrNull() shape = MaterialTheme.shapes.extraSmall
if (txMHz != null && txMHz > 0) { ) { Text("−", fontSize = 14.sp) }
val txHz = (txMHz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( Slider(
txHz, transponder, orbitalPos, offsetHz value = rxSliderValue,
) onValueChange = { norm ->
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else "" val rawFreq = rxLow + (rxRange * norm).toLong()
} val snapped = ((rawFreq + 500) / 1000L) * 1000L
} else { updateRx(snapped.coerceIn(rxLow, rxHigh))
val rxMHz = rxInputMHz.toDoubleOrNull()
if (rxMHz != null && rxMHz > 0) {
val rxHz = (rxMHz * 1_000_000).toLong()
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxHz, transponder, orbitalPos, offsetHz
)
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
}
}
}, },
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, valueRange = 0f..1f,
singleLine = true, modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth()
) )
// TX and RX inputs on the same row OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
modifier = Modifier.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
) { ) {
// TX input → compute RX Text(
OutlinedTextField( text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
value = txInputMHz, fontSize = 12.sp,
onValueChange = { newVal -> color = MaterialTheme.colorScheme.onSurfaceVariant,
txInputMHz = newVal
lastEditedBy = EditedField.TX
val mhz = newVal.toDoubleOrNull()
if (mhz != null && mhz > 0) {
val txHz = (mhz * 1_000_000).toLong()
val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txHz, transponder, orbitalPos, offsetHz
)
rxInputMHz = if (rxHz != null) String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
rxInputMHz = ""
}
},
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Row(
// RX input → compute TX verticalAlignment = Alignment.CenterVertically,
OutlinedTextField( horizontalArrangement = Arrangement.Center
value = rxInputMHz, ) {
onValueChange = { newVal -> Text(
rxInputMHz = newVal text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
lastEditedBy = EditedField.RX fontSize = 20.sp,
val mhz = newVal.toDoubleOrNull() fontWeight = FontWeight.Bold,
if (mhz != null && mhz > 0) { color = MaterialTheme.colorScheme.primary
val rxHz = (mhz * 1_000_000).toLong() )
val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( Text(
rxHz, transponder, orbitalPos, offsetHz text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
) )
txInputMHz = if (txHz != null) String.format(Locale.ENGLISH, "%.6f", txHz / 1_000_000.0) else ""
} else if (newVal.isEmpty()) {
txInputMHz = ""
} }
}, Text(
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
singleLine = true, fontSize = 12.sp,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
textAlign = TextAlign.End
) )
} }
} }
} }
}
private enum class EditedField { TX, RX }
@Composable @Composable
private fun CwDecoderPanel( private fun CwDecoderPanel(
@@ -717,31 +938,37 @@ private fun CwDecoderPanel(
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text. // PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it. // Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
val context = LocalContext.current val context = LocalContext.current
val activity = remember { context as? Activity } val activity = remember(context) {
context as? Activity ?: error("CwDecoderPanel must be hosted in an Activity")
}
val controller = remember { MainActivity() } val controller = remember { MainActivity() }
val rootView = remember { val rootView = remember(context) {
LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout
} }
var initialized by remember { mutableStateOf(false) } var initialized by remember { mutableStateOf(false) }
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
DisposableEffect(Unit) { // 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。
if (activity != null) { // controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。
controller.onCreate(activity, rootView, false) fun startDecoding() {
} if (!initialized) {
onDispose { controller.onCreate(activity, rootView)
controller.onPause()
controller.onDestroy()
}
}
LaunchedEffect(cw.hasPermission) {
if (cw.hasPermission && !initialized) {
controller.onPermissionGranted() controller.onPermissionGranted()
controller.onResume() controller.onResume()
initialized = true initialized = true
} else {
controller.onResume()
}
listening = true listening = true
} }
DisposableEffect(Unit) {
onDispose {
if (initialized) {
controller.onPause()
controller.onDestroy()
}
}
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -754,14 +981,8 @@ private fun CwDecoderPanel(
onClick = { onClick = {
if (!cw.hasPermission) { if (!cw.hasPermission) {
requestMicPermission() requestMicPermission()
} else if (!initialized) {
controller.onPermissionGranted()
controller.onResume()
initialized = true
listening = true
} else { } else {
controller.onResume() startDecoding()
listening = true
} }
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -780,23 +1001,27 @@ private fun CwDecoderPanel(
} }
} }
OutlinedButton( OutlinedButton(
onClick = { controller.clearDecoded() }, onClick = { if (initialized) controller.clearDecoded() },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(stringResource(R.string.radar_cw_reset)) Text(stringResource(R.string.radar_cw_reset))
} }
} }
AndroidView( Box(
factory = { rootView },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(150.dp) .height(150.dp)
) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
) )
} }
} }
} }
} }
}
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {