mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-07 04:57:23 +00:00
feat: add CW decoder and linear calculator page
This commit is contained in:
1 parent
a7b41c95c7
commit
a333192df5
70 files changed
+12470
-94
No files matched your search
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
|
||||
*
|
||||
* 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类):
|
||||
* - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/
|
||||
* statusTextView/verticalLayout/waterfallView);
|
||||
* - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致
|
||||
* (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy);
|
||||
* - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()),
|
||||
* 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E);
|
||||
* - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。
|
||||
*
|
||||
* 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为
|
||||
* decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.cw
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
|
||||
@Composable
|
||||
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val activity = remember(context) {
|
||||
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
|
||||
}
|
||||
// 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例
|
||||
val controller = remember { MainActivity() }
|
||||
// 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图
|
||||
val rootView = remember(context) {
|
||||
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
|
||||
}
|
||||
|
||||
var permissionGranted by remember {
|
||||
mutableStateOf(
|
||||
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var permanentlyDenied by remember { mutableStateOf(false) }
|
||||
// v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次
|
||||
var initialized by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
|
||||
// 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy
|
||||
DisposableEffect(Unit) {
|
||||
controller.onCreate(activity, rootView)
|
||||
controller.onResume()
|
||||
onDispose {
|
||||
controller.onPause()
|
||||
controller.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
// 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里
|
||||
LaunchedEffect(permissionGranted) {
|
||||
if (permissionGranted && !initialized) {
|
||||
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
||||
// v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音
|
||||
// (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等)
|
||||
controller.onResume()
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
permissionGranted = granted
|
||||
permanentlyDenied = !granted &&
|
||||
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出
|
||||
BackHandler {
|
||||
if (!controller.handleBackPress()) navigateUp()
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
// 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
IconButton(onClick = { controller.togglePause() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_pause_24),
|
||||
contentDescription = stringResource(R.string.pause)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { controller.clearDecoded() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_delete_24),
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { controller.saveText() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_baseline_save_24),
|
||||
contentDescription = stringResource(R.string.save_text)
|
||||
)
|
||||
}
|
||||
// recordSignals 需要解码核心(f11037F), 未初始化时禁用
|
||||
TextButton(
|
||||
onClick = { if (initialized) controller.recordSignals() },
|
||||
enabled = initialized
|
||||
) {
|
||||
Text(stringResource(R.string.record_signals))
|
||||
}
|
||||
TextButton(onClick = { showSettings = true }) {
|
||||
Text(stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
AndroidView(
|
||||
factory = { rootView },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
if (!permissionGranted) {
|
||||
// 未授予麦克风权限: 遮罩 + 提示文字 + 请求按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0x99000000)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Microphone permission is required for CW decoding",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}) {
|
||||
Text("Grant permission")
|
||||
}
|
||||
if (permanentlyDenied) {
|
||||
// 勾选了"不再询问": 引导去系统设置开启
|
||||
TextButton(onClick = {
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:${activity.packageName}")
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Text("Open app settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.cw
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */
|
||||
private const val KEY_MESSAGE_TYPE = "message_type"
|
||||
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
|
||||
private const val DEFAULT_MESSAGE_TYPE = "general_text"
|
||||
private const val DEFAULT_TEXT_FONT_SIZE = 18
|
||||
private const val MIN_FONT_SIZE = 7
|
||||
private const val MAX_FONT_SIZE = 99
|
||||
|
||||
/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */
|
||||
private val PaletteColors = listOf(
|
||||
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
|
||||
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
|
||||
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
|
||||
)
|
||||
|
||||
/**
|
||||
* CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。
|
||||
* 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色
|
||||
* (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。
|
||||
* 存储: SharedPreferences(getPackageName() + "_preferences")。
|
||||
* 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。
|
||||
*/
|
||||
@Composable
|
||||
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
||||
val activity = controller.mActivity ?: return
|
||||
val prefs = remember(activity) {
|
||||
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var messageType by remember {
|
||||
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
|
||||
}
|
||||
var fontSize by remember {
|
||||
mutableStateOf(
|
||||
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
|
||||
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
|
||||
)
|
||||
}
|
||||
var colorValues by remember {
|
||||
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("CW Settings") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
Text("Message type", style = MaterialTheme.typography.titleSmall)
|
||||
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
|
||||
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Text font size", style = MaterialTheme.typography.titleSmall)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Slider(
|
||||
value = fontSize,
|
||||
onValueChange = { fontSize = it },
|
||||
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
|
||||
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "${fontSize.roundToInt()}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.width(44.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("Colors", style = MaterialTheme.typography.titleSmall)
|
||||
for (i in 0 until 9) {
|
||||
ColorSettingRow(
|
||||
title = I2.b.c[i],
|
||||
value = colorValues[i],
|
||||
onSelect = { selected ->
|
||||
colorValues = colorValues.copyOf().also { it[i] = selected }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val editor = prefs.edit()
|
||||
editor.putString(KEY_MESSAGE_TYPE, messageType)
|
||||
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
|
||||
for (i in 0 until 9) {
|
||||
editor.putInt(I2.b.f663b[i], colorValues[i])
|
||||
}
|
||||
editor.apply()
|
||||
// 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位
|
||||
controller.onResume()
|
||||
onDismiss()
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { onSelect(option) }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = option == selected, onClick = { onSelect(option) })
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(value)),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = String.format("#%06X", value and 0xFFFFFF),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PaletteGrid(selected = value, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PaletteColors.chunked(3).forEach { rowColors ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rowColors.forEach { color ->
|
||||
val isSelected = color == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.border(
|
||||
width = if (isSelected) 3.dp else 1.dp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(color))
|
||||
.clickable { onSelect(color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.rtbishop.look4sat.feature.cw.suncompat;
|
||||
|
||||
/**
|
||||
* 编译期 stub:Android 无 sun.misc.Cleaner(照搬 Morse Expert 1.15 k3 FFT 库的清理回调,
|
||||
* 实际 FFT 1024 点不触发 native 路径)。
|
||||
*/
|
||||
public class Cleaner {
|
||||
public static Cleaner create(Object referent, Runnable thunk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void clean() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.rtbishop.look4sat.feature.cw.suncompat;
|
||||
|
||||
/**
|
||||
* 编译期 stub:Android 无 sun.misc.Unsafe(运行时通过反射获取,失败时 k3.r.f12109a 为 null)。
|
||||
* 照搬自 Morse Expert 1.15(k3 FFT 库的大数组 native 路径 >1GB 不触发,实际 FFT 走 float[] 分支)。
|
||||
*/
|
||||
public class Unsafe {
|
||||
public long allocateMemory(long bytes) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void freeMemory(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public byte getByte(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putByte(long address, byte value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public short getShort(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putShort(long address, short value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public int getInt(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putInt(long address, int value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public long getLong(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putLong(long address, long value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public float getFloat(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putFloat(long address, float value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public double getDouble(long address) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putDouble(long address, double value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void setMemory(long address, long bytes, byte value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void copyMemory(long src, long dst, long len) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public Object getObject(Object o, long offset) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public void putObject(Object o, long offset, Object value) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
|
||||
public long objectFieldOffset(java.lang.reflect.Field field) {
|
||||
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.ve3nea.morse_expert;
|
||||
|
||||
import F2.a;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.text.Layout;
|
||||
import android.text.Selection;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateFormat;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.ActionMode;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 照搬自 Morse Expert 1.15 DecodedTextView, 类名保持混淆原名。
|
||||
* 适配: 原 extends l.C1702e0(appcompat 混淆类) -> 直接 extends TextView
|
||||
* (C1702e0 仅提供 TextView 兼容功能, 本类未使用其特有方法)。
|
||||
*/
|
||||
public class DecodedTextView extends TextView {
|
||||
|
||||
/* renamed from: m, reason: collision with root package name */
|
||||
public final Context f11031m;
|
||||
|
||||
/* renamed from: n, reason: collision with root package name */
|
||||
public boolean f11032n;
|
||||
|
||||
/* renamed from: o, reason: collision with root package name */
|
||||
public final AtomicBoolean f11033o;
|
||||
|
||||
public DecodedTextView(Context context, AttributeSet attributeSet) {
|
||||
super(context, attributeSet);
|
||||
this.f11032n = false;
|
||||
this.f11033o = new AtomicBoolean(false);
|
||||
this.f11031m = context;
|
||||
setCustomSelectionActionModeCallback(getActionModeCallback());
|
||||
}
|
||||
|
||||
private ActionMode.Callback getActionModeCallback() {
|
||||
return new a(this);
|
||||
}
|
||||
|
||||
private String getDecodedText() {
|
||||
int selectionStart = getSelectionStart();
|
||||
int selectionEnd = getSelectionEnd();
|
||||
int max = Math.max(0, Math.min(selectionStart, selectionEnd));
|
||||
int max2 = Math.max(0, Math.max(selectionStart, selectionEnd));
|
||||
if (max2 > max) {
|
||||
return getText().subSequence(max, max2).toString();
|
||||
}
|
||||
return getText().toString();
|
||||
}
|
||||
|
||||
@Override // android.widget.TextView
|
||||
public final void onSelectionChanged(int i4, int i5) {
|
||||
super.onSelectionChanged(i4, i5);
|
||||
if (hasSelection()) {
|
||||
this.f11032n = true;
|
||||
}
|
||||
}
|
||||
|
||||
public final void q(SpannableStringBuilder spannableStringBuilder) {
|
||||
boolean z3;
|
||||
try {
|
||||
CharSequence text = getText();
|
||||
Layout layout = getLayout();
|
||||
int lineCount = getLineCount();
|
||||
int length = text.length();
|
||||
int lineHeight = getLineHeight();
|
||||
int scrollY = getScrollY();
|
||||
if (layout.getLineBottom(lineCount - 1) == getHeight() + scrollY) {
|
||||
z3 = true;
|
||||
} else {
|
||||
z3 = false;
|
||||
}
|
||||
getSelectionStart();
|
||||
getSelectionEnd();
|
||||
hasSelection();
|
||||
if (z3) {
|
||||
Selection.setSelection((Spannable) text, length, length);
|
||||
}
|
||||
append(spannableStringBuilder);
|
||||
int i4 = lineCount - 50;
|
||||
if (i4 > 0) {
|
||||
int i5 = lineHeight * i4;
|
||||
getEditableText().delete(0, layout.getLineEnd(lineCount - 51));
|
||||
if (scrollY >= i5) {
|
||||
scrollBy(0, -i5);
|
||||
}
|
||||
}
|
||||
} catch (Exception e4) {
|
||||
Log.e("printChars()", "Exception: ", e4);
|
||||
}
|
||||
}
|
||||
|
||||
public final void r() {
|
||||
String decodedText = getDecodedText();
|
||||
boolean isEmpty = TextUtils.isEmpty(decodedText);
|
||||
Context context = this.f11031m;
|
||||
if (isEmpty) {
|
||||
Toast.makeText(context, "No text to save", 0).show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File file = new File(getContext().getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + ".txt");
|
||||
String absolutePath = file.getAbsolutePath();
|
||||
PrintWriter printWriter = new PrintWriter(file);
|
||||
try {
|
||||
printWriter.println(decodedText);
|
||||
printWriter.close();
|
||||
MediaScannerConnection.scanFile(context, new String[]{absolutePath}, new String[]{"text/plain"}, null);
|
||||
Toast.makeText(context, "Text saved to " + absolutePath, 1).show();
|
||||
} catch (Throwable th) {
|
||||
printWriter.close();
|
||||
throw th;
|
||||
}
|
||||
} catch (Exception e4) {
|
||||
Log.e("File Save", "Failed", e4);
|
||||
Toast.makeText(context, "Unable to save: " + e4.getMessage(), 1).show();
|
||||
}
|
||||
}
|
||||
|
||||
public final void s() {
|
||||
Intent intent = new Intent("android.intent.action.SEND");
|
||||
intent.setFlags(268435456);
|
||||
intent.putExtra("android.intent.extra.TEXT", getDecodedText());
|
||||
intent.setType("text/plain");
|
||||
this.f11031m.startActivity(Intent.createChooser(intent, "Share Decoded Text"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
|
||||
package com.ve3nea.morse_expert;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import com.rtbishop.look4sat.feature.cw.R;
|
||||
import E2.g;
|
||||
import H2.a;
|
||||
import H2.b;
|
||||
import j1.C1646n;
|
||||
|
||||
/**
|
||||
* Ported verbatim from Morse Expert 1.15 MainActivity (com.ve3nea.morse_expert); obfuscated
|
||||
* class/field names kept as-is. Surgery notes: originally extends e.AbstractActivityC1557l
|
||||
* (obfuscated AppCompat base) -> now a plain controller class, Activity injected via
|
||||
* onCreate(Activity, ConstraintLayout) for Compose AndroidView integration. Ads (d1.*),
|
||||
* billing (I2.a), premium/rate menu removed per user. h3.b.r (recursive findViewById),
|
||||
* K1.a.M (immersive status bar), B.i (permissions) replaced with equivalent standard APIs.
|
||||
* Decoder logic (H2.a audio, H2.b core, pas engine) untouched.
|
||||
*/
|
||||
public class MainActivity {
|
||||
|
||||
public static final /* synthetic */ int f11034K = 0;
|
||||
|
||||
/* renamed from: D, reason: collision with root package name */
|
||||
public C1646n f11035D;
|
||||
|
||||
/* renamed from: E, reason: collision with root package name */
|
||||
public a f11036E;
|
||||
|
||||
/* renamed from: F, reason: collision with root package name */
|
||||
public b f11037F;
|
||||
|
||||
/* renamed from: G, reason: collision with root package name */
|
||||
public long f11038G = 0;
|
||||
|
||||
/* renamed from: J, reason: collision with root package name */
|
||||
public final g f11040J = new g(this, 1);
|
||||
|
||||
/** Injected Activity context (replaces the original this Activity capabilities) */
|
||||
public android.app.Activity mActivity;
|
||||
|
||||
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout) {
|
||||
onCreate(activity, constraintLayout, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* applyImmersive=false 用于嵌入小容器(如转发器 CW 面板):
|
||||
* 不触碰宿主窗口的系统栏标志(避免影响整个 Activity)。
|
||||
*/
|
||||
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout, boolean applyImmersive) {
|
||||
this.mActivity = activity;
|
||||
if (applyImmersive) {
|
||||
// K1.a.M(getWindow(), false): immersive status bar (nav/fullscreen flags, ported as-is)
|
||||
setImmersive(activity.getWindow(), false);
|
||||
}
|
||||
View inflate = constraintLayout;
|
||||
int i4 = R.id.decodedTextView;
|
||||
DecodedTextView decodedTextView = (DecodedTextView) findViewRecursive(inflate, R.id.decodedTextView);
|
||||
if (decodedTextView != null) {
|
||||
i4 = R.id.scaleView;
|
||||
ScaleView scaleView = (ScaleView) findViewRecursive(inflate, R.id.scaleView);
|
||||
if (scaleView != null) {
|
||||
i4 = R.id.statusTextView;
|
||||
TextView textView = (TextView) findViewRecursive(inflate, R.id.statusTextView);
|
||||
if (textView != null) {
|
||||
i4 = R.id.verticalLayout;
|
||||
LinearLayout linearLayout = (LinearLayout) findViewRecursive(inflate, R.id.verticalLayout);
|
||||
if (linearLayout != null) {
|
||||
i4 = R.id.waterfallView;
|
||||
WaterfallSurfaceView waterfallSurfaceView = (WaterfallSurfaceView) findViewRecursive(inflate, R.id.waterfallView);
|
||||
if (waterfallSurfaceView != null) {
|
||||
this.f11035D = new C1646n(constraintLayout, decodedTextView, scaleView, textView, linearLayout, waterfallSurfaceView);
|
||||
((WaterfallSurfaceView) this.f11035D.f11892k).setOnTouchListener(this.f11040J);
|
||||
// Originally: permission check then v(); permission flow handled by the Compose page
|
||||
// Originally: ad banner mounting (removed)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new NullPointerException("Missing required view with ID: " + inflate.getResources().getResourceName(i4));
|
||||
}
|
||||
|
||||
/** Original h3.b.r: recursive findViewById (ported behavior) */
|
||||
private static View findViewRecursive(View view, int id) {
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup viewGroup = (ViewGroup) view;
|
||||
int childCount = viewGroup.getChildCount();
|
||||
for (int i = 0; i < childCount; i++) {
|
||||
View found = viewGroup.getChildAt(i).findViewById(id);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Original K1.a.M(window, false): immersive (hide nav/fullscreen flags, ported as-is) */
|
||||
private static void setImmersive(Window window, boolean immersive) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
window.setDecorFitsSystemWindows(!immersive);
|
||||
if (immersive) {
|
||||
window.getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN);
|
||||
} else {
|
||||
window.getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
|
||||
}
|
||||
} else {
|
||||
View decorView = window.getDecorView();
|
||||
int systemUiVisibility = decorView.getSystemUiVisibility();
|
||||
decorView.setSystemUiVisibility(immersive ? (systemUiVisibility & (-1793)) : (systemUiVisibility | 1792));
|
||||
}
|
||||
}
|
||||
|
||||
/** Original v(): audio capture + decoder core init (logic unchanged) */
|
||||
public void v() {
|
||||
this.f11036E = new a();
|
||||
b bVar = new b(mActivity.getApplicationContext());
|
||||
this.f11037F = bVar;
|
||||
this.f11036E.f601l = bVar;
|
||||
bVar.f619o = (WaterfallSurfaceView) this.f11035D.f11892k;
|
||||
bVar.f620p = this;
|
||||
bVar.f621q = this;
|
||||
}
|
||||
|
||||
/** Original onResume: restore decoding (message type/font/background/record start/waterfall) */
|
||||
public void onResume() {
|
||||
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
|
||||
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
|
||||
String messageType = prefs.getString("message_type", "general_text");
|
||||
b bVar = this.f11037F;
|
||||
if (bVar != null) {
|
||||
bVar.f623s.set(messageType.equals("ham_radio_qso"));
|
||||
}
|
||||
((DecodedTextView) this.f11035D.f11888g).setTextSize(Math.max(7, prefs.getInt("text_font_size", 18)));
|
||||
b bVar2 = this.f11037F;
|
||||
if (bVar2 != null) {
|
||||
bVar2.f617m.b(mActivity);
|
||||
((DecodedTextView) this.f11035D.f11888g).setBackgroundColor(this.f11037F.f617m.f665a[0]);
|
||||
}
|
||||
a aVar = this.f11036E;
|
||||
if (aVar != null) {
|
||||
android.media.AudioRecord audioRecord = aVar.f596g;
|
||||
audioRecord.startRecording();
|
||||
if (audioRecord.getState() != 1) {
|
||||
android.util.Log.e("AudioRecord", "cannot start");
|
||||
}
|
||||
aVar.f598i.set(true);
|
||||
}
|
||||
((WaterfallSurfaceView) this.f11035D.f11892k).onResume();
|
||||
// Originally: ad lifecycle (removed)
|
||||
}
|
||||
|
||||
/** Original onPause: stop recording + waterfall pause */
|
||||
public void onPause() {
|
||||
a aVar = this.f11036E;
|
||||
if (aVar != null) {
|
||||
aVar.f598i.set(false);
|
||||
android.media.AudioRecord audioRecord = aVar.f596g;
|
||||
audioRecord.stop();
|
||||
if (audioRecord.getState() != 1) {
|
||||
android.util.Log.e("AudioRecord", "cannot stop");
|
||||
}
|
||||
}
|
||||
((WaterfallSurfaceView) this.f11035D.f11892k).onPause();
|
||||
}
|
||||
|
||||
/** Original onDestroy: release audio/decoder (billing cleanup removed) */
|
||||
public void onDestroy() {
|
||||
a aVar = this.f11036E;
|
||||
if (aVar != null) {
|
||||
aVar.f598i.set(false);
|
||||
try {
|
||||
aVar.f596g.stop();
|
||||
aVar.f596g.release();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
aVar.f597h.shutdownNow();
|
||||
}
|
||||
this.f11036E = null;
|
||||
this.f11037F = null;
|
||||
}
|
||||
|
||||
/** Menu: clear decoded text (original clear_mnu) */
|
||||
public void clearDecoded() {
|
||||
((DecodedTextView) this.f11035D.f11888g).setText("");
|
||||
}
|
||||
|
||||
/** Menu: pause/resume decoding (original pause_mnu, red/white icon toggle logic) */
|
||||
public boolean togglePause() {
|
||||
java.util.concurrent.atomic.AtomicBoolean atomicBoolean = ((DecodedTextView) this.f11035D.f11888g).f11033o;
|
||||
atomicBoolean.set(!atomicBoolean.get());
|
||||
return atomicBoolean.get();
|
||||
}
|
||||
|
||||
/** Menu: save text (original save_mnu -> DecodedTextView.r()) */
|
||||
public void saveText() {
|
||||
((DecodedTextView) this.f11035D.f11888g).r();
|
||||
}
|
||||
|
||||
/** Menu: record signals (original record_signals_mnu) */
|
||||
public void recordSignals() {
|
||||
this.f11037F.f626v.set(true);
|
||||
}
|
||||
|
||||
/** Original tap_back_again_to_close hint */
|
||||
public boolean handleBackPress() {
|
||||
if (System.currentTimeMillis() < this.f11038G + 2000) {
|
||||
return true; // double-back: exit
|
||||
}
|
||||
this.f11038G = System.currentTimeMillis();
|
||||
Toast.makeText(mActivity, R.string.tap_back_again_to_close, 0).show();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Start decoding after permission granted (original onRequestPermissionsResult success) */
|
||||
public void onPermissionGranted() {
|
||||
v();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.ve3nea.morse_expert;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public class ScaleView extends View {
|
||||
public int f;
|
||||
|
||||
/* renamed from: g, reason: collision with root package name */
|
||||
public int f11041g;
|
||||
|
||||
/* renamed from: h, reason: collision with root package name */
|
||||
public final Paint f11042h;
|
||||
|
||||
/* renamed from: i, reason: collision with root package name */
|
||||
public final Paint f11043i;
|
||||
|
||||
/* renamed from: j, reason: collision with root package name */
|
||||
public final Paint f11044j;
|
||||
|
||||
/* renamed from: k, reason: collision with root package name */
|
||||
public final Paint f11045k;
|
||||
|
||||
public ScaleView(Context context, AttributeSet attributeSet) {
|
||||
super(context, attributeSet);
|
||||
Paint paint = new Paint();
|
||||
this.f11042h = paint;
|
||||
paint.setColor(-16711936);
|
||||
Paint paint2 = new Paint();
|
||||
this.f11043i = paint2;
|
||||
paint2.setColor(-16744448);
|
||||
Paint paint3 = new Paint();
|
||||
this.f11044j = paint3;
|
||||
paint3.setColor(-256);
|
||||
Paint paint4 = new Paint();
|
||||
this.f11045k = paint4;
|
||||
paint4.setColor(-8355840);
|
||||
}
|
||||
|
||||
@Override // android.view.View
|
||||
public final void onDraw(Canvas canvas) {
|
||||
Paint paint;
|
||||
super.onDraw(canvas);
|
||||
int i4 = this.f11041g;
|
||||
if (i4 != 1) {
|
||||
if (i4 != 2) {
|
||||
if (i4 != 3) {
|
||||
if (i4 != 4) {
|
||||
return;
|
||||
} else {
|
||||
paint = this.f11044j;
|
||||
}
|
||||
} else {
|
||||
paint = this.f11045k;
|
||||
}
|
||||
} else {
|
||||
paint = this.f11042h;
|
||||
}
|
||||
} else {
|
||||
paint = this.f11043i;
|
||||
}
|
||||
int round = (int) Math.round(getWidth() * 0.7d);
|
||||
Path path = new Path();
|
||||
path.moveTo(0.0f, this.f);
|
||||
float f = round;
|
||||
path.lineTo(f, this.f - round);
|
||||
path.lineTo(f, this.f + round);
|
||||
path.close();
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ve3nea.morse_expert;
|
||||
|
||||
import J2.b;
|
||||
import android.content.Context;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
/* loaded from: classes.dex */
|
||||
public class WaterfallSurfaceView extends GLSurfaceView {
|
||||
public final b f;
|
||||
|
||||
public WaterfallSurfaceView(Context context, AttributeSet attributeSet) {
|
||||
super(context, attributeSet);
|
||||
setEGLContextClientVersion(2);
|
||||
b bVar = new b(context, H2.b.f603B);
|
||||
this.f = bVar;
|
||||
setRenderer(bVar);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user