From dbf9a2a65fbc052f4cbb703e05da2dbdc75df403 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 7 Aug 2026 11:25:42 +0800 Subject: [PATCH] fix: native decoder crash on 64-bit devices; restore calculator layout --- app/build.gradle.kts | 20 +- .../utility/DopplerFrequencyCalculator.kt | 78 ++- .../feature/radar/TransceiversPage.kt | 515 +++++++++++++----- 3 files changed, 440 insertions(+), 173 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 979c78a7..649b3400 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,10 +3,22 @@ plugins { } android { - // PR #1 CW decoder uses the Morse Expert native decoder, which ships only armeabi-v7a. + namespace = libs.versions.packageName.get() defaultConfig { - ndk { - abiFilters += listOf("armeabi-v7a") + applicationId = "cn.ba7opf.look4sat" + 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") + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt index 51c59129..5ffc5f12 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -16,20 +16,28 @@ import java.util.Locale /** * Computes Doppler-corrected reciprocal frequencies for linear transponders. * - * For a linear (passband) transponder, uplink and downlink frequencies are - * related by a fixed passband offset. When the satellite moves, both are - * Doppler-shifted. Given one, we compute the other: + * The full physical path: * - * downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler) - * uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler) + * TX→RX (uplink → downlink): + * ① 地面发射 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). */ object DopplerFrequencyCalculator { /** - * Given a downlink frequency, compute the Doppler-corrected uplink frequency. - * Returns null if the transponder is not a linear passband type. + * Given a downlink frequency (what the user hears), compute the + * uplink frequency the user should transmit. + * Full path: ④→③→②→① */ fun computeUplinkFromDownlink( downlinkHz: Long, @@ -37,17 +45,22 @@ object DopplerFrequencyCalculator { orbitalPos: OrbitalPos ): Long? { 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 - * with an offset applied to the downlink (in Hz). - * Returns null if the transponder is not a linear passband type. + * Given a downlink frequency (what the user hears), compute the + * uplink frequency the user should transmit, with an offset applied + * to the downlink (in Hz). + * Full path: ④→③→②→① * * 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( downlinkHz: Long, @@ -56,13 +69,20 @@ object DopplerFrequencyCalculator { offsetHz: Long ): Long? { if (!isLinearTransponder(transponder)) return null - val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null - return orbitalPos.getUplinkFreq(baseUplink) + // ④→③ 逆下行多普勒:卫星转发的频率(含 offset) + 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. - * Returns null if the transponder is not a linear passband type. + * Given an uplink frequency (what the user transmits), compute the + * downlink frequency the user will hear. + * Full path: ①→②→③→④ */ fun computeDownlinkFromUplink( uplinkHz: Long, @@ -70,14 +90,19 @@ object DopplerFrequencyCalculator { orbitalPos: OrbitalPos ): Long? { 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 - * with an offset applied to the downlink (in Hz). - * Returns null if the transponder is not a linear passband type. + * Given an uplink frequency (what the user transmits), compute the + * downlink frequency the user will hear, with an offset applied + * to the downlink (in Hz). + * Full path: ①→②→③→④ */ fun computeDownlinkFromUplinkWithOffset( uplinkHz: Long, @@ -86,8 +111,13 @@ object DopplerFrequencyCalculator { offsetHz: Long ): Long? { 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. */ diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index f4af915b..ee0d5d56 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -23,14 +23,18 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.FilterChip @@ -49,22 +54,30 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType 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.predict.OrbitalPos 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.R import com.rtbishop.look4sat.core.presentation.formatFrequency @@ -146,9 +160,7 @@ fun CalculatorPage( } LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(8.dp), + modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { if (calculatorTransceivers.size > 1) { @@ -532,6 +544,8 @@ private fun ExpandedRadioControl( } } +private enum class EditedField { TX, PASSBAND, RX } + @Composable private fun DopplerFrequencyCalculator( transponder: SatRadio, @@ -540,144 +554,351 @@ private fun DopplerFrequencyCalculator( ) { if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return - var txInputMHz by remember { mutableStateOf("") } - var rxInputMHz by remember { mutableStateOf("") } + var lastEditedField by remember { mutableStateOf(EditedField.TX) } + 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 lastEditedBy by remember { mutableStateOf(EditedField.TX) } + var stepSizeKHz by remember { mutableIntStateOf(1) } 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) { - if (lastEditedBy == EditedField.TX) { - val txMHz = txInputMHz.toDoubleOrNull() - if (txMHz != null && txMHz > 0) { - val txHz = (txMHz * 1_000_000).toLong() - val rxHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( - txHz, transponder, orbitalPos, offsetHz - ) - if (rxHz != null) { - rxInputMHz = String.format(Locale.ENGLISH, "%.6f", rxHz / 1_000_000.0) - } + if (lastEditedField == EditedField.TX) { + // TX 是锚点,不变;重算 RX + rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( + txFrequencyHz, rawTransponder, orbitalPos, offsetHz + ) ?: rxFrequencyHz + } else if (lastEditedField == EditedField.RX) { + // RX 是锚点,不变;重算 TX + txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( + 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 { + ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f) + } + 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 { - val rxMHz = rxInputMHz.toDoubleOrNull() - 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) - } + 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( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = modifier.fillMaxWidth().padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = stringResource(R.string.radar_doppler_calc), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.radar_doppler_info), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - // Offset input - OutlinedTextField( - value = offsetKHz, - onValueChange = { newVal -> - offsetKHz = newVal - // Trigger recompute based on last edited field - if (lastEditedBy == EditedField.TX) { - val txMHz = txInputMHz.toDoubleOrNull() - if (txMHz != null && txMHz > 0) { - val txHz = (txMHz * 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 { - 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 "" - } + 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() + ) } - }, - label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), - modifier = Modifier.fillMaxWidth() - ) + // 右半边: 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 and RX inputs on the same row - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - // TX input → compute RX - OutlinedTextField( - value = txInputMHz, - onValueChange = { newVal -> - 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 + // 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 = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0), + fontSize = 12.sp, + 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 + ) + Text( + text = " MHz", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) - 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) + Text( + text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + } + + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ) - // RX input → compute TX - OutlinedTextField( - value = rxInputMHz, - onValueChange = { newVal -> - rxInputMHz = newVal - lastEditedBy = EditedField.RX - val mhz = newVal.toDoubleOrNull() - if (mhz != null && mhz > 0) { - val rxHz = (mhz * 1_000_000).toLong() - val txHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( - rxHz, transponder, orbitalPos, offsetHz + // RX 行 + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + 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) } + + Slider( + value = rxSliderValue, + onValueChange = { norm -> + val rawFreq = rxLow + (rxRange * norm).toLong() + val snapped = ((rawFreq + 500) / 1000L) * 1000L + updateRx(snapped.coerceIn(rxLow, rxHigh)) + }, + valueRange = 0f..1f, + modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp) + ) + + 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( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + 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 = "" } - }, - label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) + Text( + text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + } } - } } -private enum class EditedField { TX, RX } - @Composable private fun CwDecoderPanel( cw: CwSubState, @@ -717,30 +938,36 @@ private fun CwDecoderPanel( // 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. 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 rootView = remember { - LayoutInflater.from(context).inflate(CwR.layout.cw_panel_main, null) as ConstraintLayout + val rootView = remember(context) { + LayoutInflater.from(context).inflate(CwR.layout.activity_main, null) as ConstraintLayout } var initialized by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) } - DisposableEffect(Unit) { - if (activity != null) { - controller.onCreate(activity, rootView, false) - } - onDispose { - controller.onPause() - controller.onDestroy() - } - } - - LaunchedEffect(cw.hasPermission) { - if (cw.hasPermission && !initialized) { + // 重要: 不在展开时初始化控制器(避免"一展开就崩溃")。 + // controller.onCreate/onResume/onPermissionGranted 全部推迟到用户点 Start 才执行。 + fun startDecoding() { + if (!initialized) { + controller.onCreate(activity, rootView) controller.onPermissionGranted() controller.onResume() initialized = true - listening = true + } else { + controller.onResume() + } + listening = true + } + + DisposableEffect(Unit) { + onDispose { + if (initialized) { + controller.onPause() + controller.onDestroy() + } } } @@ -754,14 +981,8 @@ private fun CwDecoderPanel( onClick = { if (!cw.hasPermission) { requestMicPermission() - } else if (!initialized) { - controller.onPermissionGranted() - controller.onResume() - initialized = true - listening = true } else { - controller.onResume() - listening = true + startDecoding() } }, modifier = Modifier.weight(1f) @@ -780,19 +1001,23 @@ private fun CwDecoderPanel( } } OutlinedButton( - onClick = { controller.clearDecoded() }, + onClick = { if (initialized) controller.clearDecoded() }, modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.radar_cw_reset)) } } - AndroidView( - factory = { rootView }, + Box( modifier = Modifier .fillMaxWidth() .height(150.dp) - ) + ) { + AndroidView( + factory = { rootView }, + modifier = Modifier.fillMaxSize() + ) + } } } }