Compare commits

..

No commits in common. "7.19" and "main" have entirely different histories.
7.19 ... main

38 changed files with 381 additions and 1867 deletions

16
.codegraph/.gitignore vendored
View File

@ -1,16 +0,0 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.stand.standapp"
minSdk = 24
targetSdk = 34
versionCode = 14
versionName = "1.0.13"
versionCode = 13
versionName = "1.0.12"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@ -12,7 +12,6 @@
-dontwarn com.google.android.material.snackbar.**
-dontwarn java.beans.**
-dontwarn org.yaml.snakeyaml.**
-keep class org.yaml.snakeyaml.** { *; }
# OkHttp 混淆规则
-keepattributes Signature

View File

@ -23,8 +23,7 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.StandAPP"
android:hardwareAccelerated="true">
android:theme="@style/Theme.StandAPP">
<activity
android:name=".SplashActivity"

View File

@ -113,18 +113,12 @@
</div>
<div style="margin-bottom: 20px;">
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">App 弹窗与音频交互</div>
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">App 弹窗交互</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #e11d48;" onclick="triggerRestartDialog()">触发重启提示</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #0d9488;" onclick="triggerJsonDialog()">发送 JSON 弹窗</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #8b5cf6;" onclick="playNativeAlert()">播放提示音</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #475569;" onclick="stopNativeAlert()">停止播放</button>
</div>
<div style="margin-top: 8px;">
<span class="ptt-label">提示音播放次数:</span>
<input type="number" id="alert-count" class="ws-input" value="3" style="margin-bottom: 5px;">
<span class="ptt-label">提示音音量 (0.0 - 1.0):</span>
<input type="text" id="alert-volume" class="ws-input" value="1.0" style="margin-bottom: 5px;">
<span class="ptt-label">提示标题:</span>
<input type="text" id="dialog-title" class="ws-input" value="系统通知" style="margin-bottom: 5px;">
<span class="ptt-label">提示内容:</span>
@ -146,8 +140,6 @@
<button class="btn" style="background: #10B981;" onclick="callUploadLogs()">4. 上传系统日志</button>
<button class="btn" style="background: #64748b;" onclick="callAbout()">5. 关于应用</button>
<button class="btn btn-red" onclick="callExit()">6. 退出应用</button>
<button class="btn" style="background: #e11d48;" onclick="callForceExit()">7. 强制退出</button>
<button class="btn btn-orange" onclick="callLogout()">8. 注销登录</button>
<!-- WebSocket 控制区 -->
<div class="ws-card">
@ -244,7 +236,6 @@
<button class="btn ptt-btn ptt-action-btn" style="background: #0ea5e9;" onclick="pttGetCurrentGroup()">当前群组</button>
<button class="btn ptt-btn ptt-action-btn btn-orange" onclick="pttForceRelease()" disabled>强制释放</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #000; color: #fca5a5;" onclick="pttSimulateConflict()" disabled>模拟冲突</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #10b981;" onclick="queryTempGroupId()">查询临时群 GID</button>
</div>
<div id="ptt-result" class="log-container" style="background: #0f172a; margin-top: 10px; height: 120px;">
@ -528,101 +519,18 @@
}
}
function callForceExit() {
if (window.android && window.android.forceExit) {
window.android.forceExit();
} else {
alert("App 强制退出接口不可用");
}
}
function callLogout() {
if (window.android && window.android.logout) {
window.android.logout();
} else {
alert("App 注销接口不可用");
}
}
function callAbout() {
if (window.android && window.android.getAboutInfo) {
var infoStr = window.android.getAboutInfo();
try {
var info = JSON.parse(infoStr);
var friendlyNames = {
appName: '应用名称',
versionName: '版本号',
model: '设备型号',
osVersion: '系统版本',
cpuAbi: 'CPU 架构',
is64Bit: '运行模式',
screen: '屏幕与显示',
width: '宽度像素',
height: '高度像素',
density: '屏幕密度',
densityDpi: '屏幕DPI',
physicalSize: '物理尺寸 (英寸)',
hardware: '硬件与厂商',
manufacturer: '制造商',
brand: '品牌',
fingerprint: '系统指纹',
system: '系统环境',
sdkVersion: 'SDK 版本',
language: '系统语言',
country: '国家代码',
securityPatch: '安全补丁',
memory: '内存信息',
totalRam: '总内存 (RAM)',
availRam: '可用内存',
storage: '存储信息',
totalInternal: '总内部存储',
availInternal: '可用内部存储',
network: '网络状态',
type: '网络类型',
ipAddress: 'IP 地址',
battery: '电池状态',
level: '电量百分比',
status: '充电状态',
plugged: '供电类型'
};
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function buildHtml(data, prefix) {
var keys = Object.keys(data);
var res = '';
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var val = data[key];
var label = friendlyNames[key] || key;
var indent = prefix ? 'margin-left: 15px;' : '';
if (val !== null && typeof val === 'object') {
res += '<div style="' + indent + 'margin-top: 8px; margin-bottom: 4px; border-bottom: 1px dashed #eee; padding-bottom: 2px;"><strong>' + label + '</strong></div>';
res += buildHtml(val, true);
} else {
var displayVal = val;
if (key === 'is64Bit') {
displayVal = val ? '64位' : '32位兼容模式';
} else if (key === 'totalRam' || key === 'availRam' || key === 'totalInternal' || key === 'availInternal') {
displayVal = formatBytes(val);
} else if (key === 'level') {
displayVal = val >= 0 ? val + '%' : '未知';
}
res += '<div style="' + indent + 'line-height: 1.6;"><strong>' + label + ':</strong> ' + displayVal + '</div>';
}
}
return res;
}
document.getElementById('aboutBody').innerHTML = buildHtml(info, false);
var html = '<strong>应用名称:</strong> ' + info.appName + '<br>' +
'<strong>版本号:</strong> <span style="color: #3b82f6;">' + info.versionName + '</span><br>' +
'<strong>设备型号:</strong> ' + info.model + '<br>' +
'<strong>系统版本:</strong> Android ' + info.osVersion + '<br>' +
'<strong>CPU 架构:</strong> ' + info.cpuAbi + '<br>' +
'<strong>运行模式:</strong> ' + (info.is64Bit ? "64位" : "32位兼容模式");
document.getElementById('aboutBody').innerHTML = html;
document.getElementById('aboutModal').style.display = 'flex';
} catch (e) {
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;
@ -1150,42 +1058,6 @@
showPttResult("已发送 AT 指令: " + cmd);
}
}
// 注册临时群锁定回调
window.startPullGroupLock = function(gid) {
showPttResult("收到临时群锁定回调 startPullGroupLock, GID: " + gid);
};
window.endPullGroupLock = function() {
showPttResult("收到临时群解锁回调 endPullGroupLock");
};
function queryTempGroupId() {
if (window.android && window.android.getTempGroupId) {
var gid = window.android.getTempGroupId();
showPttResult("当前在临时群的 GID: " + (gid || "无"));
} else {
showPttResult("接口 getTempGroupId 不可用");
}
}
function playNativeAlert() {
if (window.android && window.android.playAlert) {
var count = parseInt(document.getElementById('alert-count').value) || 3;
var volume = parseFloat(document.getElementById('alert-volume').value) || 1.0;
window.android.playAlert(count, volume);
} else {
alert("播放提示音接口未就绪");
}
}
function stopNativeAlert() {
if (window.android && window.android.stopAlert) {
window.android.stopAlert();
} else {
alert("停止播放接口未就绪");
}
}
</script>
</body>
</html>

View File

@ -75,29 +75,6 @@ public class AudioRecordManager {
return mInstance;
}
/**
* 彻底销毁单例及相关资源
*/
public static void destroy() {
if (mInstance != null) {
synchronized (AudioRecordManager.class) {
if (mInstance != null) {
try {
mInstance.destroyAudio();
} catch (Exception e) {
Timber.tag("AudioRecordManager").e(e, "destroyAudio error");
}
try {
mInstance.destroyThread();
} catch (Exception e) {
Timber.tag("AudioRecordManager").e(e, "destroyThread error");
}
mInstance = null;
}
}
}
}
/**
* 销毁线程方法
*/

View File

@ -5,10 +5,7 @@ import android.util.Log;
import timber.log.Timber;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@ -20,20 +17,17 @@ public class CallBackResolution {
private static String TAG = "PTT_RX";
private static String indexGroupId = "";//用户所在群组Id
private static String indexGroupName = "";//用户所在群组名称
private static final int MAX_GROUP_MEMBERS = 500;
private static final int MAX_GROUP_INFOS = 100;
private static final Map<String, GroupMemberInfoDto> memberInfoDtos = new LinkedHashMap<>(MAX_GROUP_MEMBERS);
private static List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
private static int speckType = 1;
private static String talkId = "";
private static String talkName = "";
private static final Map<String, GroupInfoDto> groupInfos = new LinkedHashMap<>(MAX_GROUP_INFOS);
private static ArrayList<GroupInfoDto> groupInfos = new ArrayList<>();
private static String oldLoginSate = "00";
private static int logNum = 0;
private static long offlineTime = 0;
private static boolean loginState = false;
private static String pttLoginStatus = "00"; // 00-未登录 01-登录中 02-已登录
private static String pttLoginId = "";
private static ScheduledExecutorService keepAliveExecutor = null;
public static String getPttLoginStatus() {
return pttLoginStatus;
@ -44,12 +38,8 @@ public class CallBackResolution {
}
public static void resetPttLoginStatus() {
loginState = false;
pttLoginStatus = "00";
pttLoginId = "";
indexGroupId ="";
indexGroupName = "";
keepAliveExecutor.shutdownNow();
}
public static String getCurrentGroupId() {
@ -93,10 +83,6 @@ public class CallBackResolution {
com.stand.standapp.MainActivity.onPttLoginSuccess();
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
}
if ("01".equals(errorNum)) {
com.stand.standapp.MainActivity.onPttLoginSuccess();
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('02','" + pttLoginId + "')");
}
break;
case "a2"://账号有视频功能
@ -153,7 +139,7 @@ public class CallBackResolution {
Timber.tag(TAG).d( "at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
if (1 == groupNo) groupInfos.clear();
GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount);
groupInfos.put(groupId, groupInfo);
groupInfos.add(groupInfo);
}
break;
@ -182,7 +168,7 @@ public class CallBackResolution {
String tempTxt = unicodeToString(groupMemberName.toString());
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
if (memberNo == 1) memberInfoDtos.clear();
memberInfoDtos.put(memberId, infoDto);
memberInfoDtos.add(infoDto);
}
break;
@ -277,29 +263,34 @@ public class CallBackResolution {
// 处理临时呼叫页面逻辑
if (code == 1) {
com.stand.standapp.MainActivity.executeJs("onTempCallStateChanged('created')");
// try {
// android.content.Context context = com.stand.standapp.MyApplication.getAppInstance();
// String account = com.stand.standapp.AppConfig.getSavedUsername(context);
// String token = com.stand.standapp.AppConfig.getAccessToken(context);
// String tempGroupName = "临时会话";
// if (tempTxt.startsWith("临时呼叫") && tempTxt.length() > "临时呼叫".length()) {
// tempGroupName = tempTxt.substring("临时呼叫".length());
// }
// com.stand.standapp.TempCallActivity.checkAndHandleTempGroup(context, account, token, tempGroupName);
// } catch (Exception e) {
// Timber.tag(TAG).e(e, "Failed to check initiator status and handle TempCallActivity");
// }
// 临时呼叫临时群组 打开临时呼叫页面
try {
android.content.Intent intent = new android.content.Intent(
com.stand.standapp.MyApplication.getAppInstance(),
com.stand.standapp.TempCallActivity.class
);
// 从84消息中提取临时群名称格式为"临时呼叫XXX"
String tempGroupName = "临时会话";
if (tempTxt.startsWith("临时呼叫") && tempTxt.length() > "临时呼叫".length()) {
tempGroupName = tempTxt.substring("临时呼叫".length());
}
intent.putExtra("group_name", tempGroupName);
intent.addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK |
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
);
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent);
} catch (Exception e) {
Timber.tag(TAG).e( "Failed to open TempCallActivity: " + e.getMessage());
}
} else if (code == 0) {
// 退出临时呼叫 关闭临时呼叫页面
com.stand.standapp.MainActivity.executeJs("onTempCallStateChanged('destroyed')");
com.stand.standapp.MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }");
// if (com.stand.standapp.TempCallActivity.isOpen()) {
// com.stand.standapp.TempCallActivity.closeWithMessage("临时呼叫已结束");
// } else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
// // 最小化状态下收到结束消息清除状态并通知前端
// com.stand.standapp.TempCallActivity.clearMinimizedState();
// }
if (com.stand.standapp.TempCallActivity.isOpen()) {
com.stand.standapp.TempCallActivity.closeWithMessage("临时呼叫已结束");
} else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
// 最小化状态下收到结束消息清除状态并通知前端
com.stand.standapp.TempCallActivity.clearMinimizedState();
}
} else if (code == 2) {
// 呼叫失败 关闭临时呼叫页面
// if (com.stand.standapp.TempCallActivity.isOpen()) {
@ -344,8 +335,6 @@ public class CallBackResolution {
indexGroupId = userGroupId;
indexGroupName = userGroupName;
CallBackUtil.callBackNotifyUpdateGroup(userGroupId, userGroupName);
// 触发首次进入群组状态
com.stand.standapp.MainActivity.onFirstGroupEntered();
}
break;
@ -554,16 +543,13 @@ public class CallBackResolution {
if (tempTxt.contains("已登录")) {
String showName = tempTxt.substring(3, tempTxt.length());
CallBackUtil.callBackLogin(true, showName);
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
keepAliveExecutor.shutdownNow();
}
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> {
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
SendAtUtil.sendUDP();
SendAtUtil.sendTCP();
};
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
} else if (tempTxt.contains("账号已更新")) {
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
} else if (tempTxt.contains("账号或密码错误")) {

View File

@ -1,14 +1,9 @@
package com.example.kingway.ptt;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import timber.log.Timber;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**指令返回类
* 说明操作UI请在主线程进行
@ -16,45 +11,6 @@ import java.util.concurrent.ConcurrentHashMap;
public class CallBackUtil {
private static String TAG = "TYT_CallBackUtil";
private static final long THROTTLE_INTERVAL_MS = 100L;
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
private static final ConcurrentHashMap<String, Long> lastPushAt = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Runnable> pendingRunnables = new ConcurrentHashMap<>();
private static void pushJsThrottled(String eventName, String script) {
long now = SystemClock.uptimeMillis();
Long last = lastPushAt.get(eventName);
if (last == null || now - last >= THROTTLE_INTERVAL_MS) {
lastPushAt.put(eventName, now);
cancelPending(eventName);
com.stand.standapp.MainActivity.executeJs(script);
return;
}
Runnable prev = pendingRunnables.get(eventName);
if (prev != null) {
MAIN_HANDLER.removeCallbacks(prev);
}
final long scheduledAt = now;
Runnable task = () -> {
lastPushAt.put(eventName, SystemClock.uptimeMillis());
pendingRunnables.remove(eventName);
com.stand.standapp.MainActivity.executeJs(script);
};
pendingRunnables.put(eventName, task);
MAIN_HANDLER.postDelayed(task, THROTTLE_INTERVAL_MS - (now - last));
}
private static void cancelPending(String eventName) {
Runnable r = pendingRunnables.remove(eventName);
if (r != null) {
MAIN_HANDLER.removeCallbacks(r);
}
}
private static void pushJsImmediate(String script) {
com.stand.standapp.MainActivity.executeJs(script);
}
/**
* 返回登录
@ -112,11 +68,11 @@ public class CallBackUtil {
* 查询群组成员返回
* state true-成功 false-失败
*/
public static void callBackOnMemberSuccess(boolean state, java.util.Map<String, GroupMemberInfoDto> dtos) {
public static void callBackOnMemberSuccess(boolean state, List<GroupMemberInfoDto> dtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
if (dtos != null && state) {
for (GroupMemberInfoDto dto : dtos.values()) {
for (GroupMemberInfoDto dto : dtos) {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("memberId", dto.getMemberId());
obj.put("memberName", dto.getGmemberName());
@ -124,7 +80,7 @@ public class CallBackUtil {
arr.put(obj);
}
}
pushJsThrottled("MemberList", "if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -172,17 +128,17 @@ public class CallBackUtil {
* 返回群组集合信息
* GroupInfoDto 群组信息
* */
public static void callBackGroupInfo (java.util.Map<String, GroupInfoDto> groupInfoDtos) {
public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
for (GroupInfoDto dto : groupInfoDtos.values()) {
for (GroupInfoDto dto : groupInfoDtos) {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("groupId", dto.getGroupId());
obj.put("groupName", dto.getGroupName());
obj.put("groupNo", dto.getGroupNo());
arr.put(obj);
}
pushJsThrottled("GroupList", "if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -201,7 +157,7 @@ public class CallBackUtil {
obj.put("groupName", groupName);
obj.put("talkId", talkId);
obj.put("talkName", talkName);
pushJsThrottled("SpeakerUpdate", "if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -215,7 +171,7 @@ public class CallBackUtil {
obj.put("groupName", groupName);
obj.put("talkId", talkId);
obj.put("talkName", talkName);
pushJsThrottled("PlayStatus", "if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -255,7 +211,7 @@ public class CallBackUtil {
obj.put("latitude", latitude);
obj.put("longitude", longitude);
obj.put("time", time);
pushJsThrottled("LocationStatus", "if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}

View File

@ -30,7 +30,6 @@ public class SendAtUtil {
*/
public static void toCancelLogin() {
send("050000\r\n");
send("05000000\r\n");
}
/**
@ -38,7 +37,6 @@ public class SendAtUtil {
*/
public static void toLogout() {
send("040000\r\n");
send("04000000\r\n");
}
/**

View File

@ -18,11 +18,6 @@ class AndroidInterface(private val activity: MainActivity) {
private var lastSpeakTime = 0L
private val MIN_SPEAK_DURATION = 200L
companion object {
private var alertPlayer: android.media.MediaPlayer? = null
private var alertPlayCount = 0
}
/**
* JS bridge 调度入口
* @param method JS 调用的方法名
@ -80,7 +75,6 @@ class AndroidInterface(private val activity: MainActivity) {
"printRawData" -> { printRawData(optStringOrNull(args, 0)); "" }
"startUpdate" -> { startUpdate(optStringOrNull(args, 0)); "" }
"exitApp" -> { exitApp(); "" }
"forceExit" -> { forceExit(); "" }
"getVersionName" -> getVersionName()
"getAboutInfo" -> getAboutInfo()
"saveMessage" -> { saveMessage(optStringOrNull(args, 0)); "" }
@ -91,18 +85,6 @@ class AndroidInterface(private val activity: MainActivity) {
"showJsonDialog" -> { showJsonDialog(args.optString(0, "")); "" }
"getTempCallStatus" -> getTempCallStatus()
"maximizeTempCall" -> { maximizeTempCall(); "" }
"getTempGroupId" -> getTempGroupId()
"logout" -> { logout(); "" }
"playAlert" -> {
val count = args.optInt(0, 3)
val volume = args.optDouble(1, 1.0).toFloat()
playAlert(count, volume)
""
}
"stopAlert" -> {
stopAlert()
""
}
else -> {
Timber.tag("Bridge").w("Unknown method: $method")
""
@ -211,9 +193,6 @@ class AndroidInterface(private val activity: MainActivity) {
fun pttJumpGroup(gid: String) {
Timber.tag("PTT").d("pttJumpGroup: gid=%s", gid)
SendAtUtil.jumpGroup(gid)
if (gid.isNotEmpty()) {
AppConfig.setLastGroupId(activity, gid)
}
}
fun getPrinterStatus(): String {
@ -386,17 +365,6 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
fun forceExit() {
Handler(Looper.getMainLooper()).post {
try {
com.stand.standapp.utils.AppKiller.killApp(activity)
} catch (e: Exception) {
Timber.tag("App").e(e, "强制退出失败")
activity.finish()
}
}
}
fun getVersionName(): String {
return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
@ -410,9 +378,22 @@ class AndroidInterface(private val activity: MainActivity) {
fun getAboutInfo(): String {
return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
val json = com.stand.standapp.utils.DeviceInfoUtils.getDeviceInfoJson(activity)
val json = JSONObject()
json.put("appName", activity.getString(R.string.app_name))
json.put("versionName", packageInfo.versionName ?: "1.0.0")
json.put("versionName", packageInfo.versionName)
json.put("model", android.os.Build.MODEL)
json.put("osVersion", android.os.Build.VERSION.RELEASE)
val abis = android.os.Build.SUPPORTED_ABIS
json.put("cpuAbi", abis.joinToString(","))
val is64Bit = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
android.os.Process.is64Bit()
} else {
abis.any { it.contains("64") }
}
json.put("is64Bit", is64Bit)
json.toString()
} catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")
@ -469,9 +450,6 @@ class AndroidInterface(private val activity: MainActivity) {
com.stand.standapp.MainActivity.executeJs("if(window.appLogin){window.appLogin('$escaped');}")
Timber.tag("AndroidInterface").d("Called appLogin")
}
// 页面加载并初始化完成后触发状态
com.stand.standapp.MainActivity.onPageLoadFinished()
} catch (e: Exception) {
Timber.tag("UI").e(e, "隐藏加载布局失败")
}
@ -516,10 +494,6 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
fun getTempGroupId(): String {
return TempCallActivity.currentTempGroupId
}
fun maximizeTempCall() {
Handler(Looper.getMainLooper()).post {
try {
@ -529,63 +503,4 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
}
fun logout() {
activity.logout()
}
fun playAlert(count: Int, volume: Float) {
Handler(Looper.getMainLooper()).post {
try {
realStopAlert()
alertPlayCount = 0
alertPlayer = android.media.MediaPlayer.create(activity, R.raw.pull_alert).apply {
setVolume(volume, volume)
setOnCompletionListener {
alertPlayCount++
Timber.tag("AlertPlayer").d("already alert play, count=$alertPlayCount")
if (alertPlayCount < count) {
try {
it.start()
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error repeating alert play")
realStopAlert()
}
} else {
realStopAlert()
}
}
start()
}
Timber.tag("AlertPlayer").d("Started alert play, count=$count, volume=$volume")
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error initializing alert player")
}
}
}
fun stopAlert() {
if (Looper.myLooper() == Looper.getMainLooper()) {
realStopAlert()
} else {
Handler(Looper.getMainLooper()).post {
realStopAlert()
}
}
}
private fun realStopAlert() {
try {
alertPlayer?.let { player ->
if (player.isPlaying) {
player.stop()
}
player.release()
}
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error stopping alert player")
} finally {
alertPlayer = null
}
}
}

View File

@ -11,8 +11,6 @@ object AppConfig {
private const val KEY_USERNAME = "saved_username"
private const val KEY_PASSWORD = "saved_password"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_LAST_GROUP_ID = "last_group_id"
private const val KEY_USER_ID = "user_id"
// 默认配置
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
@ -56,7 +54,6 @@ object AppConfig {
getPrefs(context).edit().putBoolean(KEY_REMEMBER_LOGIN, remember).apply()
}
@JvmStatic
fun getSavedUsername(context: Context): String {
return getPrefs(context).getString(KEY_USERNAME, "") ?: ""
}
@ -73,7 +70,6 @@ object AppConfig {
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
}
@JvmStatic
fun getAccessToken(context: Context): String {
return getPrefs(context).getString(KEY_ACCESS_TOKEN, "") ?: ""
}
@ -81,22 +77,4 @@ object AppConfig {
fun setAccessToken(context: Context, token: String) {
getPrefs(context).edit().putString(KEY_ACCESS_TOKEN, token).apply()
}
fun getLastGroupId(context: Context): String {
return getPrefs(context).getString(KEY_LAST_GROUP_ID, "") ?: ""
}
fun setLastGroupId(context: Context, groupId: String) {
getPrefs(context).edit().putString(KEY_LAST_GROUP_ID, groupId).apply()
}
@JvmStatic
fun getSavedUserId(context: Context): String {
return getPrefs(context).getString(KEY_USER_ID, "") ?: ""
}
@JvmStatic
fun setSavedUserId(context: Context, userId: String) {
getPrefs(context).edit().putString(KEY_USER_ID, userId).apply()
}
}

View File

@ -4,11 +4,9 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
class DeveloperActivity : AppCompatActivity() {
@ -31,48 +29,6 @@ class DeveloperActivity : AppCompatActivity() {
startActivity(Intent(this, com.stand.standapp.printer.PrintTestActivity::class.java))
}
// 本地演示界面
findViewById<LinearLayout>(R.id.item_demo_ui).setOnClickListener {
val builder = androidx.appcompat.app.AlertDialog.Builder(this)
builder.setTitle("本地演示登录")
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(50, 40, 50, 10)
}
val etUser = EditText(this).apply {
hint = "账号"
setText("test010")
}
layout.addView(etUser)
val etPwd = EditText(this).apply {
hint = "密码"
setText("123456")
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
layout.addView(etPwd)
builder.setView(layout)
builder.setPositiveButton("登录") { _, _ ->
val username = etUser.text.toString().trim()
val password = etPwd.text.toString().trim()
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(this, "账号密码不能为空", Toast.LENGTH_SHORT).show()
} else {
val intent = Intent(this, LoginActivity::class.java).apply {
putExtra("official_mode_login", false)
putExtra("extra_username", username)
putExtra("extra_password", password)
}
startActivity(intent)
}
}
builder.setNegativeButton("取消", null)
builder.show()
}
// 崩溃模拟
findViewById<LinearLayout>(R.id.item_crash_test).setOnClickListener {
androidx.appcompat.app.AlertDialog.Builder(this)

View File

@ -9,7 +9,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions
import com.stand.standapp.net.NetworkModule
import com.stand.standapp.printer.PrinterManager
import okhttp3.*
import org.json.JSONObject
@ -18,7 +17,7 @@ import java.io.IOException
class LoginActivity : AppCompatActivity() {
private val client = NetworkModule.defaultClient
private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -33,6 +32,7 @@ class LoginActivity : AppCompatActivity() {
val etPassword = findViewById<EditText>(R.id.et_password)
val cbRemember = findViewById<CheckBox>(R.id.cb_remember)
val btnLogin = findViewById<Button>(R.id.btn_login)
val btnLoginOfficial = findViewById<Button>(R.id.btn_login_official)
val btnExit = findViewById<Button>(R.id.btn_exit)
val ivSettings = findViewById<ImageView>(R.id.iv_settings)
val tvCopyright = findViewById<TextView>(R.id.tv_copyright)
@ -45,22 +45,12 @@ class LoginActivity : AppCompatActivity() {
etPassword.setText(AppConfig.getSavedPassword(this))
}
// 检查是否有传递过来的用户名和密码(来自开发者界面的演示登录)
val extraUsername = intent.getStringExtra("extra_username")
val extraPassword = intent.getStringExtra("extra_password")
if (!extraUsername.isNullOrEmpty() && !extraPassword.isNullOrEmpty()) {
etUsername.setText(extraUsername)
etPassword.setText(extraPassword)
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
performLogin(extraUsername, extraPassword, cbRemember.isChecked, isOfficialMode)
}
// 2. 服务器配置图标点击事件
ivSettings.setOnClickListener {
showServerConfigDialog()
}
// 3. 登录按钮点击事件(进行正式界面登录)
// 3. 登录按钮点击事件
btnLogin.setOnClickListener {
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString().trim()
@ -70,9 +60,21 @@ class LoginActivity : AppCompatActivity() {
return@setOnClickListener
}
// 根据传入的 Intent 决定是正式登录还是演示登录,默认是正式登录(true)
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
performLogin(username, password, cbRemember.isChecked, isOfficialMode)
// 执行真实接口登录
performLogin(username, password, cbRemember.isChecked, false)
}
// 3.1 正式界面登录按钮点击事件
btnLoginOfficial.setOnClickListener {
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString().trim()
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(applicationContext, "请输入账号和密码", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
performLogin(username, password, cbRemember.isChecked, true)
}
// 4. 退出按钮
@ -116,7 +118,6 @@ class LoginActivity : AppCompatActivity() {
val request = Request.Builder()
.url(loginUrl)
.addHeader("X-Client-Type", "android")
.post(body)
.build()
@ -158,14 +159,6 @@ class LoginActivity : AppCompatActivity() {
val accessToken = data?.optString("access_token", "") ?: ""
AppConfig.setAccessToken(this@LoginActivity, accessToken)
// 提取并保存当前用户的 userId
val userObj = data?.optJSONObject("user")
val userId = userObj?.optString("userId", "")?.takeIf { it.isNotEmpty() }
?: userObj?.optString("id", "")?.takeIf { it.isNotEmpty() }
?: data?.optString("userId", "")?.takeIf { it.isNotEmpty() }
?: data?.optString("id", "") ?: ""
AppConfig.setSavedUserId(this@LoginActivity, userId)
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
// 获取独立的 PTT 账号并初始化
@ -270,7 +263,55 @@ class LoginActivity : AppCompatActivity() {
private fun checkTempGroup(account: String, loginData: org.json.JSONObject?) {
val accessToken = loginData?.optString("access_token", "") ?: return
if (accessToken.isEmpty()) return
TempCallActivity.checkAndHandleTempGroup(this, account, accessToken)
val serverUrl = AppConfig.getServerUrl(this)
val url = "$serverUrl/api/external/ptt/user/groups?account=$account"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.get()
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Timber.tag("TempGroup").w(e, "检查临时群组失败")
}
override fun onResponse(call: Call, response: Response) {
try {
val body = response.body?.string() ?: return
val json = JSONObject(body)
val dataObj = json.optJSONObject("data")
val list = dataObj?.optJSONArray("data") ?: return
for (i in 0 until list.length()) {
val group = list.optJSONObject(i) ?: continue
val gtype = group.optString("gtype", "")
if (gtype == "2") {
val gname = group.optString("gname", "临时群组")
Timber.tag("TempGroup").d("发现临时群组: $gname")
// 打开临时呼叫页面
val appCtx = com.stand.standapp.MyApplication.getAppInstance()
val intent = android.content.Intent(
appCtx,
TempCallActivity::class.java
)
intent.putExtra("group_name", gname)
intent.addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
appCtx.startActivity(intent)
break
}
}
} catch (e: Exception) {
Timber.tag("TempGroup").e(e, "解析临时群组响应失败")
}
}
})
}
private fun initPttWithAccount(username: String, accessToken: String) {

View File

@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import android.widget.FrameLayout
import com.example.kingway.ptt.CallBackResolution
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
@ -20,9 +19,6 @@ class MainActivity : AppCompatActivity() {
companion object {
private var instance: MainActivity? = null
@Volatile
private var geckoRuntime: GeckoRuntime? = null
// 防止对话框堆叠
@Volatile
private var isConflictDialogShowing = false
@ -35,72 +31,15 @@ class MainActivity : AppCompatActivity() {
@Volatile
private var pendingJsCode: String? = null
private val jsLock = Any()
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(jsLock) {
synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@Volatile
private var hasFirstGroupEntered = false
@Volatile
private var hasPageFinished = false
@Volatile
private var hasAutoJumped = false
@JvmStatic
fun onFirstGroupEntered() {
Timber.tag("PTT").d("Condition met: First group entered")
hasFirstGroupEntered = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun onPageLoadFinished() {
Timber.tag("PTT").d("Condition met: Web page loaded and initialized")
hasPageFinished = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun resetAutoJumpStates() {
hasFirstGroupEntered = false
hasPageFinished = false
hasAutoJumped = false
Timber.tag("PTT").d("Auto jump states reset")
}
@JvmStatic
fun checkAndAutoJumpToLastGroup() {
// synchronized(this) {
// if (hasFirstGroupEntered && hasPageFinished && !hasAutoJumped) {
// hasAutoJumped = true
// instance?.let { ctx ->
// val lastGroupId = AppConfig.getLastGroupId(ctx)
// if (lastGroupId.isNotEmpty()) {
// Timber.tag("PTT").d("Both conditions met. Auto-jumping to last joined group: %s after 2s delay", lastGroupId)
// ctx.runOnUiThread {
// android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
// instance?.pttInterface?.pttJumpGroup(lastGroupId)
// }, 1000)
// }
// } else {
// Timber.tag("PTT").d("Both conditions met but lastGroupId is empty, skip auto-jump")
// }
// }
// } else {
// Timber.tag("PTT").d("Check auto jump: hasFirstGroupEntered=$hasFirstGroupEntered, hasPageFinished=$hasPageFinished, hasAutoJumped=$hasAutoJumped")
// }
// }
}
@JvmStatic
fun onPttLoginSuccess() {
Timber.tag("MainActivity").d("onPttLoginSuccess called from Native")
@ -180,7 +119,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(jsLock) {
synchronized(pendingJsCode ?: Any()) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
@ -189,6 +128,7 @@ class MainActivity : AppCompatActivity() {
}
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private var canGoBack = false
private lateinit var geckoView: GeckoView
@ -216,7 +156,7 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
geckoView = findViewById<GeckoView>(R.id.geckoView)
?: return
?: return
findViewById<android.widget.Button>(R.id.btn_retry)?.setOnClickListener {
hideErrorPage()
@ -277,46 +217,17 @@ class MainActivity : AppCompatActivity() {
}
private fun createGeckoRuntime(): GeckoRuntime {
synchronized(MainActivity::class.java) {
val existing = geckoRuntime
if (existing != null) {
return existing
}
// 1. 将配置写入应用的本地沙盒文件 (YAML 格式)
val configFile = java.io.File(filesDir, "geckoview-config.yaml")
try {
val configContent = """
prefs:
layout.testing.overlay-scrollbars.always-visible: true
""".trimIndent()
configFile.writeText(configContent)
Timber.tag("MainActivity").d("Wrote GeckoView config to: ${configFile.absolutePath}")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to write geckoview-config.yaml")
}
// 2. 通过 GeckoRuntimeSettings.Builder 加载该 YAML 配置文件并创建 GeckoRuntime 实例
val settingsBuilder = org.mozilla.geckoview.GeckoRuntimeSettings.Builder()
.aboutConfigEnabled(true)
.consoleOutput(true)
if (configFile.exists()) {
settingsBuilder.configFilePath(configFile.absolutePath)
}
val runtime = GeckoRuntime.create(this.applicationContext, settingsBuilder.build())
geckoRuntime = runtime
// 3. 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
}
return runtime
val runtime = GeckoRuntime.getDefault(this)
runtime.settings.aboutConfigEnabled = true
runtime.settings.consoleOutputEnabled = true
// 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
}
return runtime
}
private fun createSession(): GeckoSession {
@ -398,7 +309,7 @@ class MainActivity : AppCompatActivity() {
val result = handleBridgeCall(payload, prompt.defaultValue ?: "")
return GeckoResult.fromValue(prompt.confirm(result))
}
// 默认处理(显示对话框)
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
@ -506,78 +417,45 @@ class MainActivity : AppCompatActivity() {
.show()
}
fun logout() {
runOnUiThread {
Timber.tag("MainActivity").i("用户注销,清理程序")
// 1. PTT Logout and cancel login
// try {
// com.example.kingway.ptt.SendAtUtil.toCancelLogin()
// } catch (e: Exception) {
// Timber.tag("MainActivity").e(e, "PTT cancel failed")
// }
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("MainActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "PTT logout failed")
}
// 2. Stop GPIO listening
try {
com.stand.standapp.utils.GpioManager.stopListening()
Timber.tag("MainActivity").i("GPIO stop listening")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
resetAutoJumpStates()
CallBackResolution.resetPttLoginStatus();
}catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear public value")
}
// 4. Stop and destroy AudioRecord
try {
com.example.kingway.ptt.AudioRecordManager.destroy()
Timber.tag("MainActivity").i("AudioRecordManager destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "AudioRecordManager destroy failed")
}
// 3. Destroy/release AudioTrack in PTT Native
try {
com.stand.standapp.MyApplication.pNative?.destory()
Timber.tag("MainActivity").i("PTT native AudioTrack destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "PTT native destroy failed")
}
// 5. Close printer connection and reset
try {
com.stand.standapp.printer.PrinterManager.destroy()
Timber.tag("MainActivity").i("Printer destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Printer destroy failed")
}
// 6. Clear Access Token
try {
AppConfig.setAccessToken(this, "")
Timber.tag("MainActivity").i("Cleared access token")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear access token")
}
// 7. Redirect back to LoginActivity and clear task stack
try {
val intent = Intent(this, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to redirect to LoginActivity")
}
private fun pttLogoutAndDestroy() {
try {
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT cancel failed")
}
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("LoginActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT logout failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "GPIO stop failed")
}
try {
// com.example.kingway.ptt.MyApplication.destroy()
Timber.tag("LoginActivity").i("PTT native destroyed")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT destroy failed")
}
try {
// PrinterManager.shutdown()
Timber.tag("LoginActivity").i("Printer shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "Printer shutdown failed")
}
try {
com.stand.standapp.utils.LogManager.shutdown()
Timber.tag("LoginActivity").i("LogManager shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "LogManager shutdown failed")
}
}
@ -594,34 +472,15 @@ class MainActivity : AppCompatActivity() {
return super.onKeyDown(keyCode, event)
}
override fun onPause() {
super.onPause()
geckoSession?.setActive(false)
}
override fun onResume() {
super.onResume()
geckoSession?.setActive(true)
}
override fun onDestroy() {
cancelTimeoutTimer()
geckoView.releaseSession()
geckoSession?.close()
geckoSession = null
clearCompanionState()
instance = null
super.onDestroy()
}
private fun clearCompanionState() {
synchronized(jsLock) {
pendingJsCode = null
}
pendingDialog = null
isConflictDialogShowing = false
}
private fun startTimeoutTimer() {
cancelTimeoutTimer()
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)

View File

@ -7,37 +7,14 @@ import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
class SplashActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
private var checkCount = 0
private val maxChecks = 20 // 最多检查20次(2秒)
private val checkInitRunnable = object : Runnable {
override fun run() {
if (MyApplication.pNative != null || checkCount >= maxChecks) {
navigateToLogin()
} else {
checkCount++
handler.postDelayed(this, 100)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
// 异步检查PTT JNI初始化状态避免硬等待2秒
handler.post(checkInitRunnable)
}
private fun navigateToLogin() {
if (isFinishing || isDestroyed) return
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
override fun onDestroy() {
handler.removeCallbacks(checkInitRunnable)
super.onDestroy()
// 延迟 2 秒进入登录界面
Handler(Looper.getMainLooper()).postDelayed({
startActivity(Intent(this, LoginActivity::class.java))
finish()
}, 2000)
}
}

View File

@ -26,8 +26,6 @@ class TempCallActivity : AppCompatActivity() {
private val MIN_SPEAK_DURATION = 200L
private val handler = Handler(Looper.getMainLooper())
private var pulseAnimator: ObjectAnimator? = null
private var alertPlayer: android.media.MediaPlayer? = null
private var alertPlayCount = 0
companion object {
@Volatile
@ -36,15 +34,12 @@ class TempCallActivity : AppCompatActivity() {
@Volatile
var isMinimized = false
internal set
private set
// 存储当前临时群组名称,用于恢复时传递
@Volatile
var currentGroupName: String = "临时会话"
@Volatile
var currentTempGroupId: String = ""
@JvmStatic
fun isOpen(): Boolean = instance != null
@ -58,11 +53,9 @@ class TempCallActivity : AppCompatActivity() {
val intent = android.content.Intent(
com.stand.standapp.MyApplication.getAppInstance(),
TempCallActivity::class.java
).apply {
putExtra("group_name", currentGroupName)
putExtra("gid", currentTempGroupId)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
intent.putExtra("group_name", currentGroupName)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent)
}
}
@ -83,137 +76,7 @@ class TempCallActivity : AppCompatActivity() {
fun clearMinimizedState() {
isMinimized = false
currentGroupName = "临时会话"
currentTempGroupId = ""
MainActivity.executeJs("onTempCallStateChanged('destroyed')")
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
@JvmStatic
@JvmOverloads
fun checkAndHandleTempGroup(
context: android.content.Context,
account: String,
token: String,
groupNameFromCall: String? = null,
targetGid: String? = null
) {
// if (token.isEmpty()) {
// Timber.tag("TempGroupCheck").w("token is empty, skip check")
// return
// }
//
// val serverUrl = AppConfig.getServerUrl(context)
// val url = "$serverUrl/api/vid-pull-record/my-list"
//
// val request = okhttp3.Request.Builder()
// .url(url)
// .addHeader("Authorization", "Bearer $token")
// .get()
// .build()
//
// com.stand.standapp.net.NetworkModule.defaultClient.newCall(request).enqueue(object : okhttp3.Callback {
// override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
// Timber.tag("TempGroupCheck").e(e, "获取拉动记录失败")
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
//
// override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
// try {
// val body = response.body?.string() ?: return
// val json = org.json.JSONObject(body)
// if (json.optInt("code", -1) == 0) {
// val dataArray = json.optJSONArray("data")
// var matchedRecord: org.json.JSONObject? = null
//
// if (dataArray != null && dataArray.length() > 0) {
// // 1. 如果指定了 GID优先按 GID 匹配
// if (targetGid != null && targetGid.isNotEmpty()) {
// for (i in 0 until dataArray.length()) {
// val record = dataArray.optJSONObject(i) ?: continue
// val pocGid = record.optString("pocGid", "")
// if (pocGid == targetGid) {
// matchedRecord = record
// break
// }
// }
// }
//
// // 2. 如果未指定 GID 或未匹配到,且有传入的呼叫群组名称,按名称匹配
// if (matchedRecord == null && groupNameFromCall != null && groupNameFromCall.isNotEmpty() && groupNameFromCall != "临时会话") {
// for (i in 0 until dataArray.length()) {
// val record = dataArray.optJSONObject(i) ?: continue
// val pullTitle = record.optString("pullTitle", "")
// if (pullTitle.contains(groupNameFromCall) || groupNameFromCall.contains(pullTitle)) {
// matchedRecord = record
// break
// }
// }
// }
//
// // 3. 如果仍未匹配到,默认使用最新的一条记录
// if (matchedRecord == null) {
// matchedRecord = dataArray.optJSONObject(0)
// }
// }
//
// if (matchedRecord != null) {
// val pocGid = matchedRecord.optString("pocGid", "")
// val pullTitle = matchedRecord.optString("pullTitle", "临时会话")
// val createBy = matchedRecord.optString("createBy", "")
// val currentUserId = AppConfig.getSavedUserId(context)
//
// val isInitiator = currentUserId.isNotEmpty() && currentUserId == createBy
// Timber.tag("TempGroupCheck").d("匹配拉动记录成功: title=$pullTitle, pocGid=$pocGid, createBy=$createBy, isInitiator=$isInitiator")
//
// if (isInitiator) {
// isMinimized = true
// currentTempGroupId = pocGid
// currentGroupName = pullTitle
// MainActivity.executeJs("onTempCallStateChanged('minimized')")
// if (pocGid.isNotEmpty()) {
// MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$pocGid'); }")
// }
// } else {
// startTempCallActivity(context, pullTitle, pocGid)
// }
// } else {
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// } else {
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// } catch (e: Exception) {
// Timber.tag("TempGroupCheck").e(e, "解析拉动记录响应失败")
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// }
// })
}
@JvmStatic
private fun startTempCallActivity(context: android.content.Context, gname: String, gid: String) {
try {
val intent = android.content.Intent(context, TempCallActivity::class.java).apply {
putExtra("group_name", gname)
putExtra("gid", gid)
addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
}
context.startActivity(intent)
} catch (e: Exception) {
Timber.tag("TempGroupCheck").e(e, "Failed to start TempCallActivity")
}
}
}
@ -231,22 +94,14 @@ class TempCallActivity : AppCompatActivity() {
instance = this
isMinimized = false
val rawGroupName = intent.getStringExtra("group_name")
val groupName = if (rawGroupName.isNullOrEmpty() || rawGroupName == "null") "临时会话" else rawGroupName
val gid = intent.getStringExtra("gid") ?: ""
val groupName = intent.getStringExtra("group_name") ?: "临时会话"
currentGroupName = groupName
if (gid.isNotEmpty()) {
currentTempGroupId = gid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
findViewById<TextView>(R.id.tv_group_name).text = groupName
setupMinimizeButton()
setupPttButton()
setupHangupButton()
startPulseAnimation()
startVoiceAlert()
fetchPullRecord()
}
override fun onResume() {
@ -259,16 +114,13 @@ class TempCallActivity : AppCompatActivity() {
pulseAnimator?.cancel()
if (instance == this) {
instance = null
// 如果不是最小化状态,说明是真正结束,清除所有状态
if (!isMinimized) {
currentGroupName = "临时会话"
currentTempGroupId = ""
}
// 通知 web 页面状态变化
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
if (!isMinimized) {
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
}
stopVoiceAlert()
}
// 拦截返回键
@ -430,104 +282,4 @@ class TempCallActivity : AppCompatActivity() {
}
}
}
private fun startVoiceAlert() {
try {
findViewById<Button>(R.id.btn_confirm_alert)?.apply {
visibility = View.VISIBLE
setOnClickListener {
stopVoiceAlert()
}
}
alertPlayCount = 0
alertPlayer = android.media.MediaPlayer.create(this, R.raw.pull_alert).apply {
setOnCompletionListener {
alertPlayCount++
if (alertPlayCount < 3) {
try {
it.start()
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error repeating voice play")
stopVoiceAlert()
}
} else {
stopVoiceAlert()
}
}
start()
}
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error initializing MediaPlayer")
findViewById<Button>(R.id.btn_confirm_alert)?.visibility = View.GONE
}
}
private fun stopVoiceAlert() {
try {
findViewById<Button>(R.id.btn_confirm_alert)?.visibility = View.GONE
alertPlayer?.let { player ->
if (player.isPlaying) {
player.stop()
}
player.release()
}
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error stopping MediaPlayer")
} finally {
alertPlayer = null
}
}
private fun fetchPullRecord() {
val serverUrl = AppConfig.getServerUrl(this)
val token = AppConfig.getAccessToken(this)
if (token.isEmpty()) {
Timber.tag("TempCallAlert").w("AccessToken is empty, cannot query pull record")
return
}
val url = "$serverUrl/api/vid-pull-record/my-list"
val request = okhttp3.Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $token")
.get()
.build()
com.stand.standapp.net.NetworkModule.defaultClient.newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Timber.tag("TempCallAlert").e(e, "获取拉动记录失败")
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
try {
val body = response.body?.string() ?: return
val json = org.json.JSONObject(body)
if (json.optInt("code", -1) == 0) {
val dataArray = json.optJSONArray("data")
if (dataArray != null && dataArray.length() > 0) {
val record = dataArray.optJSONObject(0) ?: return
val pullTitle = record.optString("pullTitle", "")
val pullTime = record.optString("pullTime", "")
val sourceUnitName = record.optString("sourceUnitName", "")
val pocGid = record.optString("pocGid", "")
if (pocGid.isNotEmpty() && currentTempGroupId.isEmpty()) {
currentTempGroupId = pocGid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
runOnUiThread {
findViewById<View>(R.id.ll_pull_info)?.visibility = View.VISIBLE
findViewById<TextView>(R.id.tv_pull_title)?.text = "标题:$pullTitle"
findViewById<TextView>(R.id.tv_pull_source_unit)?.text = "发送单位:$sourceUnitName"
findViewById<TextView>(R.id.tv_pull_time)?.text = "拉动时间:$pullTime"
findViewById<TextView>(R.id.tv_pull_content)?.text = "内容:请注意!正在对你单位进行视频拉动。"
}
}
}
} catch (e: Exception) {
Timber.tag("TempCallAlert").e(e, "解析拉动记录响应失败")
}
}
})
}
}

View File

@ -15,10 +15,7 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import com.stand.standapp.net.NetworkModule
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
@ -34,17 +31,21 @@ class UpdateManager(private val context: Context) {
private val fileName = "StandApp_Update.apk"
/**
* 获取应用外部专属下载目录下的文件 URI避免申请外部存储权限的同时确保安装包能被系统安装器读取
* 获取或创建公共下载目录下的文件 URI
*/
private fun getCacheFileUri(): Uri {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: context.externalCacheDir
?: context.cacheDir
val file = File(dir, fileName)
if (file.exists()) {
file.delete()
private fun getPublicDownloadUri(): Uri? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.android.package-archive")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val contentResolver = context.contentResolver
return contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} else {
val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)
return Uri.fromFile(file)
}
return Uri.fromFile(file)
}
fun checkUpdate() {
@ -54,41 +55,11 @@ class UpdateManager(private val context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) it.longVersionCode.toInt() else it.versionCode
}
} catch (e: Exception) { 0 }
val currentVersionName = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: Exception) { "" }
// 获取详细设备和尺寸属性
val deviceId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID) ?: "unknown_android"
val displayMetrics = context.resources.displayMetrics
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
val screenDensity = displayMetrics.density.toString()
val loginAccount = AppConfig.getSavedUsername(context)
val url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
val jsonParams = JSONObject().apply {
put("versionCode", currentVersionCode)
put("versionName", currentVersionName)
put("deviceId", deviceId)
put("deviceType", "接警终端")
put("deviceBrand", Build.BRAND)
put("deviceModel", Build.MODEL)
put("osVersion", "Android " + Build.VERSION.RELEASE)
put("screenWidth", screenWidth)
put("screenHeight", screenHeight)
put("screenDensity", screenDensity)
put("loginAccount", loginAccount)
}
val url = "$serverUrl/api/app-version/check-update"
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonParams.toString().toRequestBody(mediaType)
val client = NetworkModule.defaultClient
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
@ -102,7 +73,7 @@ class UpdateManager(private val context: Context) {
handler.post { Toast.makeText(context.applicationContext, "更新检查失败: ${response.code}", Toast.LENGTH_SHORT).show() }
return
}
val json = JSONObject(body)
if (json.getInt("code") == 0) {
val data = json.optJSONObject("data")
@ -111,9 +82,9 @@ class UpdateManager(private val context: Context) {
if (newVersionCode > currentVersionCode) {
val versionName = data.optString("versionName", "New")
val content = data.optString("updateContent", "优化系统体验")
val id = data.optString("id", "")
if (id != "") {
val id = data.optLong("id", -1)
if (id != -1L) {
(context as? AppCompatActivity)?.runOnUiThread {
try {
showUpdateDialog(versionName, content, id)
@ -140,7 +111,7 @@ class UpdateManager(private val context: Context) {
})
}
private fun showUpdateDialog(versionName: String, content: String, id: String) {
private fun showUpdateDialog(versionName: String, content: String, id: Long) {
val view = (context as AppCompatActivity).layoutInflater.inflate(R.layout.dialog_update, null)
val tvTitle = view.findViewById<android.widget.TextView>(R.id.tv_update_title)
val tvContent = view.findViewById<android.widget.TextView>(R.id.tv_update_content)
@ -158,7 +129,7 @@ class UpdateManager(private val context: Context) {
.setCancelable(false)
.setPositiveButton("立即升级") { _, _ ->
val serverUrl = AppConfig.getServerUrl(context)
val downloadUrl = "$serverUrl/api/app-version/download/$id"
val downloadUrl = "$serverUrl/app-version/download/$id"
startManualDownload(downloadUrl)
}
.setNegativeButton("稍后再说", null)
@ -167,8 +138,8 @@ class UpdateManager(private val context: Context) {
private fun startManualDownload(url: String) {
showProgressDialog()
val client = NetworkModule.defaultClient
val client = OkHttpClient()
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", "Mozilla/5.0 (Android)")
@ -212,20 +183,21 @@ class UpdateManager(private val context: Context) {
var targetUri: Uri? = null
try {
targetUri = getCacheFileUri()
val file = File(targetUri.path!!)
outputStream = FileOutputStream(file)
inputStream = body.byteStream()
targetUri = getPublicDownloadUri()
if (targetUri == null) throw IOException("无法创建文件记录")
outputStream = context.contentResolver.openOutputStream(targetUri)
inputStream = body.byteStream()
val totalBytes = body.contentLength()
val buffer = ByteArray(8192)
var bytesRead: Int
var totalRead: Long = 0
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
outputStream?.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (totalBytes > 0) {
val progress = (totalRead * 100 / totalBytes).toInt()
handler.post {
@ -234,11 +206,11 @@ class UpdateManager(private val context: Context) {
}
}
}
outputStream.flush()
outputStream?.flush()
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "下载完成,正在安装...", Toast.LENGTH_SHORT).show()
Toast.makeText(context.applicationContext, "下载完成,文件已保存到系统下载目录", Toast.LENGTH_SHORT).show()
installApk(targetUri)
}
} catch (e: Exception) {
@ -282,7 +254,7 @@ class UpdateManager(private val context: Context) {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// 如果是 content:// URI 直接使用,否则通过 FileProvider 转换
val installUri = if (uri.scheme == "content") {
uri
@ -290,7 +262,7 @@ class UpdateManager(private val context: Context) {
val file = File(uri.path!!)
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
intent.setDataAndType(installUri, "application/vnd.android.package-archive")
context.startActivity(intent)
} catch (e: Exception) {

View File

@ -1,25 +0,0 @@
package com.stand.standapp.net
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object NetworkModule {
private const val DEFAULT_CONNECT_TIMEOUT_SEC = 15L
private const val DEFAULT_READ_TIMEOUT_SEC = 30L
private const val DEFAULT_WRITE_TIMEOUT_SEC = 30L
val defaultClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(DEFAULT_CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS)
.readTimeout(DEFAULT_READ_TIMEOUT_SEC, TimeUnit.SECONDS)
.writeTimeout(DEFAULT_WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
fun streamingClient(): OkHttpClient {
return defaultClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
}
}

View File

@ -8,13 +8,12 @@ import com.dp.dp_serialportlist.Serialport_Factory
import com.stand.standapp.AppConfig
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
object PrinterManager {
private var sf: Serialport_Factory? = null
private var isConnecting = false
private var executor: ScheduledExecutorService? = null
private val executor = Executors.newSingleThreadScheduledExecutor()
private var mContext: Context? = null
private var hasPaper: Boolean = true // 默认为有纸
@ -44,9 +43,6 @@ object PrinterManager {
fun init(context: Context) {
if (sf != null) return
mContext = context.applicationContext
if (executor == null || executor!!.isShutdown) {
executor = Executors.newSingleThreadScheduledExecutor()
}
// Serialport_Factory 构造函数内部会创建 Handler必须在有 Looper 的线程(如主线程)调用
mHandler.post {
@ -55,9 +51,9 @@ object PrinterManager {
sf = Serialport_Factory.getSerialport_Factory(mContext, mHandler)
// 成功创建 factory 后,将后续耗时的连接任务丢给子线程
executor?.execute {
executor.execute {
// 启动自动重连定时任务
executor?.scheduleWithFixedDelay({
executor.scheduleWithFixedDelay({
checkAndConnect()
}, 0, 10, TimeUnit.SECONDS)
}
@ -69,26 +65,6 @@ object PrinterManager {
}
}
fun destroy() {
Timber.tag("Printer").i("正在销毁并重置打印机组件...")
try {
executor?.shutdownNow()
executor = null
} catch (e: Exception) {
Timber.tag("Printer").e(e, "关闭打印机线程池异常")
}
try {
sf?.ClosePort()
sf = null
} catch (e: Exception) {
Timber.tag("Printer").e(e, "关闭打印机串口异常")
}
mContext = null
isConnecting = false
hasPaper = true
Timber.tag("Printer").i("打印机组件销毁完成")
}
@Synchronized
private fun checkAndConnect() {
val factory = sf ?: return

View File

@ -13,16 +13,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
private const val STREAM_FLUSH_INTERVAL_MS = 150L
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val repository = AiChatRepository()
@ -35,49 +30,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _streamingContent = MutableStateFlow<Map<String, String>>(emptyMap())
val streamingContent: StateFlow<Map<String, String>> = _streamingContent.asStateFlow()
// 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问!
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话!
private var activeChatJob: kotlinx.coroutines.Job? = null
private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE)
private val chunkBuffer = java.util.concurrent.ConcurrentHashMap<String, StringBuilder>()
private val lastFlushAt = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val flushScope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
)
private val flushMutex = kotlinx.coroutines.sync.Mutex()
private fun scheduleFlush(messageId: String) {
val now = android.os.SystemClock.uptimeMillis()
val last = lastFlushAt[messageId] ?: 0
val delta = now - last
if (delta >= STREAM_FLUSH_INTERVAL_MS) {
flushStreamingMessage(messageId)
} else {
flushScope.launch {
kotlinx.coroutines.delay(STREAM_FLUSH_INTERVAL_MS - delta)
flushStreamingMessage(messageId)
}
}
}
private fun flushStreamingMessage(messageId: String) {
flushScope.launch {
flushMutex.withLock {
val buffer = chunkBuffer.remove(messageId) ?: return@withLock
val pending = buffer.toString()
if (pending.isEmpty()) return@withLock
_streamingContent.update { current ->
current + (messageId to (current[messageId].orEmpty() + pending))
}
lastFlushAt[messageId] = android.os.SystemClock.uptimeMillis()
}
}
}
init {
// 👈 将数据加载移到IO线程避免阻塞主线程导致UI卡顿
@ -139,57 +99,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun saveCacheToLocal() {
// 将数据保存放到IO线程中防止SharedPreferences写大文本阻塞主线程卡顿
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
// 保存会话列表
val sessionsArray = JSONArray()
_sessions.value.forEach { session ->
val obj = JSONObject()
obj.put("id", session.id)
obj.put("title", session.title)
obj.put("timestamp", session.timestamp)
sessionsArray.put(obj)
}
// 保存消息列表
val messagesArray = JSONArray()
_messages.value.forEach { msg ->
val obj = JSONObject()
obj.put("id", msg.id)
obj.put("sessionId", msg.sessionId)
obj.put("role", msg.role.name)
obj.put("content", msg.content)
messagesArray.put(obj)
}
prefs.edit()
.putString("cache_sessions", sessionsArray.toString())
.putString("cache_messages", messagesArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
try {
// 保存会话列表
val sessionsArray = JSONArray()
_sessions.value.forEach { session ->
val obj = JSONObject()
obj.put("id", session.id)
obj.put("title", session.title)
obj.put("timestamp", session.timestamp)
sessionsArray.put(obj)
}
// 保存消息列表
val messagesArray = JSONArray()
_messages.value.forEach { msg ->
val obj = JSONObject()
obj.put("id", msg.id)
obj.put("sessionId", msg.sessionId)
obj.put("role", msg.role.name)
obj.put("content", msg.content)
messagesArray.put(obj)
}
prefs.edit()
.putString("cache_sessions", sessionsArray.toString())
.putString("cache_messages", messagesArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
fun cancelActiveStreaming() {
activeChatJob?.cancel()
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
activeChatJob = null
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
_messages.value = _messages.value.map { msg ->
if (msg.isStreaming) {
val finalContent = _streamingContent.value[msg.id] ?: msg.content
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false
saveCacheToLocal()
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
}
fun createNewSession() {
@ -278,21 +232,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun appendAiMessageChunk(messageId: String, chunk: String) {
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
scheduleFlush(messageId)
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
msg.copy(content = msg.content + chunk)
} else msg
}
}
private fun finalizeAiMessage(messageId: String) {
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
_streamingContent.update { it - messageId }
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
lastFlushAt.remove(messageId)
saveCacheToLocal()
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
}
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {

View File

@ -94,19 +94,25 @@ fun FloatingChatWidget() {
visible = true
}
// 👈 用 FAB 位置计算对话框位置,但强制钳制在屏幕内
// FAB右下角为基准对话框应该出现在 FAB 附近
val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX // FAB右边缘
val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY // FAB下边缘
// 对话框优先放在 FAB 左上方,如果放不下就贴边
val rawX = fabRight - cardWidthPx - paddingPx // FAB左边
val rawY = fabBottom - cardHeightPx - paddingPx // FAB上边
// 👈 关键:钳制到屏幕内
val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx)
val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx)
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)),
modifier = Modifier
.offset {
val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX
val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY
val rawX = fabRight - cardWidthPx - paddingPx
val rawY = fabBottom - cardHeightPx - paddingPx
val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx)
val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx)
IntOffset(
(clampedX + cardDragX).roundToInt()
.coerceIn(0, (screenWidthPx - cardWidthPx).toInt()),

View File

@ -40,18 +40,6 @@ fun ChatArea(
}
}
val context = androidx.compose.ui.platform.LocalContext.current
val markwon = remember(context) {
io.noties.markwon.Markwon.builder(context)
.usePlugin(io.noties.markwon.core.CorePlugin.create())
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context))
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(context))
.build()
}
val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } }
if (isAnyStreaming) {
val totalLength = messages.map { it.content.length }.sum()
@ -123,7 +111,6 @@ fun ChatArea(
val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
MarkwonRenderer(
markdown = contentWithCursor,
markwon = markwon,
modifier = Modifier.padding(12.dp)
)
}
@ -174,7 +161,7 @@ fun ChatArea(
}
@Composable
fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) {
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply {
@ -186,14 +173,23 @@ fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifi
setPadding(0, 0, 0, 0)
}
val markwon = io.noties.markwon.Markwon.builder(ctx)
.usePlugin(io.noties.markwon.core.CorePlugin.create())
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(ctx))
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(ctx))
.build()
textView.tag = markwon
textView
},
update = { view ->
try {
val currentMarkwon = view.tag as? io.noties.markwon.Markwon
if (currentMarkwon != null) {
currentMarkwon.setMarkdown(view, markdown)
val markwon = view.tag as? io.noties.markwon.Markwon
if (markwon != null) {
markwon.setMarkdown(view, markdown)
} else {
view.text = markdown
}

View File

@ -31,19 +31,9 @@ fun ChatScaffold(
val sessions by viewModel.sessions.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val messages by viewModel.messages.collectAsState()
val streamingContent by viewModel.streamingContent.collectAsState()
var showClearDialog by remember { mutableStateOf(false) }
val currentMessages = remember(messages, streamingContent, currentSessionId) {
val streaming = streamingContent
messages.asSequence()
.filter { it.sessionId == currentSessionId }
.map { msg ->
val live = streaming[msg.id]
if (msg.isStreaming && live != null) msg.copy(content = live) else msg
}
.toList()
}
val currentMessages = messages.filter { it.sessionId == currentSessionId }
// 清除确认对话框
if (showClearDialog) {

View File

@ -2,7 +2,6 @@ package com.stand.standapp.ui.chat.repo
import com.stand.standapp.AppConfig
import android.content.Context
import com.stand.standapp.net.NetworkModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
@ -16,9 +15,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
class AiChatRepository {
private val client: OkHttpClient = NetworkModule.streamingClient()
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
.build()
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关

View File

@ -60,13 +60,6 @@ object AppKiller {
mgr.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
}
} catch (e: Exception) {}
} else {
// 如果不是重启,且 context 是 Activity调用 finishAffinity() 销毁所有关联的 Activity
if (context is android.app.Activity) {
try {
context.finishAffinity()
} catch (e: Exception) {}
}
}
android.os.Process.killProcess(android.os.Process.myPid())

View File

@ -1,217 +0,0 @@
package com.stand.standapp.utils
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Environment
import android.os.Process
import android.os.StatFs
import org.json.JSONObject
import timber.log.Timber
import java.net.NetworkInterface
import java.util.Collections
import java.util.Locale
object DeviceInfoUtils {
fun getDeviceInfoJson(context: Context): JSONObject {
val json = JSONObject()
val appContext = context.applicationContext
// Base App info is typically set at the caller level, but we populate core details
try {
json.put("model", Build.MODEL)
json.put("osVersion", Build.VERSION.RELEASE)
val abis = Build.SUPPORTED_ABIS
json.put("cpuAbi", abis.joinToString(","))
val is64Bit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Process.is64Bit()
} else {
abis.any { it.contains("64") }
}
json.put("is64Bit", is64Bit)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get base device info")
}
// 1. Screen and display info
try {
val screenObj = JSONObject()
val metrics = appContext.resources.displayMetrics
val widthPixels = metrics.widthPixels
val heightPixels = metrics.heightPixels
val xdpi = metrics.xdpi
val ydpi = metrics.ydpi
screenObj.put("width", widthPixels)
screenObj.put("height", heightPixels)
screenObj.put("density", metrics.density)
screenObj.put("densityDpi", metrics.densityDpi)
val physicalSize = if (xdpi > 0 && ydpi > 0) {
val widthInches = widthPixels / xdpi
val heightInches = heightPixels / ydpi
Math.sqrt((widthInches * widthInches + heightInches * heightInches).toDouble())
} else {
0.0
}
screenObj.put("physicalSize", Math.round(physicalSize * 10.0) / 10.0) // 1 decimal place
json.put("screen", screenObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get screen metrics")
}
// 2. Hardware and manufacturer info
try {
val hwObj = JSONObject()
hwObj.put("manufacturer", Build.MANUFACTURER)
hwObj.put("brand", Build.BRAND)
hwObj.put("hardware", Build.HARDWARE)
hwObj.put("fingerprint", Build.FINGERPRINT)
json.put("hardware", hwObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get hardware info")
}
// 3. System and Locale environment
try {
val sysObj = JSONObject()
sysObj.put("sdkVersion", Build.VERSION.SDK_INT)
sysObj.put("language", Locale.getDefault().language)
sysObj.put("country", Locale.getDefault().country)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
sysObj.put("securityPatch", Build.VERSION.SECURITY_PATCH)
} else {
sysObj.put("securityPatch", "")
}
json.put("system", sysObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get system info")
}
// 4. Memory Info (RAM)
try {
val memObj = JSONObject()
val actManager = appContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
actManager.getMemoryInfo(memInfo)
memObj.put("totalRam", memInfo.totalMem)
memObj.put("availRam", memInfo.availMem)
json.put("memory", memObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get memory info")
}
// 5. Storage Info
try {
val storageObj = JSONObject()
val path = Environment.getDataDirectory()
val stat = StatFs(path.path)
val blockSize = stat.blockSizeLong
val totalBlocks = stat.blockCountLong
val availableBlocks = stat.availableBlocksLong
storageObj.put("totalInternal", totalBlocks * blockSize)
storageObj.put("availInternal", availableBlocks * blockSize)
json.put("storage", storageObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get storage info")
}
// 6. Network state
try {
val netObj = JSONObject()
val connManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
var networkType = "NONE"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNetwork = connManager.activeNetwork
val capabilities = connManager.getNetworkCapabilities(activeNetwork)
if (capabilities != null) {
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
networkType = "WIFI"
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
networkType = "CELLULAR"
}
}
} else {
@Suppress("DEPRECATION")
val activeNetworkInfo = connManager.activeNetworkInfo
if (activeNetworkInfo != null && activeNetworkInfo.isConnected) {
if (activeNetworkInfo.type == ConnectivityManager.TYPE_WIFI) {
networkType = "WIFI"
} else if (activeNetworkInfo.type == ConnectivityManager.TYPE_MOBILE) {
networkType = "CELLULAR"
}
}
}
netObj.put("type", networkType)
var ipAddress = ""
try {
val interfaces = Collections.list(NetworkInterface.getNetworkInterfaces())
for (intf in interfaces) {
val addrs = Collections.list(intf.inetAddresses)
for (addr in addrs) {
if (!addr.isLoopbackAddress) {
val sAddr = addr.hostAddress ?: continue
val isIPv4 = sAddr.indexOf(':') < 0
if (isIPv4) {
ipAddress = sAddr
break
}
}
}
if (ipAddress.isNotEmpty()) break
}
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").w(e, "Failed to resolve IP address")
}
netObj.put("ipAddress", ipAddress)
json.put("network", netObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get network status")
}
// 7. Battery status
try {
val batObj = JSONObject()
val batteryStatus: Intent? = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1) ?: -1
val batteryPct = if (level >= 0 && scale > 0) (level * 100 / scale.toFloat()).toInt() else -1
batObj.put("level", batteryPct)
val status = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1) ?: -1
val statusString = when (status) {
android.os.BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
android.os.BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
android.os.BatteryManager.BATTERY_STATUS_FULL -> "full"
android.os.BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
else -> "unknown"
}
batObj.put("status", statusString)
val chargePlug = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_PLUGGED, -1) ?: -1
val plugString = when (chargePlug) {
android.os.BatteryManager.BATTERY_PLUGGED_AC -> "ac"
android.os.BatteryManager.BATTERY_PLUGGED_USB -> "usb"
android.os.BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
else -> "none"
}
batObj.put("plugged", plugString)
json.put("battery", batObj)
} catch (e: Exception) {
Timber.tag("DeviceInfoUtils").e(e, "Failed to get battery info")
}
return json
}
}

View File

@ -17,7 +17,7 @@ object GpioManager {
private var mIsRunning = false
private var mThread: Thread? = null
private var lastGpioValue = 1 // 默认松开状态 (1)
fun init(context: Context) {
if (mZysjSystemManager != null) return
try {
@ -35,21 +35,18 @@ object GpioManager {
Timber.tag("GPIO").i("Start GPIO listening thread")
while (mIsRunning) {
try {
// 硬件轮询频率优化:150ms 既能保证响应速度,又能降低 CPU 消耗
Thread.sleep(150)
// 硬件轮询频率优化:50ms 既能保证响应速度,又能降低 CPU 消耗
Thread.sleep(50)
val manager = mZysjSystemManager ?: continue
// 获取 GPIO 1 的值 (0 按下, 1 松开)
val currentValue = manager.get_zysj_gpio_value(1)
if (currentValue != lastGpioValue) {
Timber.tag("GPIO").d("GPIO 1 Value changed: $lastGpioValue -> $currentValue")
handleGpioChange(currentValue)
lastGpioValue = currentValue
}
} catch (e: InterruptedException) {
Timber.tag("GPIO").i("GPIO thread interrupted, exiting gracefully")
break
} catch (e: Exception) {
Timber.tag("GPIO").e(e, "Error in GPIO loop")
}
@ -61,10 +58,6 @@ object GpioManager {
mIsRunning = false
mThread?.interrupt()
mThread = null
mZysjSystemManager = null
isSpeaking = false
lastGpioValue = 1
Timber.tag("GPIO").i("GPIO listening stopped and state reset")
}
private var isSpeaking = false
@ -96,7 +89,7 @@ object GpioManager {
if (isSpeaking) {
val now = System.currentTimeMillis()
val duration = now - lastSpeakTime
if (duration < MIN_SPEAK_DURATION) {
val delay = MIN_SPEAK_DURATION - duration
Timber.tag("GPIO").d("Hardware speak duration too short ($duration ms), delaying release by $delay ms")

View File

@ -2,7 +2,6 @@ package com.stand.standapp.utils
import android.content.Context
import com.stand.standapp.AppConfig
import com.stand.standapp.net.NetworkModule
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.asRequestBody
@ -18,7 +17,7 @@ import kotlin.system.exitProcess
object LogManager {
private val executor = Executors.newSingleThreadExecutor()
private const val MAX_DAYS = 15
private val client = NetworkModule.defaultClient
private val client = OkHttpClient()
private var logDir: File? = null
fun init(context: Context) {
@ -99,6 +98,19 @@ object LogManager {
onCrashDetected(null, thread.name, throwable)
defaultHandler?.uncaughtException(thread, throwable) ?: exitProcess(1)
}
// 2. 捕获主线程 Looper
android.os.Handler(android.os.Looper.getMainLooper()).post {
while (true) {
try {
android.os.Looper.loop()
} catch (e: Throwable) {
onCrashDetected(null, "MainLooper", e)
// 如果是 UnsatisfiedLinkError这种错误无法恢复必须抛出让系统处理
throw e
}
}
}
}
private fun cleanOldLogs(dir: File) {
@ -187,9 +199,6 @@ object LogManager {
private val fileNameFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// 过滤掉高频的 VERBOSE 级别日志写入(例如录音帧),保留在控制台输出
if (priority == android.util.Log.VERBOSE) return
executor.execute {
try {
val logFile = File(logDir, "${fileNameFormat.format(Date())}.log")

View File

@ -149,53 +149,6 @@
android:tint="#CBD5E1" />
</LinearLayout>
<!-- 演示界面入口卡片 -->
<LinearLayout
android:id="@+id/item_demo_ui"
android:layout_width="match_parent"
android:layout_height="72dp"
android:background="@drawable/bg_developer_item"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@android:drawable/ic_menu_gallery"
android:tint="#F59E0B" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="本地演示界面"
android:textColor="#1E293B"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="启动本地 H5 打印及 PTT 控制演示页面"
android:textColor="#94A3B8"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@android:drawable/ic_media_next"
android:tint="#CBD5E1" />
</LinearLayout>
<!-- 崩溃测试卡片 -->
<LinearLayout
android:id="@+id/item_crash_test"

View File

@ -22,7 +22,7 @@
android:id="@+id/ll_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/login_header_margin_top"
android:layout_marginTop="28dp"
android:gravity="center"
android:orientation="vertical">
@ -46,8 +46,8 @@
android:background="@drawable/shape_dot_accent" />
<ImageView
android:layout_width="@dimen/login_logo_size"
android:layout_height="@dimen/login_logo_size"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:src="@drawable/ic_fire_logo"
@ -73,7 +73,7 @@
android:layout_marginTop="10dp"
android:text="消防值守台"
android:textColor="#00CFFF"
android:textSize="@dimen/login_title_text_size"
android:textSize="26sp"
android:textStyle="bold"
android:letterSpacing="0.15" />
@ -84,7 +84,7 @@
android:layout_marginTop="4dp"
android:text="FIRE WATCH MONITORING PLATFORM"
android:textColor="#2A6A8A"
android:textSize="@dimen/login_subtitle_text_size"
android:textSize="10sp"
android:letterSpacing="0.1" />
</LinearLayout>
@ -102,7 +102,7 @@
android:elevation="8dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/login_card_padding"
android:padding="28dp"
app:layout_constraintWidth_max="450dp"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.88"
@ -117,7 +117,7 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="@dimen/login_card_title_margin_bottom">
android:layout_marginBottom="20dp">
<View
android:layout_width="0dp"
@ -157,19 +157,19 @@
</LinearLayout>
<!-- 测试账号提示 -->
<!-- <TextView-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginBottom="@dimen/login_card_tip_margin_bottom"-->
<!-- android:text="测试账号 test010 test011 密码: 123456"-->
<!-- android:textColor="#2A6A8A"-->
<!-- android:textSize="12sp" />-->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="测试账号 test010 test011 密码: 123456"
android:textColor="#2A6A8A"
android:textSize="12sp" />
<!-- 用户名输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/login_input_height"
android:layout_marginBottom="@dimen/login_input_margin_bottom"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:background="@drawable/shape_edit_text">
<TextView
@ -205,8 +205,8 @@
<!-- 密码输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/login_input_height"
android:layout_marginBottom="@dimen/login_input_margin_bottom"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:background="@drawable/shape_edit_text">
<TextView
@ -259,7 +259,7 @@
<Button
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="@dimen/login_btn_height"
android:layout_height="48dp"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_button_login"
android:text="登 录"
@ -268,11 +268,22 @@
android:textStyle="bold"
android:letterSpacing="0.2" />
<!-- 正式界面登录按钮 -->
<Button
android:id="@+id/btn_login_official"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginBottom="8dp"
android:background="@drawable/shape_button_secondary"
android:text="正式界面登录"
android:textColor="#3A7A9A"
android:textSize="14sp" />
<!-- 退出应用 -->
<Button
android:id="@+id/btn_exit"
android:layout_width="match_parent"
android:layout_height="@dimen/login_exit_btn_height"
android:layout_height="36dp"
android:background="@android:color/transparent"
android:text="退出应用"
android:textColor="#1A4A6B"

View File

@ -11,8 +11,8 @@
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:layout_marginTop="@dimen/temp_call_minimize_margin_top"
android:layout_marginRight="@dimen/temp_call_minimize_margin_right"
android:layout_marginTop="40dp"
android:layout_marginRight="16dp"
android:background="@drawable/btn_minimize_bg"
android:gravity="center"
android:orientation="horizontal"
@ -45,13 +45,13 @@
android:layout_alignParentTop="true"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="@dimen/temp_call_header_padding_top"
android:paddingBottom="@dimen/temp_call_header_padding_bottom">
android:paddingTop="50dp"
android:paddingBottom="16dp">
<!-- 临时呼叫图标 -->
<ImageView
android:layout_width="@dimen/temp_call_logo_size"
android:layout_height="@dimen/temp_call_logo_size"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_menu_call"
android:tint="#FFFFFF" />
@ -63,7 +63,7 @@
android:layout_marginTop="8dp"
android:text="临时呼叫中"
android:textColor="#FFFFFF"
android:textSize="@dimen/temp_call_status_text_size"
android:textSize="18sp"
android:textStyle="bold"
android:letterSpacing="0.1" />
@ -86,7 +86,7 @@
android:background="@drawable/shape_login_box"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/temp_call_card_padding">
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
@ -103,53 +103,9 @@
android:layout_marginTop="8dp"
android:text="临时群组"
android:textColor="#FFFFFF"
android:textSize="@dimen/temp_call_group_name_text_size"
android:textSize="22sp"
android:textStyle="bold" />
<!-- 视频拉动信息区域 (默认隐藏,只有成功获取到数据才展示) -->
<LinearLayout
android:id="@+id/ll_pull_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_pull_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_pull_source_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="#CCFFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/tv_pull_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_pull_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="#EF4444"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 分隔线 -->
<View
android:layout_width="60dp"
@ -175,19 +131,6 @@
android:textColor="#10B981"
android:textSize="12sp"
android:visibility="gone" />
<!-- 用于确认并静音语音的按钮 -->
<Button
android:id="@+id/btn_confirm_alert"
android:layout_width="@dimen/temp_call_confirm_btn_width"
android:layout_height="@dimen/temp_call_confirm_btn_height"
android:layout_marginTop="16dp"
android:layout_gravity="center_horizontal"
android:background="@drawable/shape_button_login"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
<!-- 底部按钮区域 -->
@ -197,7 +140,7 @@
android:layout_alignParentBottom="true"
android:gravity="center"
android:orientation="vertical"
android:paddingBottom="@dimen/temp_call_bottom_padding">
android:paddingBottom="48dp">
<!-- PTT 大按钮 -->
<FrameLayout
@ -208,21 +151,21 @@
<!-- 外圈光晕 -->
<View
android:id="@+id/view_glow"
android:layout_width="@dimen/temp_call_ptt_glow_size"
android:layout_height="@dimen/temp_call_ptt_glow_size"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_glow" />
<!-- PTT 按钮 -->
<Button
android:id="@+id/btn_ptt"
android:layout_width="@dimen/temp_call_ptt_btn_size"
android:layout_height="@dimen/temp_call_ptt_btn_size"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_button"
android:text="按住\n说话"
android:textColor="#FFFFFF"
android:textSize="@dimen/temp_call_ptt_btn_text_size"
android:textSize="22sp"
android:textStyle="bold" />
</FrameLayout>

Binary file not shown.

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 1088x612 或极矮高度屏幕 (h400dp及以下) 适配尺寸 -->
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">8dp</dimen>
<dimen name="login_logo_size">36dp</dimen>
<dimen name="login_title_text_size">18sp</dimen>
<dimen name="login_subtitle_text_size">8sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">12dp</dimen>
<dimen name="login_card_title_margin_bottom">6dp</dimen>
<dimen name="login_card_tip_margin_bottom">6dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">38dp</dimen>
<dimen name="login_input_margin_bottom">6dp</dimen>
<dimen name="login_btn_height">36dp</dimen>
<dimen name="login_exit_btn_height">24dp</dimen>
<!-- 临时呼叫界面适配尺寸 (h400dp) -->
<dimen name="temp_call_minimize_margin_top">12dp</dimen>
<dimen name="temp_call_minimize_margin_right">10dp</dimen>
<dimen name="temp_call_header_padding_top">10dp</dimen>
<dimen name="temp_call_header_padding_bottom">8dp</dimen>
<dimen name="temp_call_logo_size">24dp</dimen>
<dimen name="temp_call_status_text_size">14sp</dimen>
<dimen name="temp_call_card_padding">12dp</dimen>
<dimen name="temp_call_group_name_text_size">16sp</dimen>
<dimen name="temp_call_confirm_btn_width">120dp</dimen>
<dimen name="temp_call_confirm_btn_height">32dp</dimen>
<dimen name="temp_call_bottom_padding">12dp</dimen>
<dimen name="temp_call_ptt_glow_size">110dp</dimen>
<dimen name="temp_call_ptt_btn_size">90dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">14sp</dimen>
</resources>

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 1280x720 或中等高度屏幕 (h540dp及以下) 适配尺寸 -->
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">16dp</dimen>
<dimen name="login_logo_size">48dp</dimen>
<dimen name="login_title_text_size">22sp</dimen>
<dimen name="login_subtitle_text_size">9sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">20dp</dimen>
<dimen name="login_card_title_margin_bottom">12dp</dimen>
<dimen name="login_card_tip_margin_bottom">10dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">44dp</dimen>
<dimen name="login_input_margin_bottom">10dp</dimen>
<dimen name="login_btn_height">42dp</dimen>
<dimen name="login_exit_btn_height">30dp</dimen>
<!-- 临时呼叫界面适配尺寸 (h540dp) -->
<dimen name="temp_call_minimize_margin_top">24dp</dimen>
<dimen name="temp_call_minimize_margin_right">14dp</dimen>
<dimen name="temp_call_header_padding_top">24dp</dimen>
<dimen name="temp_call_header_padding_bottom">12dp</dimen>
<dimen name="temp_call_logo_size">30dp</dimen>
<dimen name="temp_call_status_text_size">16sp</dimen>
<dimen name="temp_call_card_padding">16dp</dimen>
<dimen name="temp_call_group_name_text_size">18sp</dimen>
<dimen name="temp_call_confirm_btn_width">140dp</dimen>
<dimen name="temp_call_confirm_btn_height">36dp</dimen>
<dimen name="temp_call_bottom_padding">24dp</dimen>
<dimen name="temp_call_ptt_glow_size">144dp</dimen>
<dimen name="temp_call_ptt_btn_size">70dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">15sp</dimen>
</resources>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">28dp</dimen>
<dimen name="login_logo_size">56dp</dimen>
<dimen name="login_title_text_size">26sp</dimen>
<dimen name="login_subtitle_text_size">10sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">28dp</dimen>
<dimen name="login_card_title_margin_bottom">20dp</dimen>
<dimen name="login_card_tip_margin_bottom">16dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">50dp</dimen>
<dimen name="login_input_margin_bottom">14dp</dimen>
<dimen name="login_btn_height">48dp</dimen>
<dimen name="login_exit_btn_height">36dp</dimen>
<!-- 临时呼叫界面适配尺寸 -->
<dimen name="temp_call_minimize_margin_top">40dp</dimen>
<dimen name="temp_call_minimize_margin_right">16dp</dimen>
<dimen name="temp_call_header_padding_top">48dp</dimen>
<dimen name="temp_call_header_padding_bottom">16dp</dimen>
<dimen name="temp_call_logo_size">36dp</dimen>
<dimen name="temp_call_status_text_size">18sp</dimen>
<dimen name="temp_call_card_padding">24dp</dimen>
<dimen name="temp_call_group_name_text_size">22sp</dimen>
<dimen name="temp_call_confirm_btn_width">160dp</dimen>
<dimen name="temp_call_confirm_btn_height">40dp</dimen>
<dimen name="temp_call_bottom_padding">48dp</dimen>
<dimen name="temp_call_ptt_glow_size">180dp</dimen>
<dimen name="temp_call_ptt_btn_size">150dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">22sp</dimen>
</resources>

View File

@ -1,60 +0,0 @@
# 临时群组界面状态缓存与前端接口回调设计规约 (Design Spec)
## 1. 需求概述
为 StandAPP 临时呼叫功能增加群组锁定状态的缓存与回调机制:
1. **创建界面**:缓存当前的临时群十六进制 GID并通知前端页面调用 JS 接口 `startPullGroupLock(gid)`
2. **销毁界面**:清除缓存的临时群 GID并通知前端页面调用 JS 接口 `endPullGroupLock()`
3. **查询接口**:提供一个新的原生桥接方法 `getTempGroupId` 供前端 JS 主动查询当前在临时群的十六进制 GID。
4. **演示页面**:修改 `print_demo.html` 演示接收回调通知,并添加按钮演示查询当前临时群 GID。
## 2. 方案设计
### 2.1 临时群 GID 缓存逻辑 (`TempCallActivity.kt`)
* **静态成员变量**:在 `TempCallActivity.Companion` 中定义 `@Volatile var currentTempGroupId: String = ""`
* **创建逻辑 (`onCreate`)**
* 获取 `intent.getStringExtra("group_name")`(即临时群组名称,如 "临时群组1" )。
* 将名称使用 `SendAtUtil.str2HexStr(name)` 转为十六进制字符串(如 `"D5E1CAE6..."`),并存入 `currentTempGroupId`
* 如果 `intent` 直接传了 `group_id`,可以直接使用;如果没有,则使用上述十六进制 GID 作为 fallback。
* 调用:
```kotlin
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
```
* **销毁逻辑 (`onDestroy` / `clearMinimizedState`)**
* 销毁时如果 `!isMinimized`,则清除 `currentTempGroupId = ""`
* 发生真实销毁时调用:
```kotlin
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
```
### 2.2 原生桥接接口增加 (`AndroidInterface.kt`)
* 在 `dispatch` 方法的 `when(method)` 分支中增加:
* `"getTempGroupId" -> getTempGroupId()`
* 实现 `getTempGroupId` 接口:
```kotlin
fun getTempGroupId(): String {
return TempCallActivity.currentTempGroupId
}
```
### 2.3 演示页面修改 (`print_demo.html`)
* **注册回调方法**
```javascript
window.startPullGroupLock = function(gid) {
showPttResult("收到临时群锁定回调 startPullGroupLock, GID: " + gid);
};
window.endPullGroupLock = function() {
showPttResult("收到临时群解锁回调 endPullGroupLock");
};
```
* **新增主动查询按钮**
* 在 PTT 功能区域内添加一个按钮 “查询临时群 GID”
```html
<button onclick="queryTempGroupId()" class="ws-btn">查询当前临时群 GID</button>
```
* JS 触发逻辑:
```javascript
function queryTempGroupId() {
var gid = window.android.getTempGroupId();
showPttResult("当前在临时群的 GID: " + (gid || "无"));
}
```

View File

@ -1,38 +0,0 @@
# 临时呼叫界面优化与 pocGid 逻辑对接设计规约 (Design Spec)
## 1. 需求概述
为 StandAPP 临时呼叫功能做进一步改进与视觉优化:
1. **pocGid 解析与覆盖**:异步查询接口 `/api/vid-pull-record/my-list` 后,若返回数据包含 `pocGid` 字段且有效,需要用其覆盖当前的 `currentTempGroupId` 缓存,并通知前端接口 `startPullGroupLock(pocGid)`
2. **第一排“临时群组”显示为 null 的修复**:防范传递的 Intent Extra `"group_name"` 出现为空、`null` 或 `"null"` 字符串的情形,确保兜底展示为 `"临时会话"`
3. **界面字体、颜色与确认按钮样式优化**:美化拉动记录卡片排版,包括增大标题字号、加粗、改变按钮背景与宽度、调整配色提升对比度等。
## 2. 方案设计
### 2.1 pocGid 覆盖机制
`TempCallActivity.kt``fetchPullRecord()` 成功响应体处理中:
```kotlin
val pocGid = record.optString("pocGid", "")
if (pocGid.isNotEmpty()) {
currentTempGroupId = pocGid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
```
### 2.2 null 值防御
`onCreate()` 中,对 intent 传入的群组名称进行过滤判断:
```kotlin
val rawGroupName = intent.getStringExtra("group_name")
val groupName = if (rawGroupName.isNullOrEmpty() || rawGroupName == "null") "临时会话" else rawGroupName
currentGroupName = groupName
```
### 2.3 UI 优化
* **布局文件**`app/src/main/res/layout/activity_temp_call.xml`
* **拉动记录区域样式优化**
* `tv_pull_title` (标题):字体设为 `#FFFFFF``textSize="18sp"`,加粗,`layout_marginTop="12dp"`。
* `tv_pull_source_unit` (发送单位):字体设为 `#CCFFFFFF``textSize="13sp"``layout_marginTop="6dp"`。
* `tv_pull_time` (拉动时间):字体设为 `#99FFFFFF``textSize="12sp"``layout_marginTop="2dp"`。
* `tv_pull_content` (拉动内容):字体设为警示的红色 `#EF4444` 或黄色 `#FBBF24``textSize="14sp"`,加粗,`layout_marginTop="10dp"`。
* **确认按钮样式优化**
* 修改为固定宽度 `layout_width="160dp"`(居中显示),高为 `40dp`
* 背景使用带有圆角的深蓝或半透明按钮背景,字体颜色为纯白,字号为 `14sp`

View File

@ -1,85 +0,0 @@
# 临时呼叫页面语音提示与确认功能设计规约 (Design Spec)
## 1. 需求概述
`TempCallActivity` 弹出页面(临时呼叫页面)时,需要播放一段本地语音提示。
* **语音播报文本**"您有一条拉动,请注意确认"。
* **播报循环规则**:若用户不操作,默认播放并重复 3 次。
* **交互设计**
* 页面中央的信息卡片中新增一个“确认”按钮。
* 用户点击“确认”按钮时,能够立即静音/停止当前正在播放的语音,且该按钮在静音后隐藏。
* 点击“确认”按钮时仅关闭语音播放,暂时不需要关闭页面。
## 2. 方案设计
### 2.1 语音资源准备
* **文件名**`pull_alert.wav`
* **保存路径**`app/src/main/res/raw/pull_alert.wav`(若没有 `raw` 文件夹,将自动创建)
* **生成方式**:使用 Python 脚本调用 Edge TTS 等免费 TTS 接口,在线合成带有“您有一条拉动,请注意确认”文本的高质量 WAV 音频,并自动保存到对应目录下。
### 2.2 布局修改
* **目标文件**`app/src/main/res/layout/activity_temp_call.xml`
* **修改位置**:在包含 `tv_talk_status` 的内部 `LinearLayout` 容器下方,添加“确认”按钮。
* **按钮样式**
* 文字:"确认"
* 默认可见性:`android:visibility="gone"`(开始播放时显示,停止播放/确认后隐藏)
* 背景:复用现有 `@drawable/btn_minimize_bg`
```xml
<!-- 用于确认并静音语音的按钮 -->
<Button
android:id="@+id/btn_confirm_alert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/btn_minimize_bg"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
```
### 2.3 业务逻辑实现
* **目标文件**`app/src/main/java/com/stand/standapp/TempCallActivity.kt`
* **主要逻辑**
1. 定义 `MediaPlayer` 实例 `private var alertPlayer: MediaPlayer? = null` 及播放计数器 `private var alertPlayCount = 0`
2. 实现播放方法 `private fun startVoiceAlert()`
- 如果 `alertPlayer` 尚未初始化,利用 `MediaPlayer.create(this, R.raw.pull_alert)` 初始化。
- 显示“确认”按钮:`btnConfirmAlert.visibility = View.VISIBLE`。
- 监听播放完成事件:
```kotlin
alertPlayer?.setOnCompletionListener {
alertPlayCount++
if (alertPlayCount < 3) {
alertPlayer?.start()
} else {
stopVoiceAlert()
}
}
```
- 开始播放并设置 `alertPlayCount = 0`
3. 实现停止方法 `private fun stopVoiceAlert()`
- 安全释放 `MediaPlayer` 资源:
```kotlin
alertPlayer?.let { player ->
try {
if (player.isPlaying) {
player.stop()
}
} catch (e: Exception) {
// 忽略错误
} finally {
player.release()
}
}
alertPlayer = null
```
- 隐藏“确认”按钮:`btnConfirmAlert.visibility = View.GONE`。
4. 设置“确认”按钮的点击事件:
- 点击时调用 `stopVoiceAlert()`
5. 页面销毁生命周期:
- 在 `onDestroy()` 中,必须调用 `stopVoiceAlert()` 以防止内存泄漏和页面销毁后后台仍在播放的情况。
## 3. 测试与自审规范
* **测试用例 1**:启动临时呼叫页面,验证语音提示是否以正常音量播放,且重复播放 3 次后自动停止,同时“确认”按钮自动隐藏。
* **测试用例 2**:语音播放期间点击“确认”按钮,验证语音是否立刻停止播放,且“确认”按钮是否立即隐藏。
* **测试用例 3**:语音播放期间直接关闭或最小化页面,验证后台音乐播放器是否正确释放,不存在背景音残留。