Compare commits
69 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
7376b902e9 | |
|
|
5f09d0646f | |
|
|
55094fc24d | |
|
|
6e1ff7fb56 | |
|
|
24df4cb070 | |
|
|
d570ecb57f | |
|
|
f7def3a7f6 | |
|
|
65943334b1 | |
|
|
f840740a1a | |
|
|
24c8289a0d | |
|
|
a6cd238330 | |
|
|
f3e6aae9c3 | |
|
|
75909d953c | |
|
|
850550c392 | |
|
|
776eb9daae | |
|
|
b451e694f4 | |
|
|
8fe4a28f4c | |
|
|
a748d2b90b | |
|
|
2e1f115c19 | |
|
|
1d615bfafa | |
|
|
a74cdb7769 | |
|
|
f41557db0e | |
|
|
e807974315 | |
|
|
1733b2a971 | |
|
|
967700ff58 | |
|
|
e9147b1527 | |
|
|
cc73549ebd | |
|
|
38c7494d73 | |
|
|
bfa19d36ac | |
|
|
d7e9b29513 | |
|
|
59f48520a2 | |
|
|
f01cc99952 | |
|
|
e5a6d073cd | |
|
|
b3c7e243b5 | |
|
|
923c88c595 | |
|
|
74b10cd583 | |
|
|
2e3de6b968 | |
|
|
b0a5eb7234 | |
|
|
b12cefeb5a | |
|
|
71ba9f3e77 | |
|
|
15b04be06f | |
|
|
9340aa319d | |
|
|
4b10fea5b5 | |
|
|
c531965106 | |
|
|
9d5f4dc3a9 | |
|
|
8a7531428b | |
|
|
d937ad854d | |
|
|
ea7c977f62 | |
|
|
9ed1e31700 | |
|
|
e266a4e690 | |
|
|
89f1df9a9b | |
|
|
96068d56b2 | |
|
|
62cb78167d | |
|
|
d9b65c0e7e | |
|
|
992c18d8dc | |
|
|
4cb6b4ed08 | |
|
|
854e04b28f | |
|
|
eaf169c626 | |
|
|
4465887c96 | |
|
|
4b2bf8f5f4 | |
|
|
7060b99dd9 | |
|
|
58885f69a9 | |
|
|
01dec2de0e | |
|
|
a2c8fd2c1a | |
|
|
414681fa26 | |
|
|
450e04db89 | |
|
|
a982e54eeb | |
|
|
8bddca4996 | |
|
|
397e103596 |
|
|
@ -0,0 +1,16 @@
|
|||
# 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
|
||||
|
|
@ -11,8 +11,8 @@ android {
|
|||
applicationId = "com.stand.standapp"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 13
|
||||
versionName = "1.0.12"
|
||||
versionCode = 14
|
||||
versionName = "1.0.13"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
-dontwarn com.google.android.material.snackbar.**
|
||||
-dontwarn java.beans.**
|
||||
-dontwarn org.yaml.snakeyaml.**
|
||||
-keep class org.yaml.snakeyaml.** { *; }
|
||||
|
||||
# OkHttp 混淆规则
|
||||
-keepattributes Signature
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.StandAPP">
|
||||
android:theme="@style/Theme.StandAPP"
|
||||
android:hardwareAccelerated="true">
|
||||
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
|
|
|
|||
|
|
@ -113,12 +113,18 @@
|
|||
</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>
|
||||
|
|
@ -140,6 +146,8 @@
|
|||
<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">
|
||||
|
|
@ -236,6 +244,7 @@
|
|||
<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;">
|
||||
|
|
@ -519,18 +528,101 @@
|
|||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
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);
|
||||
document.getElementById('aboutModal').style.display = 'flex';
|
||||
} catch (e) {
|
||||
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;
|
||||
|
|
@ -1058,6 +1150,42 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,29 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁线程方法
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ 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;
|
||||
|
|
@ -17,17 +20,20 @@ public class CallBackResolution {
|
|||
private static String TAG = "PTT_RX";
|
||||
private static String indexGroupId = "";//用户所在群组Id
|
||||
private static String indexGroupName = "";//用户所在群组名称
|
||||
private static List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
|
||||
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 int speckType = 1;
|
||||
private static String talkId = "";
|
||||
private static String talkName = "";
|
||||
private static ArrayList<GroupInfoDto> groupInfos = new ArrayList<>();
|
||||
private static final Map<String, GroupInfoDto> groupInfos = new LinkedHashMap<>(MAX_GROUP_INFOS);
|
||||
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;
|
||||
|
|
@ -38,8 +44,12 @@ public class CallBackResolution {
|
|||
}
|
||||
|
||||
public static void resetPttLoginStatus() {
|
||||
loginState = false;
|
||||
pttLoginStatus = "00";
|
||||
pttLoginId = "";
|
||||
indexGroupId ="";
|
||||
indexGroupName = "";
|
||||
keepAliveExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
public static String getCurrentGroupId() {
|
||||
|
|
@ -83,6 +93,10 @@ 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"://账号有视频功能
|
||||
|
|
@ -139,7 +153,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.add(groupInfo);
|
||||
groupInfos.put(groupId, groupInfo);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -168,7 +182,7 @@ public class CallBackResolution {
|
|||
String tempTxt = unicodeToString(groupMemberName.toString());
|
||||
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
|
||||
if (memberNo == 1) memberInfoDtos.clear();
|
||||
memberInfoDtos.add(infoDto);
|
||||
memberInfoDtos.put(memberId, infoDto);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -263,34 +277,29 @@ public class CallBackResolution {
|
|||
|
||||
// 处理临时呼叫页面逻辑
|
||||
if (code == 1) {
|
||||
// 临时呼叫临时群组 → 打开临时呼叫页面
|
||||
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());
|
||||
}
|
||||
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");
|
||||
// }
|
||||
} else if (code == 0) {
|
||||
// 退出临时呼叫 → 关闭临时呼叫页面
|
||||
if (com.stand.standapp.TempCallActivity.isOpen()) {
|
||||
com.stand.standapp.TempCallActivity.closeWithMessage("临时呼叫已结束");
|
||||
} else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
|
||||
// 最小化状态下收到结束消息,清除状态并通知前端
|
||||
com.stand.standapp.TempCallActivity.clearMinimizedState();
|
||||
}
|
||||
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();
|
||||
// }
|
||||
} else if (code == 2) {
|
||||
// 呼叫失败 → 关闭临时呼叫页面
|
||||
// if (com.stand.standapp.TempCallActivity.isOpen()) {
|
||||
|
|
@ -335,6 +344,8 @@ public class CallBackResolution {
|
|||
indexGroupId = userGroupId;
|
||||
indexGroupName = userGroupName;
|
||||
CallBackUtil.callBackNotifyUpdateGroup(userGroupId, userGroupName);
|
||||
// 触发首次进入群组状态
|
||||
com.stand.standapp.MainActivity.onFirstGroupEntered();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -543,13 +554,16 @@ public class CallBackResolution {
|
|||
if (tempTxt.contains("已登录")) {
|
||||
String showName = tempTxt.substring(3, tempTxt.length());
|
||||
CallBackUtil.callBackLogin(true, showName);
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
|
||||
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
|
||||
keepAliveExecutor.shutdownNow();
|
||||
}
|
||||
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
Runnable task = () -> {
|
||||
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
|
||||
SendAtUtil.sendUDP();
|
||||
SendAtUtil.sendTCP();
|
||||
};
|
||||
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
} else if (tempTxt.contains("账号已更新")) {
|
||||
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
|
||||
} else if (tempTxt.contains("账号或密码错误")) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
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请在主线程进行
|
||||
|
|
@ -11,6 +16,45 @@ import java.util.List;
|
|||
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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 返回登录
|
||||
|
|
@ -68,11 +112,11 @@ public class CallBackUtil {
|
|||
* 查询群组成员返回
|
||||
* state true-成功, false-失败
|
||||
*/
|
||||
public static void callBackOnMemberSuccess(boolean state, List<GroupMemberInfoDto> dtos) {
|
||||
public static void callBackOnMemberSuccess(boolean state, java.util.Map<String, GroupMemberInfoDto> dtos) {
|
||||
try {
|
||||
org.json.JSONArray arr = new org.json.JSONArray();
|
||||
if (dtos != null && state) {
|
||||
for (GroupMemberInfoDto dto : dtos) {
|
||||
for (GroupMemberInfoDto dto : dtos.values()) {
|
||||
org.json.JSONObject obj = new org.json.JSONObject();
|
||||
obj.put("memberId", dto.getMemberId());
|
||||
obj.put("memberName", dto.getGmemberName());
|
||||
|
|
@ -80,7 +124,7 @@ public class CallBackUtil {
|
|||
arr.put(obj);
|
||||
}
|
||||
}
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
|
||||
pushJsThrottled("MemberList", "if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -128,17 +172,17 @@ public class CallBackUtil {
|
|||
* 返回群组集合信息
|
||||
* GroupInfoDto 群组信息
|
||||
* */
|
||||
public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
|
||||
public static void callBackGroupInfo (java.util.Map<String, GroupInfoDto> groupInfoDtos) {
|
||||
try {
|
||||
org.json.JSONArray arr = new org.json.JSONArray();
|
||||
for (GroupInfoDto dto : groupInfoDtos) {
|
||||
for (GroupInfoDto dto : groupInfoDtos.values()) {
|
||||
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);
|
||||
}
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
|
||||
pushJsThrottled("GroupList", "if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +201,7 @@ public class CallBackUtil {
|
|||
obj.put("groupName", groupName);
|
||||
obj.put("talkId", talkId);
|
||||
obj.put("talkName", talkName);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
|
||||
pushJsThrottled("SpeakerUpdate", "if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +215,7 @@ public class CallBackUtil {
|
|||
obj.put("groupName", groupName);
|
||||
obj.put("talkId", talkId);
|
||||
obj.put("talkName", talkName);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
|
||||
pushJsThrottled("PlayStatus", "if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +255,7 @@ public class CallBackUtil {
|
|||
obj.put("latitude", latitude);
|
||||
obj.put("longitude", longitude);
|
||||
obj.put("time", time);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
|
||||
pushJsThrottled("LocationStatus", "if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class SendAtUtil {
|
|||
*/
|
||||
public static void toCancelLogin() {
|
||||
send("050000\r\n");
|
||||
send("05000000\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +38,7 @@ public class SendAtUtil {
|
|||
*/
|
||||
public static void toLogout() {
|
||||
send("040000\r\n");
|
||||
send("04000000\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ 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 调用的方法名
|
||||
|
|
@ -75,6 +80,7 @@ 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)); "" }
|
||||
|
|
@ -85,6 +91,18 @@ 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")
|
||||
""
|
||||
|
|
@ -193,6 +211,9 @@ 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 {
|
||||
|
|
@ -365,6 +386,17 @@ 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)
|
||||
|
|
@ -378,22 +410,9 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
fun getAboutInfo(): String {
|
||||
return try {
|
||||
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
|
||||
val json = JSONObject()
|
||||
val json = com.stand.standapp.utils.DeviceInfoUtils.getDeviceInfoJson(activity)
|
||||
json.put("appName", activity.getString(R.string.app_name))
|
||||
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.put("versionName", packageInfo.versionName ?: "1.0.0")
|
||||
json.toString()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")
|
||||
|
|
@ -450,6 +469,9 @@ 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, "隐藏加载布局失败")
|
||||
}
|
||||
|
|
@ -494,6 +516,10 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getTempGroupId(): String {
|
||||
return TempCallActivity.currentTempGroupId
|
||||
}
|
||||
|
||||
fun maximizeTempCall() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
|
|
@ -503,4 +529,63 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ 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"
|
||||
|
|
@ -54,6 +56,7 @@ object AppConfig {
|
|||
getPrefs(context).edit().putBoolean(KEY_REMEMBER_LOGIN, remember).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSavedUsername(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_USERNAME, "") ?: ""
|
||||
}
|
||||
|
|
@ -70,6 +73,7 @@ object AppConfig {
|
|||
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAccessToken(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_ACCESS_TOKEN, "") ?: ""
|
||||
}
|
||||
|
|
@ -77,4 +81,22 @@ 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ 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() {
|
||||
|
|
@ -29,6 +31,48 @@ 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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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
|
||||
|
|
@ -17,7 +18,7 @@ import java.io.IOException
|
|||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val client = NetworkModule.defaultClient
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -32,7 +33,6 @@ 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,12 +45,22 @@ 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()
|
||||
|
|
@ -60,21 +70,9 @@ class LoginActivity : AppCompatActivity() {
|
|||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// 执行真实接口登录
|
||||
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)
|
||||
// 根据传入的 Intent 决定是正式登录还是演示登录,默认是正式登录(true)
|
||||
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
|
||||
performLogin(username, password, cbRemember.isChecked, isOfficialMode)
|
||||
}
|
||||
|
||||
// 4. 退出按钮
|
||||
|
|
@ -118,6 +116,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
val request = Request.Builder()
|
||||
.url(loginUrl)
|
||||
.addHeader("X-Client-Type", "android")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
|
|
@ -159,6 +158,14 @@ 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 账号并初始化
|
||||
|
|
@ -263,55 +270,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
private fun checkTempGroup(account: String, loginData: org.json.JSONObject?) {
|
||||
val accessToken = loginData?.optString("access_token", "") ?: return
|
||||
if (accessToken.isEmpty()) return
|
||||
|
||||
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, "解析临时群组响应失败")
|
||||
}
|
||||
}
|
||||
})
|
||||
TempCallActivity.checkAndHandleTempGroup(this, account, accessToken)
|
||||
}
|
||||
|
||||
private fun initPttWithAccount(username: String, accessToken: String) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
|
@ -19,6 +20,9 @@ class MainActivity : AppCompatActivity() {
|
|||
companion object {
|
||||
private var instance: MainActivity? = null
|
||||
|
||||
@Volatile
|
||||
private var geckoRuntime: GeckoRuntime? = null
|
||||
|
||||
// 防止对话框堆叠
|
||||
@Volatile
|
||||
private var isConflictDialogShowing = false
|
||||
|
|
@ -31,15 +35,72 @@ class MainActivity : AppCompatActivity() {
|
|||
@Volatile
|
||||
private var pendingJsCode: String? = null
|
||||
|
||||
private val jsLock = Any()
|
||||
|
||||
@JvmStatic
|
||||
fun executeJs(script: String) {
|
||||
Timber.tag("ExecuteJs").d(script)
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
// 合并多次调用(用换行分隔)
|
||||
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")
|
||||
|
|
@ -119,7 +180,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
|
||||
@JvmStatic
|
||||
fun pollPendingJs(): String {
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
val code = pendingJsCode ?: ""
|
||||
pendingJsCode = null
|
||||
return code
|
||||
|
|
@ -128,7 +189,6 @@ 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
|
||||
|
|
@ -156,7 +216,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()
|
||||
|
|
@ -217,17 +277,46 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun createGeckoRuntime(): GeckoRuntime {
|
||||
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")
|
||||
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
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
private fun createSession(): GeckoSession {
|
||||
|
|
@ -309,7 +398,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val result = handleBridgeCall(payload, prompt.defaultValue ?: "")
|
||||
return GeckoResult.fromValue(prompt.confirm(result))
|
||||
}
|
||||
|
||||
|
||||
// 默认处理(显示对话框)
|
||||
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
|
||||
runOnUiThread {
|
||||
|
|
@ -417,45 +506,78 @@ class MainActivity : AppCompatActivity() {
|
|||
.show()
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,15 +594,34 @@ 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)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,37 @@ 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)
|
||||
|
||||
// 延迟 2 秒进入登录界面
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}, 2000)
|
||||
// 异步检查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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ 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
|
||||
|
|
@ -34,12 +36,15 @@ class TempCallActivity : AppCompatActivity() {
|
|||
|
||||
@Volatile
|
||||
var isMinimized = false
|
||||
private set
|
||||
internal set
|
||||
|
||||
// 存储当前临时群组名称,用于恢复时传递
|
||||
@Volatile
|
||||
var currentGroupName: String = "临时会话"
|
||||
|
||||
@Volatile
|
||||
var currentTempGroupId: String = ""
|
||||
|
||||
@JvmStatic
|
||||
fun isOpen(): Boolean = instance != null
|
||||
|
||||
|
|
@ -53,9 +58,11 @@ class TempCallActivity : AppCompatActivity() {
|
|||
val intent = android.content.Intent(
|
||||
com.stand.standapp.MyApplication.getAppInstance(),
|
||||
TempCallActivity::class.java
|
||||
)
|
||||
intent.putExtra("group_name", currentGroupName)
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
).apply {
|
||||
putExtra("group_name", currentGroupName)
|
||||
putExtra("gid", currentTempGroupId)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +83,137 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,14 +231,22 @@ class TempCallActivity : AppCompatActivity() {
|
|||
instance = this
|
||||
isMinimized = false
|
||||
|
||||
val groupName = intent.getStringExtra("group_name") ?: "临时会话"
|
||||
val rawGroupName = intent.getStringExtra("group_name")
|
||||
val groupName = if (rawGroupName.isNullOrEmpty() || rawGroupName == "null") "临时会话" else rawGroupName
|
||||
val gid = intent.getStringExtra("gid") ?: ""
|
||||
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() {
|
||||
|
|
@ -114,13 +259,16 @@ 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()
|
||||
}
|
||||
|
||||
// 拦截返回键
|
||||
|
|
@ -282,4 +430,104 @@ 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, "解析拉动记录响应失败")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ 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
|
||||
|
|
@ -31,21 +34,17 @@ class UpdateManager(private val context: Context) {
|
|||
private val fileName = "StandApp_Update.apk"
|
||||
|
||||
/**
|
||||
* 获取或创建公共下载目录下的文件 URI
|
||||
* 获取应用外部专属下载目录下的文件 URI,避免申请外部存储权限的同时确保安装包能被系统安装器读取
|
||||
*/
|
||||
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)
|
||||
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()
|
||||
}
|
||||
return Uri.fromFile(file)
|
||||
}
|
||||
|
||||
fun checkUpdate() {
|
||||
|
|
@ -55,11 +54,41 @@ 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 url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
|
||||
// 获取详细设备和尺寸属性
|
||||
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 client = OkHttpClient()
|
||||
val request = Request.Builder().url(url).build()
|
||||
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()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
|
|
@ -73,7 +102,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")
|
||||
|
|
@ -82,9 +111,9 @@ class UpdateManager(private val context: Context) {
|
|||
if (newVersionCode > currentVersionCode) {
|
||||
val versionName = data.optString("versionName", "New")
|
||||
val content = data.optString("updateContent", "优化系统体验")
|
||||
val id = data.optLong("id", -1)
|
||||
|
||||
if (id != -1L) {
|
||||
val id = data.optString("id", "")
|
||||
|
||||
if (id != "") {
|
||||
(context as? AppCompatActivity)?.runOnUiThread {
|
||||
try {
|
||||
showUpdateDialog(versionName, content, id)
|
||||
|
|
@ -111,7 +140,7 @@ class UpdateManager(private val context: Context) {
|
|||
})
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(versionName: String, content: String, id: Long) {
|
||||
private fun showUpdateDialog(versionName: String, content: String, id: String) {
|
||||
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)
|
||||
|
|
@ -129,7 +158,7 @@ class UpdateManager(private val context: Context) {
|
|||
.setCancelable(false)
|
||||
.setPositiveButton("立即升级") { _, _ ->
|
||||
val serverUrl = AppConfig.getServerUrl(context)
|
||||
val downloadUrl = "$serverUrl/app-version/download/$id"
|
||||
val downloadUrl = "$serverUrl/api/app-version/download/$id"
|
||||
startManualDownload(downloadUrl)
|
||||
}
|
||||
.setNegativeButton("稍后再说", null)
|
||||
|
|
@ -138,8 +167,8 @@ class UpdateManager(private val context: Context) {
|
|||
|
||||
private fun startManualDownload(url: String) {
|
||||
showProgressDialog()
|
||||
|
||||
val client = OkHttpClient()
|
||||
|
||||
val client = NetworkModule.defaultClient
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", "Mozilla/5.0 (Android)")
|
||||
|
|
@ -183,21 +212,20 @@ class UpdateManager(private val context: Context) {
|
|||
var targetUri: Uri? = null
|
||||
|
||||
try {
|
||||
targetUri = getPublicDownloadUri()
|
||||
if (targetUri == null) throw IOException("无法创建文件记录")
|
||||
|
||||
outputStream = context.contentResolver.openOutputStream(targetUri)
|
||||
targetUri = getCacheFileUri()
|
||||
val file = File(targetUri.path!!)
|
||||
outputStream = FileOutputStream(file)
|
||||
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 {
|
||||
|
|
@ -206,11 +234,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) {
|
||||
|
|
@ -254,7 +282,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
|
||||
|
|
@ -262,7 +290,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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,13 @@ 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 val executor = Executors.newSingleThreadScheduledExecutor()
|
||||
private var executor: ScheduledExecutorService? = null
|
||||
private var mContext: Context? = null
|
||||
private var hasPaper: Boolean = true // 默认为有纸
|
||||
|
||||
|
|
@ -43,6 +44,9 @@ 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 {
|
||||
|
|
@ -51,9 +55,9 @@ object PrinterManager {
|
|||
sf = Serialport_Factory.getSerialport_Factory(mContext, mHandler)
|
||||
|
||||
// 成功创建 factory 后,将后续耗时的连接任务丢给子线程
|
||||
executor.execute {
|
||||
executor?.execute {
|
||||
// 启动自动重连定时任务
|
||||
executor.scheduleWithFixedDelay({
|
||||
executor?.scheduleWithFixedDelay({
|
||||
checkAndConnect()
|
||||
}, 0, 10, TimeUnit.SECONDS)
|
||||
}
|
||||
|
|
@ -65,6 +69,26 @@ 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
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ 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()
|
||||
|
||||
|
|
@ -30,14 +35,49 @@ 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卡顿
|
||||
|
|
@ -99,51 +139,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun saveCacheToLocal() {
|
||||
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)
|
||||
}
|
||||
// 将数据保存放到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)
|
||||
}
|
||||
// 保存消息列表
|
||||
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()
|
||||
prefs.edit()
|
||||
.putString("cache_sessions", sessionsArray.toString())
|
||||
.putString("cache_messages", messagesArray.toString())
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
|
||||
fun cancelActiveStreaming() {
|
||||
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
|
||||
activeChatJob?.cancel()
|
||||
activeChatJob = null
|
||||
|
||||
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
|
||||
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.isStreaming) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = _streamingContent.value[msg.id] ?: msg.content
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
|
||||
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
|
||||
saveCacheToLocal() // 强制存盘归档
|
||||
_streamingContent.value = emptyMap()
|
||||
chunkBuffer.clear()
|
||||
lastFlushAt.clear()
|
||||
|
||||
_isLoading.value = false
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
fun createNewSession() {
|
||||
|
|
@ -232,20 +278,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun appendAiMessageChunk(messageId: String, chunk: String) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(content = msg.content + chunk)
|
||||
} else msg
|
||||
}
|
||||
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
|
||||
scheduleFlush(messageId)
|
||||
}
|
||||
|
||||
private fun finalizeAiMessage(messageId: String) {
|
||||
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
|
||||
_streamingContent.update { it - messageId }
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
|
||||
lastFlushAt.remove(messageId)
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
||||
|
|
|
|||
|
|
@ -94,25 +94,19 @@ 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()),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ 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()
|
||||
|
|
@ -111,6 +123,7 @@ fun ChatArea(
|
|||
val contentWithCursor = msg.content + if (msg.isStreaming) " █" else ""
|
||||
MarkwonRenderer(
|
||||
markdown = contentWithCursor,
|
||||
markwon = markwon,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -161,7 +174,7 @@ fun ChatArea(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
|
||||
fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
val textView = SafeSelectableTextView(ctx).apply {
|
||||
|
|
@ -173,23 +186,14 @@ fun MarkwonRenderer(markdown: String, modifier: Modifier) {
|
|||
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 markwon = view.tag as? io.noties.markwon.Markwon
|
||||
if (markwon != null) {
|
||||
markwon.setMarkdown(view, markdown)
|
||||
val currentMarkwon = view.tag as? io.noties.markwon.Markwon
|
||||
if (currentMarkwon != null) {
|
||||
currentMarkwon.setMarkdown(view, markdown)
|
||||
} else {
|
||||
view.text = markdown
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,19 @@ 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 = messages.filter { it.sessionId == currentSessionId }
|
||||
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()
|
||||
}
|
||||
|
||||
// 清除确认对话框
|
||||
if (showClearDialog) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -15,12 +16,9 @@ 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.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
|
||||
.build()
|
||||
private val client: OkHttpClient = NetworkModule.streamingClient()
|
||||
|
||||
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
|
||||
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ 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())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 +35,21 @@ object GpioManager {
|
|||
Timber.tag("GPIO").i("Start GPIO listening thread")
|
||||
while (mIsRunning) {
|
||||
try {
|
||||
// 硬件轮询频率优化:50ms 既能保证响应速度,又能降低 CPU 消耗
|
||||
Thread.sleep(50)
|
||||
// 硬件轮询频率优化:150ms 既能保证响应速度,又能降低 CPU 消耗
|
||||
Thread.sleep(150)
|
||||
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")
|
||||
}
|
||||
|
|
@ -58,6 +61,10 @@ 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
|
||||
|
|
@ -89,7 +96,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")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -17,7 +18,7 @@ import kotlin.system.exitProcess
|
|||
object LogManager {
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private const val MAX_DAYS = 15
|
||||
private val client = OkHttpClient()
|
||||
private val client = NetworkModule.defaultClient
|
||||
private var logDir: File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
|
|
@ -98,19 +99,6 @@ 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) {
|
||||
|
|
@ -199,6 +187,9 @@ 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")
|
||||
|
|
|
|||
|
|
@ -149,6 +149,53 @@
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
android:id="@+id/ll_header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginTop="@dimen/login_header_margin_top"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
|
@ -46,8 +46,8 @@
|
|||
android:background="@drawable/shape_dot_accent" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_width="@dimen/login_logo_size"
|
||||
android:layout_height="@dimen/login_logo_size"
|
||||
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="26sp"
|
||||
android:textSize="@dimen/login_title_text_size"
|
||||
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="10sp"
|
||||
android:textSize="@dimen/login_subtitle_text_size"
|
||||
android:letterSpacing="0.1" />
|
||||
</LinearLayout>
|
||||
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
android:elevation="8dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="28dp"
|
||||
android:padding="@dimen/login_card_padding"
|
||||
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="20dp">
|
||||
android:layout_marginBottom="@dimen/login_card_title_margin_bottom">
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
|
|
@ -157,19 +157,19 @@
|
|||
</LinearLayout>
|
||||
|
||||
<!-- 测试账号提示 -->
|
||||
<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" />
|
||||
<!-- <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" />-->
|
||||
|
||||
<!-- 用户名输入框 -->
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:layout_height="@dimen/login_input_height"
|
||||
android:layout_marginBottom="@dimen/login_input_margin_bottom"
|
||||
android:background="@drawable/shape_edit_text">
|
||||
|
||||
<TextView
|
||||
|
|
@ -205,8 +205,8 @@
|
|||
<!-- 密码输入框 -->
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:layout_height="@dimen/login_input_height"
|
||||
android:layout_marginBottom="@dimen/login_input_margin_bottom"
|
||||
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="48dp"
|
||||
android:layout_height="@dimen/login_btn_height"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:background="@drawable/shape_button_login"
|
||||
android:text="登 录"
|
||||
|
|
@ -268,22 +268,11 @@
|
|||
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="36dp"
|
||||
android:layout_height="@dimen/login_exit_btn_height"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="退出应用"
|
||||
android:textColor="#1A4A6B"
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_alignParentRight="true"
|
||||
android:layout_marginTop="40dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginTop="@dimen/temp_call_minimize_margin_top"
|
||||
android:layout_marginRight="@dimen/temp_call_minimize_margin_right"
|
||||
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="50dp"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingTop="@dimen/temp_call_header_padding_top"
|
||||
android:paddingBottom="@dimen/temp_call_header_padding_bottom">
|
||||
|
||||
<!-- 临时呼叫图标 -->
|
||||
<ImageView
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="@dimen/temp_call_logo_size"
|
||||
android:layout_height="@dimen/temp_call_logo_size"
|
||||
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="18sp"
|
||||
android:textSize="@dimen/temp_call_status_text_size"
|
||||
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="24dp">
|
||||
android:padding="@dimen/temp_call_card_padding">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
|
|
@ -103,9 +103,53 @@
|
|||
android:layout_marginTop="8dp"
|
||||
android:text="临时群组"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="22sp"
|
||||
android:textSize="@dimen/temp_call_group_name_text_size"
|
||||
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"
|
||||
|
|
@ -131,6 +175,19 @@
|
|||
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>
|
||||
|
||||
<!-- 底部按钮区域 -->
|
||||
|
|
@ -140,7 +197,7 @@
|
|||
android:layout_alignParentBottom="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="48dp">
|
||||
android:paddingBottom="@dimen/temp_call_bottom_padding">
|
||||
|
||||
<!-- PTT 大按钮 -->
|
||||
<FrameLayout
|
||||
|
|
@ -151,21 +208,21 @@
|
|||
<!-- 外圈光晕 -->
|
||||
<View
|
||||
android:id="@+id/view_glow"
|
||||
android:layout_width="180dp"
|
||||
android:layout_height="180dp"
|
||||
android:layout_width="@dimen/temp_call_ptt_glow_size"
|
||||
android:layout_height="@dimen/temp_call_ptt_glow_size"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/circle_ptt_glow" />
|
||||
|
||||
<!-- PTT 按钮 -->
|
||||
<Button
|
||||
android:id="@+id/btn_ptt"
|
||||
android:layout_width="150dp"
|
||||
android:layout_height="150dp"
|
||||
android:layout_width="@dimen/temp_call_ptt_btn_size"
|
||||
android:layout_height="@dimen/temp_call_ptt_btn_size"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/circle_ptt_button"
|
||||
android:text="按住\n说话"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="22sp"
|
||||
android:textSize="@dimen/temp_call_ptt_btn_text_size"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,36 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# 临时群组界面状态缓存与前端接口回调设计规约 (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 || "无"));
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# 临时呼叫界面优化与 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`。
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# 临时呼叫页面语音提示与确认功能设计规约 (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**:语音播放期间直接关闭或最小化页面,验证后台音乐播放器是否正确释放,不存在背景音残留。
|
||||
Loading…
Reference in New Issue