Compare commits

..

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

85 changed files with 606 additions and 3310 deletions

1
.gitignore vendored
View File

@ -35,4 +35,3 @@ local.properties
/.kotlin/
/第三方/*
/startopencode
/docs/*

View File

@ -11,13 +11,13 @@ android {
applicationId = "com.stand.standapp"
minSdk = 24
targetSdk = 34
versionCode = 13
versionName = "1.0.12"
versionCode = 12
versionName = "1.0.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters.addAll(listOf("armeabi-v7a"))
abiFilters.addAll(listOf("armeabi-v7a","x86"))
}
}
@ -67,13 +67,6 @@ dependencies {
implementation("org.mozilla.geckoview:geckoview-omni:115.0.20230710165010")
implementation("com.google.android.material:material:1.12.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
implementation("io.noties.markwon:core:4.6.2")
implementation("io.noties.markwon:ext-tables:4.6.2")
implementation("io.noties.markwon:html:4.6.2")
implementation("io.noties.markwon:linkify:4.6.2") // 👈 链接自动识别
implementation("io.noties.markwon:ext-strikethrough:4.6.2") // 👈 删除线支持
implementation("io.noties.markwon:ext-tasklist:4.6.2") // 👈 任务列表支持
implementation("com.github.getActivity:XXPermissions:18.2")
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
@ -84,7 +77,6 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") // 👈 新增这一行以支持 Compose 中的 viewModel()
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,8 +1,6 @@
# GeckoView 混淆规则
-dontwarn org.mozilla.geckoview.**
-dontwarn org.mozilla.gecko.**
-keep class org.mozilla.gecko.** { *; }
-keep class org.mozilla.geckoview.** { *; }
-keep class org.mozilla.gecko.util.DebugConfig { *; }
# AndroidInterface 桥接类保护
-keep class com.stand.standapp.AndroidInterface { *; }
@ -10,8 +8,6 @@
# 忽略不存在的可选依赖报错
-dontwarn com.google.android.material.snackbar.**
-dontwarn java.beans.**
-dontwarn org.yaml.snakeyaml.**
# OkHttp 混淆规则
-keepattributes Signature
@ -23,22 +19,5 @@
# A resource is loaded at runtime for config index:
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# 保护 PTT 原生回调类native 通过 JNI 反射调用,不能混淆)
-keep class com.example.kingway.ptt.** { *; }
## 保护打印机 SDKjar 包中的类不能混淆)
-keep class com.dp.dp_serialportlist.** { *; }
-dontwarn com.dp.dp_serialportlist.**
-keep class com.zy.** { *; }
-dontwarn com.zy.**
#
## 保护硬件 GPIO 系统服务(隐藏 APIR8 不认识)
-keep class android.app.ZysjSystemManager { *; }
-dontwarn android.app.ZysjSystemManager
# 保护实体类 (如果你后续有 JSON 解析)
-keep class com.stand.standapp.** { *; }
-dontwarn java.beans.**
# R8 missing class 处理
-ignorewarnings

View File

@ -53,14 +53,6 @@
android:label="开发者模式"
android:theme="@style/Theme.StandAPP" />
<activity
android:name=".TempCallActivity"
android:exported="false"
android:label="临时呼叫"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:excludeFromRecents="true"
android:launchMode="singleTop" />
<activity
android:name=".printer.PrintTestActivity"
android:exported="false"

Binary file not shown.

View File

@ -1 +0,0 @@
E:\SDK\platform-tools\adb.exe exec-out "run-as com.stand.standapp cat /data/data/com.stand.standapp/cfg.dat" > cfg.dat

View File

@ -1,4 +1,5 @@
(function() {
// 在页面上下文中注入桥接脚本,避免 cross-compartment 访问被拒
var code = [
'(function() {',
' var _realAndroid = window.android;',

View File

@ -1,16 +1,10 @@
{
"manifest_version": 2,
"browser_specific_settings": {
"gecko": {
"id": "standapp-bridge@standapp.local"
}
},
"name": "standapp-bridge",
"version": "1.0",
"type": "extension",
"description": "JS-Native bridge for StandAPP",
"content_scripts": [{
"matches": ["<all_urls>"],
"matches": ["*://*/*"],
"js": ["bridge.js"],
"run_at": "document_start",
"all_frames": true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

View File

@ -92,13 +92,12 @@
</div>
<div style="margin-bottom: 20px;">
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">高级业务样式与测试</div>
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">高级业务样式</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<button class="btn btn-orange" style="margin:0; padding:10px; font-size:13px;" onclick="callNativePrintColumns()">分栏打印测试</button>
<button class="btn btn-orange" style="margin:0; padding:10px; font-size:13px;" onclick="callNativePrintAlarm()">警情通知单</button>
<button class="btn btn-orange" style="margin:0; padding:10px; font-size:13px;" onclick="callNativePrintDemo(false)">2寸演示单据</button>
<button class="btn btn-orange" style="margin:0; padding:10px; font-size:13px;" onclick="callNativePrintDemo(true)">3寸演示单据</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #0ea5e9; grid-column: span 2;" onclick="testES6Features()">ES6 引擎特性测试</button>
</div>
</div>
@ -112,22 +111,6 @@
</div>
</div>
<div style="margin-bottom: 20px;">
<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>
</div>
<div style="margin-top: 8px;">
<span class="ptt-label">提示标题:</span>
<input type="text" id="dialog-title" class="ws-input" value="系统通知" style="margin-bottom: 5px;">
<span class="ptt-label">提示内容:</span>
<input type="text" id="dialog-msg" class="ws-input" value="检测到配置更新,请重启应用以生效。">
<span class="ptt-label">JSON 数据:</span>
<textarea id="dialog-json" class="ws-input" rows="3" style="resize:vertical;">{"type":"alert","level":"warning","message":"测试消息","timestamp":1778505194}</textarea>
</div>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<button class="btn" style="background: #475569;" onclick="callSystemPrint()">1. 调用系统标准打印 (A4)</button>
@ -181,29 +164,6 @@
</div>
</div>
<!-- 临时呼叫状态区域 -->
<div id="temp-call-status" style="display: none; margin-top: 10px; padding: 10px; background: #fef3c7; border-radius: 8px; border: 1px solid #f59e0b;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 20px;">📞</span>
<div>
<div style="font-weight: bold; color: #92400e; font-size: 14px;">临时呼叫进行中</div>
<div id="temp-call-group-name" style="font-size: 12px; color: #b45309;">临时群组</div>
</div>
</div>
<button id="btn-maximize-temp-call" class="btn"
style="margin: 0; padding: 8px 16px; font-size: 12px; background: #f59e0b; min-width: auto;"
onclick="maximizeTempCall()">
最大化
</button>
</div>
</div>
<div style="margin-top: 8px; font-size: 13px;">
当前群组: <span id="ptt-current-group" style="color: #6366f1; font-weight: bold;">-</span>
<span id="ptt-current-group-id" style="color: #94a3b8; font-size: 11px; margin-left: 5px;"></span>
</div>
<div class="ws-input-group" style="margin-top: 10px;">
<div style="display: flex; gap: 8px;">
<div style="flex: 1;">
@ -233,7 +193,6 @@
<button class="btn ptt-btn ptt-action-btn" style="background: #6366f1;" onclick="pttGetGroups()" disabled>获取群组</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #8b5cf6;" onclick="pttGetMembers()" disabled>获取成员</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #3b82f6;" onclick="pttJumpGroup()" disabled>切换群组</button>
<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>
</div>
@ -297,15 +256,6 @@
updatePrinterStatus();
// 每 3 秒同步一次打印机状态
setInterval(updatePrinterStatus, 3000);
// 确认接口可用后再加载历史
loadHistory();
// 通知 Android 页面加载完成
if (window.android.onPageFinished) {
window.android.onPageFinished();
}
} else {
// bridge 尚未注入200ms 后重试
setTimeout(checkApp, 200);
}
}
@ -433,31 +383,6 @@
}
}
function triggerRestartDialog() {
if (window.android && window.android.showRestartDialog) {
var msg = document.getElementById('dialog-msg').value;
var title = document.getElementById('dialog-title').value;
window.android.showRestartDialog(msg, title);
} else {
alert("showRestartDialog 接口不可用");
}
}
function triggerJsonDialog() {
if (window.android && window.android.showJsonDialog) {
var json = document.getElementById('dialog-json').value;
try {
JSON.parse(json);
} catch(e) {
alert("JSON 格式错误: " + e.message);
return;
}
window.android.showJsonDialog(json);
} else {
alert("showJsonDialog 接口不可用");
}
}
// 调用系统打印
function callSystemPrint() {
if (window.android && window.android.printPage) {
@ -493,19 +418,9 @@
}
// 上传日志
var isUploading = false;
function callUploadLogs() {
if (isUploading) {
alert("正在上传中请耐心等待30秒内请勿重复点击...");
return;
}
if (window.android && window.android.uploadLogs) {
isUploading = true;
alert("开始上传日志,请耐心等待...");
window.android.uploadLogs();
setTimeout(function() {
isUploading = false;
}, 30000);
} else {
alert("上传日志接口不可用");
}
@ -545,77 +460,7 @@
document.getElementById('aboutModal').style.display = 'none';
}
// 打印机查纸回调
window.onPaperStateChanged = function(hasPaper) {
if (hasPaper) {
alert("打印机状态正常:当前有纸");
} else {
alert("⚠️ 打印机缺纸!请装入纸卷!");
}
updatePrinterStatus();
};
// PTT 各种事件回调
window.onPttEvent = function(eventType, payload) {
console.log("收到 PTT 事件: ", eventType, payload);
var msg = "";
switch(eventType) {
case 'GroupList':
msg = "获取群组成功,共 " + payload.length + " 个群组。第一组: " + (payload[0] ? payload[0].groupName : "无");
break;
case 'MemberList':
msg = "获取群成员成功,共 " + payload.length + " 个成员。";
break;
case 'SpeakerUpdate':
msg = "当前讲话人: " + payload.talkName + " (组:" + payload.groupName + ")";
break;
case 'PlayStatus':
msg = (payload.playStatus === 1 ? "开始播放:" : "结束播放:") + payload.talkName;
break;
case 'TempCall':
msg = "临时呼叫消息: " + payload.msg;
break;
case 'UpdateGroup':
msg = "进入群组: " + payload.groupName;
break;
case 'Alarm':
msg = "⚠️收到报警: " + payload.memberName + " (纬度:" + payload.latitude + ")";
break;
case 'StopAlarm':
msg = "群成员停止告警";
break;
case 'SpeakResult':
msg = payload.success ? "话权获取成功" : "话权获取失败";
break;
case 'SpeakEnd':
msg = payload.success ? "话权释放成功" : "话权释放失败";
break;
default:
msg = "收到事件: " + eventType;
}
showPttResult("[回调] " + msg);
};
// ES6 特性测试函数(通过 eval 避免阻塞旧版 JS 引擎整体解析)
function testES6Features() {
try {
var code = [
"const arr = [1, 2, 3];",
"const [a, ...rest] = arr;",
"let sum = rest.reduce((acc, val) => acc + val, 0);",
"const myMap = new Map();",
"myMap.set('key', `Sum is ${sum}`);",
"new Promise((resolve) => {",
" setTimeout(() => resolve(myMap.get('key')), 100);",
"}).then(res => {",
" alert(`ES6 引擎测试成功!\\n解构: a=${a}\\nPromise/Map 返回: ${res}`);",
"});"
].join("");
eval(code);
} catch(e) {
alert("ES6 测试失败 (当前环境可能不支持): " + e.message);
}
}
// 安卓回调函数:退出确认时执行
function addExitText() {
var tag = document.getElementById('inject-status');
if (tag) {
@ -635,86 +480,15 @@
var loginId = window.android.pttGetLoginId ? window.android.pttGetLoginId() : "";
updatePttLoginStatus(status, loginId);
}
if (window.android && window.android.pttGetCurrentGroup) {
try {
var group = JSON.parse(window.android.pttGetCurrentGroup());
if (group.groupId) {
updatePttCurrentGroup(group.groupId, group.groupName);
}
} catch(e) {}
}
// 同步临时呼叫状态
syncTempCallStatus();
}, 500);
// === 临时呼叫状态管理 ===
function syncTempCallStatus() {
if (window.android && window.android.getTempCallStatus) {
try {
var status = JSON.parse(window.android.getTempCallStatus());
updateTempCallUI(status.isOpen, status.isMinimized);
} catch(e) {}
}
}
function updateTempCallUI(isOpen, isMinimized) {
var statusDiv = document.getElementById('temp-call-status');
var maxBtn = document.getElementById('btn-maximize-temp-call');
var groupSpan = document.getElementById('temp-call-group-name');
// 只有在最小化状态时才显示最大化按钮
if (isMinimized) {
statusDiv.style.display = 'block';
maxBtn.style.display = 'inline-block';
maxBtn.disabled = false;
maxBtn.style.background = '#f59e0b';
maxBtn.style.cursor = 'pointer';
} else if (isOpen) {
// Activity 打开但未最小化,不显示状态区域
statusDiv.style.display = 'none';
} else {
// Activity 已销毁且未最小化
statusDiv.style.display = 'none';
}
}
function maximizeTempCall() {
if (window.android && window.android.maximizeTempCall) {
window.android.maximizeTempCall();
}
}
// 临时呼叫状态变化回调 (由 Android 原生调用)
window.onTempCallStateChanged = function(state) {
console.log("临时呼叫状态变化: " + state);
var statusDiv = document.getElementById('temp-call-status');
var maxBtn = document.getElementById('btn-maximize-temp-call');
switch(state) {
case 'minimized':
// 最小化:显示状态区域和最大化按钮
statusDiv.style.display = 'block';
maxBtn.style.display = 'inline-block';
maxBtn.disabled = false;
maxBtn.style.background = '#f59e0b';
showPttResult("[回调] 临时呼叫已最小化,可通过最大化按钮恢复");
break;
case 'resumed':
// 恢复:隐藏状态区域
statusDiv.style.display = 'none';
showPttResult("[回调] 临时呼叫页面已恢复");
break;
case 'destroyed':
// 销毁:隐藏所有
statusDiv.style.display = 'none';
showPttResult("[回调] 临时呼叫已结束");
break;
// 告知 Android 页面加载完成
window.onload = function() {
if (window.android && window.android.onPageFinished) {
window.android.onPageFinished();
}
};
// 告知 Android 页面加载完成(已移至 checkApp 中,等 bridge 注入后再调用)
window.onload = function() {};
// === WebSocket 逻辑 ===
var ws = null;
var clientId = "WEB_" + Math.random().toString(36).substr(2, 9);
@ -864,10 +638,11 @@
addLog("系统: 终端暂无保存的记录", false);
}
} else {
// 不再弹窗checkApp 会在 bridge 就绪后调用 loadHistory
alert("未检测到安卓接口,无法同步");
}
}
setTimeout(loadHistory, 500);
// === PTT 业务测试逻辑 ===
var pttBtn = document.getElementById('ptt-speak-btn');
@ -901,13 +676,6 @@
}
};
// 更新当前群组信息 (由 Android 实时推送)
function updatePttCurrentGroup(groupId, groupName) {
document.getElementById('ptt-current-group').innerText = groupName || '-';
document.getElementById('ptt-current-group-id').innerText = groupId ? '(' + groupId + ')' : '';
showPttResult("进入群组: " + groupName + " (" + groupId + ")");
};
// PTT 登录成功回调 (由 Android 调用)
function onPttLoginSuccess() {
updatePttLoginStatusUI("02");
@ -1032,21 +800,6 @@
}
}
function pttGetCurrentGroup() {
if (window.android && window.android.pttGetCurrentGroup) {
try {
var group = JSON.parse(window.android.pttGetCurrentGroup());
if (group.groupId) {
updatePttCurrentGroup(group.groupId, group.groupName);
} else {
showPttResult("当前无群组信息");
}
} catch(e) {
showPttResult("获取群组信息失败");
}
}
}
function pttSendAtCmd() {
var cmd = document.getElementById('ptt-at-cmd').value.trim();
if (!cmd) {

View File

@ -3,14 +3,13 @@ package com.example.kingway.ptt;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import timber.log.Timber;
import com.stand.standapp.MyApplication;
import android.util.Log;
import java.util.Arrays;
@SuppressWarnings("ResultOfMethodCallIgnored")
public class AudioRecordManager {
private final String TAG = "AudioRecordManager";
private static final int FREQUENCY = 8000; //16K采集率
private static final int FREQUENCY = 8000;
private AudioRecord mRecorder;
private Thread recordThread;
private boolean isStart = false;
@ -23,24 +22,20 @@ public class AudioRecordManager {
}
public void initAudio() {
//实例化一个AudioRecord类
int audioSource = MediaRecorder.AudioSource.MIC;
mRecorder = new AudioRecord(audioSource,
FREQUENCY,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2);
Timber.tag(TAG).d("AudioRecord初始化: source=MIC, freq=%dHz, minBuf=%d", FREQUENCY, bufferSize * 2);
}
public void setRecording(){
Timber.tag(TAG).d("setRecording -> startRecording()");
if (mRecorder == null) initAudio();
if(mRecorder.getRecordingState() != 3) mRecorder.startRecording();
}
public void stopAudio(){
Timber.tag(TAG).d("stopAudio -> stop()");
if (mRecorder != null){
mRecorder.stop();
}
@ -52,7 +47,6 @@ public class AudioRecordManager {
}
public void destroyAudio(){
Timber.tag(TAG).d("destroyAudio -> 释放AudioRecord");
if (mRecorder != null){
mRecorder.stop();
mRecorder.release();
@ -84,13 +78,12 @@ public class AudioRecordManager {
try {
recordThread.interrupt();
} catch (Exception e) {
Timber.tag(TAG).e(e, "录音线程interrupt失败");
recordThread = null;
}
}
recordThread = null;
} catch (Exception e) {
Timber.tag(TAG).e(e, "destroyThread异常");
e.printStackTrace();
} finally {
recordThread = null;
}
@ -100,7 +93,6 @@ public class AudioRecordManager {
* 启动录音线程
*/
private void startThread() {
Timber.tag(TAG).d("启动录音线程");
destroyThread();
isStart = true;
if (mRecorder == null) {
@ -123,13 +115,10 @@ public class AudioRecordManager {
int bytesRecord;
byte[] tempBuffer = new byte[bufferSize];
if (mRecorder.getState() != AudioRecord.STATE_INITIALIZED) {
Timber.tag(TAG).w("AudioRecord未初始化(state=%d),终止录音", mRecorder.getState());
stopRecord();
return;
}
mRecorder.startRecording();
Timber.tag(TAG).d("录音线程: startRecording() 成功");
int count = 0;
while (isStart) {
if (null != mRecorder) {
bytesRecord = mRecorder.read(tempBuffer, 0, bufferSize);
@ -138,16 +127,15 @@ public class AudioRecordManager {
continue;
}
if (bytesRecord != 0 && bytesRecord != -1) {
Timber.tag(TAG).v("录音帧: %d bytes", bytesRecord);
Log.i(TAG, "tempBuffer->" + Arrays.toString(tempBuffer));
MyApplication.getAppInstance().getpNative().pttNativeSendRecordCmd(tempBuffer);
} else {
Timber.tag(TAG).w("录音循环退出: bytesRecord=%d", bytesRecord);
break;
}
}
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "录音线程异常");
e.printStackTrace();
}
}
@ -157,13 +145,11 @@ public class AudioRecordManager {
* 启动录音
*/
public void startRecord() {
Timber.tag(TAG).d("startRecord() 被调用");
try {
stopRecord();
startThread();
} catch (Exception e) {
Timber.tag(TAG).e(e, "startRecord异常");
e.printStackTrace();
}
}
@ -171,8 +157,8 @@ public class AudioRecordManager {
* 停止录音
*/
public void stopRecord() {
Timber.tag(TAG).d("stopRecord() 被调用");
try {
isStart = false;
destroyThread();
if (mRecorder != null) {
try {
@ -181,11 +167,10 @@ public class AudioRecordManager {
}
mRecorder.release();
mRecorder = null;
} catch (Exception e) { Timber.tag(TAG).e(e, "释放AudioRecord失败"); }
} catch (Exception e) { }
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "stopRecord异常");
} finally {
e.printStackTrace();
}
}

View File

@ -1,16 +1,8 @@
package com.example.kingway.ptt;
import android.util.Log;
import timber.log.Timber;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**解析接收到的AT指令*/
public class CallBackResolution {
@ -42,14 +34,6 @@ public class CallBackResolution {
pttLoginId = "";
}
public static String getCurrentGroupId() {
return indexGroupId;
}
public static String getCurrentGroupName() {
return indexGroupName;
}
public static void at_OEM_AT_cb(List<String> lisHex){
if (lisHex.size() == 0) {
return;
@ -66,25 +50,21 @@ public class CallBackResolution {
String loginId = lisHex.get(2) + "" + lisHex.get(3) + "" + lisHex.get(4) + "" + lisHex.get(5);
pttLoginStatus = errorNum;
pttLoginId = loginId;
Timber.tag(TAG).d("PTT login status: "+pttLoginStatus+", id: "+pttLoginId);
Timber.tag(TAG).i("PTT login status: %s, id: %s", pttLoginStatus, pttLoginId);
// 实时推送状态到前端
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
CallBackUtil.callBackLoinState(errorNum, loginId);
loginState = isLoginConflict(errorNum);
CallBackUtil.callBackLoginConflict(loginState);
if (loginState) {
Timber.tag(TAG).w("Login conflict detected, showing conflict dialog");
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
com.stand.standapp.MainActivity.showPttConflictDialog();
}
if ("00".equals(errorNum)) {
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
}
if ("02".equals(errorNum)) {
com.stand.standapp.MainActivity.onPttLoginSuccess();
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
}
break;
case "a2"://账号有视频功能
CallBackUtil.callBackHaveVideo();
break;
@ -112,7 +92,7 @@ public class CallBackResolution {
case "80"://返回群组信息
Timber.tag(TAG).d("at_OEM_AT_cb get group info: " + lisHex);
StringBuilder strGroup = new StringBuilder();
String strGroup = "";
String groupId = lisHex.get(6) + "" + lisHex.get(7) + "" + lisHex.get(8) + "" + lisHex.get(9);
int groupNo = Integer.valueOf((lisHex.get(4) + "" + lisHex.get(5)), 16);
int groupCount = Integer.valueOf((lisHex.get(10) + "" + lisHex.get(11)), 16);
@ -123,17 +103,17 @@ public class CallBackResolution {
size = (size / 2) - 1;
for (int i = 0; i < size; i++) {
if (i == 0) {
strGroup.append(ngx).append(lisHex.get(i + numB)).append(lisHex.get(i + numB - 1));
strGroup += ngx + lisHex.get(i + numB) + "" + lisHex.get(i + numB - 1);
} else {
strGroup.append(ngx).append(lisHex.get(i + numB + 1)).append(lisHex.get(i + numB));
strGroup += ngx + lisHex.get(i + numB + 1) + "" + lisHex.get(i + numB);
numB = numB + 1;
}
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
String tempTxt = "";
tempTxt = unicodeToString(strGroup.toString());
tempTxt = unicodeToString(strGroup);
Timber.tag(TAG).i(" 组名称 " + tempTxt);
CallBackUtil.callBackGroupInfo(groupId, tempTxt, groupNo, groupCount);
Timber.tag(TAG).d("at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
@ -144,7 +124,7 @@ public class CallBackResolution {
break;
case "81"://返回群成员信息
StringBuilder groupMemberName = new StringBuilder();
String groupMemberName = "";
String status = lisHex.get(1);
boolean haveVideo = lisHex.get(10).equals("01") ? true : false;
String memberId = lisHex.get(6) + "" + lisHex.get(7) + "" + lisHex.get(8) + "" + lisHex.get(9);
@ -156,19 +136,21 @@ public class CallBackResolution {
size = (size / 2) - 1;
for (int i = 0; i < size; i++) {
if (i == 0) {
groupMemberName.append(ngx).append(lisHex.get(i + numB)).append(lisHex.get(i + numB - 1));
groupMemberName += ngx + lisHex.get(i + numB) + "" + lisHex.get(i + numB - 1);
} else {
groupMemberName.append(ngx).append(lisHex.get(i + numB + 1)).append(lisHex.get(i + numB));
groupMemberName += ngx + lisHex.get(i + numB + 1) + "" + lisHex.get(i + numB);
numB = numB + 1;
}
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
String tempTxt = unicodeToString(groupMemberName.toString());
String tempTxt = unicodeToString(groupMemberName);
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
if (memberNo == 1) memberInfoDtos.clear();
memberInfoDtos.add(infoDto);
//输出群成员信息memberInfoDtos
Timber.tag(TAG).d("at_OEM_AT_cb group member info: " + infoDto);
}
break;
@ -199,14 +181,13 @@ public class CallBackResolution {
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
String tempTxt = "";
for (int i = 0; i < stringsSpker.size(); i++) {
tempTxt += unicode(stringsSpker.get(i));
}
talkName = tempTxt;
CallBackUtil.callBackNotifySpker(speckType, indexGroupId, indexGroupName, talkId, talkName);
}
break;
@ -239,67 +220,14 @@ public class CallBackResolution {
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
String tempTxt = "";
for (int i = 0; i < stringsTalk.size(); i++) {
tempTxt += unicode(stringsTalk.get(i));
}
int code=-1;
if(tempTxt.equals("退出临时呼叫")){
code=0;
} else if (tempTxt.startsWith("临时呼叫临时群组")) {
code=1;
} else if (tempTxt.equals("呼出失败")||tempTxt.equals("呼叫失败")) {
code=2;
} else if (tempTxt.equals("呼叫成功")||tempTxt.equals("呼出成功")) {
code=3;
}else{
code=-1;
}
Timber.tag(TAG).i( " 临时讲话 信息通知的指令 " + tempTxt + " code=" + code);
Timber.tag(TAG).i(" 临时讲话 信息通知的指令 " + tempTxt);
CallBackUtil.callBackTempCallNotify(tempTxt);
// 处理临时呼叫页面逻辑
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());
}
} 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();
}
} else if (code == 2) {
// 呼叫失败 关闭临时呼叫页面
// if (com.stand.standapp.TempCallActivity.isOpen()) {
// com.stand.standapp.TempCallActivity.closeWithMessage("呼叫失败");
// } else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
// // 最小化状态下收到失败消息清除状态并通知前端
// com.stand.standapp.TempCallActivity.clearMinimizedState();
// }
}
}
break;
@ -325,7 +253,7 @@ public class CallBackResolution {
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
String userGroupName = "";
for (int i = 0; i < stringsJumpGroup.size(); i++) {
@ -358,7 +286,7 @@ public class CallBackResolution {
String time = strLis[2];
CallBackUtil.callBackNotifyLocationStatus(0, locationMemberId, latitude, longitude, time);
} catch (Exception e) {
Timber.tag(TAG).e( "获取定位Exception-->" + e.getMessage());
Timber.tag(TAG).e(e, "获取定位Exception");
}
} else {
CallBackUtil.callBackNotifyLocationStatus(1, "", 0.0, 0.0, "");
@ -459,47 +387,26 @@ public class CallBackResolution {
break;
}
}catch (Exception e){
Timber.tag(TAG).e("at_OEM_AT_cb Exception " + e.getMessage());
Timber.tag(TAG).e(e, "at_OEM_AT_cb Exception");
}
}
/**
* 判断登录状态冲突, 1min内超过5次状态切换则视为登录冲突
* state
* 0 : 离线
* 1 : 登陆中
* 2 : 登陆成功
* 3 : 注销中
* 说明第一次登录 通知状态 先01再02
* 离线再上线 通知状态 先00再01
* tip1min内会出现40次左右0-1切换
*/
public static boolean isLoginConflict(String state) {
Timber.tag(TAG).d( " isLoginConflict state: " + state + ",oldLoginSate: " + oldLoginSate + ", logNum: " + logNum);
if (oldLoginSate.equals("00") && state.equals("01")) {
Timber.tag(TAG).d( "isLoginConflict first login...");
logNum++;
oldLoginSate = state;
} else if (oldLoginSate.equals("01") && state.equals("00")) {
Timber.tag(TAG).d( "isLoginConflict login state changed...");
logNum++;
oldLoginSate = state;
}
if (1 == logNum) {
offlineTime = System.currentTimeMillis();
}
Timber.tag(TAG).d( "isLoginConflict logNum: " + logNum + ", offlineTime: " + offlineTime);
if (System.currentTimeMillis() - offlineTime <= 60 * 1000) {
if (logNum > 5) {
Timber.tag(TAG).d("isLoginConflict login state changed: " + logNum + " in 1min");
if (logNum > 4) {
if (System.currentTimeMillis() - offlineTime >= 60 * 1000) {
return true;
}
} else {
Timber.tag(TAG).d("isLoginConflict maybe network change, reset...");
logNum = 0;
offlineTime = System.currentTimeMillis();
return false;
}
return false;
}
@ -508,8 +415,6 @@ public class CallBackResolution {
try {
String[] txt = sData.split("\"");
String data = txt[1];
Timber.tag(TAG).d( "at_Play_TTS_cb sData: " + sData);
String ngx = "\\u";
List<String> strings = new ArrayList<>();
@ -532,45 +437,34 @@ public class CallBackResolution {
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
} finally {
StringBuilder sb = new StringBuilder();
String tempTxt = "";
for (int k = 0; k < strings.size(); k++) {
sb.append(unicode(strings.get(k)));
tempTxt += unicode(strings.get(k));
}
String tempTxt = sb.toString();
Timber.tag(TAG).i("at_Play_TTS_cb " + tempTxt);
if (tempTxt.contains("已登录")) {
String showName = tempTxt.substring(3, tempTxt.length());
CallBackUtil.callBackLogin(true, showName);
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
java.util.concurrent.ScheduledExecutorService executor = java.util.concurrent.Executors.newScheduledThreadPool(1);
Runnable task = () -> {
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
SendAtUtil.sendUDP();
SendAtUtil.sendTCP();
};
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
} else if (tempTxt.contains("账号已更新")) {
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
executor.scheduleAtFixedRate(task, 0, 40, java.util.concurrent.TimeUnit.SECONDS);
} else if (tempTxt.contains("账号或密码错误")) {
CallBackUtil.callBackLogin(false, "");
Timber.tag(TAG).e("Account/password error, showing dialog. TTS: '%s'", tempTxt);
com.stand.standapp.MainActivity.showPttUpdateDialog("您的 PTT 账号信息账号或密码错误。\n请点击确认重启应用。","账号或密码错误");
}else if (tempTxt.contains("请配置账号")) {
Timber.tag(TAG).w("TTS reports no account configured: '%s'", tempTxt);
}
}
} catch (Exception e) {
Timber.tag(TAG).e(e, "Decode hex failed");
e.printStackTrace();
}
}
public static String unicode(String str) {
Pattern pattern = Pattern.compile("(\\\\u(\\p{XDigit}{4}))");
Matcher matcher = pattern.matcher(str);
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("(\\\\u(\\p{XDigit}{4}))");
java.util.regex.Matcher matcher = pattern.matcher(str);
char ch;
while (matcher.find()) {
String group = matcher.group(2);
@ -586,13 +480,9 @@ public class CallBackResolution {
StringBuilder str = new StringBuilder();
for (int i = 0; i < t; i++) {
String s = hex.substring(i * 6, (i + 1) * 6);
// 高位需要补上00再转
String s1 = s.substring(2, 4) + "00";
// 低位直接转
String s2 = s.substring(4);
// 将16进制的string转为int
int n = Integer.valueOf(s1, 16) + Integer.valueOf(s2, 16);
// 将int转换为字符
char[] chars = Character.toChars(n);
str.append(new String(chars));
}

View File

@ -1,7 +1,7 @@
package com.example.kingway.ptt;
import timber.log.Timber;
import android.os.Handler;
import android.util.Log;
import java.util.ArrayList;
import java.util.List;
@ -18,7 +18,7 @@ public class CallBackUtil {
* name 用户名
*/
public static void callBackLogin(boolean loginState, String name) {
Timber.tag(TAG).d("callBackLogin loginState=%s name=%s", loginState, name);
Log.i(TAG, "callBackLogin loginState " + loginState + " name " + name);
}
/**
@ -34,7 +34,7 @@ public class CallBackUtil {
* loginId 用户ID登录成功id有效
*/
public static void callBackLoinState(String state, String loginId) {
Timber.tag(TAG).d("callBackLoinState state=%s loginId=%s", state, loginId);
Log.d(TAG, "callBackLoinState state: " + state + ", loginId: " + loginId);
}
/**
@ -43,14 +43,13 @@ public class CallBackUtil {
* false登录不冲突
* */
public static void callBackLoginConflict(boolean loginConflict) {
Timber.tag(TAG).d("callBackLoginConflict: %s", loginConflict);
Log.d(TAG, "callBackLoginConflict: " + loginConflict);
}
/**
* 登录账号有视频功能通知
*/
public static boolean callBackHaveVideo() {
Timber.tag(TAG).d("callBackHaveVideo invoked");
return true;
}
@ -61,7 +60,7 @@ public class CallBackUtil {
* state true-成功 false-失败
*/
public static void callBackOnGroupSuccess(boolean state) {
Timber.tag(TAG).d("callBackOnGroupSuccess state=%s", state);
}
/**
@ -69,19 +68,7 @@ public class CallBackUtil {
* state true-成功 false-失败
*/
public static void callBackOnMemberSuccess(boolean state, List<GroupMemberInfoDto> dtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
if (dtos != null && state) {
for (GroupMemberInfoDto dto : dtos) {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("memberId", dto.getMemberId());
obj.put("memberName", dto.getGmemberName());
obj.put("status", dto.getStatus());
arr.put(obj);
}
}
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
@ -89,16 +76,8 @@ public class CallBackUtil {
* state true-成功 false-失败
*/
public static void callBackSpeakPlaySuccess(boolean state) {
Timber.tag(TAG).d("callBackSpeakPlaySuccess state=%s", state);
if (state) {
AudioRecordManager.getInstance().startRecord();
Timber.tag(TAG).d("AudioRecord started");
}
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("success", state);
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakResult', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
Log.d(TAG, "callBackSpeakPlaySuccess state: " + state);
if (state) AudioRecordManager.getInstance().startRecord();
}
/**
@ -106,11 +85,6 @@ public class CallBackUtil {
* state true-成功 false-失败
*/
public static void callBackSpeakReleaseSuccess(boolean state) {
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("success", state);
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakEnd', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
@ -120,26 +94,16 @@ public class CallBackUtil {
* groupNo 表示此组为第几个组
* groupCount 表示此组用户成员数
*/
public static void callBackGroupInfo(String groupId, String groupName, int groupNo, int groupCount) {
Timber.tag(TAG).d("callBackGroupInfo groupId=%s name=%s no=%d count=%d", groupId, groupName, groupNo, groupCount);
}
public static void callBackGroupInfo(String groupId, String groupName, int groupNo, int groupCount) {}
/**
* 返回群组集合信息
* GroupInfoDto 群组信息
* */
public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
for (GroupInfoDto dto : groupInfoDtos) {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("groupId", dto.getGroupId());
obj.put("groupName", dto.getGroupName());
obj.put("groupNo", dto.getGroupNo());
arr.put(obj);
for (GroupInfoDto groupInfoDto : groupInfoDtos) {
Log.d(TAG, "callBackGroupInfo: " + groupInfoDto.toString());
}
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
@ -148,50 +112,44 @@ public class CallBackUtil {
* talkId 讲话用户ID
* talkName 讲话用户名字
* */
public static void callBackNotifySpker ( int speckType, String groupId, String groupName, String talkId, String talkName){
Timber.tag(TAG).d("callBackNotifySpker speckType=%d groupId=%s talkId=%s talkName=%s", speckType, groupId, talkId, talkName);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("speckType", speckType);
obj.put("groupId", groupId);
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() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
public static void callBackNotifySpker ( int speckType, String groupId, String
groupName, String talkId, String talkName){
}
public static void callBackNotifyPlayStatus ( int playStatus, int speckType, String groupId, String groupName, String talkId, String talkName){
Timber.tag(TAG).d("callBackNotifyPlayStatus playStatus=%d talkId=%s", playStatus, talkId);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("playStatus", playStatus);
obj.put("speckType", speckType);
obj.put("groupId", groupId);
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() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
/**
* 对方讲话通知
* playStatus 1表示开始讲话, 0 表示结束讲话
* speckType 自己讲话状态 0 表示自己无法讲话 1 表示自己可以中断讲话人的讲话可以进行强插讲话
* groupId 所在群组Id
* groupName 所在群组名称
* talkId 讲话用户Id
* talkName 讲话用户名称
* */
public static void callBackNotifyPlayStatus ( int playStatus, int speckType, String
groupId, String groupName, String talkId, String talkName){
}
/**
*临时呼叫信息通知
* tempCallNotify
* 临时呼叫xxx表示被单呼呼叫名xxx
* 退出临时呼叫表示退出单呼
* 呼叫成功表示单呼成功
* 呼叫失败表示单呼失败
* */
public static void callBackTempCallNotify (String tempCallNotify){
Timber.tag(TAG).d("callBackTempCallNotify msg=%s", tempCallNotify);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("msg", tempCallNotify);
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('TempCall', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
* 用户进入群组信息通知
* userGroupId 用户所在的群组ID
* userGroupName 用户所在的群组名称
* */
public static void callBackNotifyUpdateGroup (String userGroupId, String userGroupName){
Timber.tag(TAG).d("callBackNotifyUpdateGroup groupId=%s groupName=%s", userGroupId, userGroupName);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("groupId", userGroupId);
obj.put("groupName", userGroupName);
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('UpdateGroup', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
@ -202,17 +160,9 @@ public class CallBackUtil {
* longitude 经度
* time 时间
*/
public static void callBackNotifyLocationStatus ( int status, String userId,double latitude, double longitude, String time){
Timber.tag(TAG).d("callBackNotifyLocationStatus status=%d userId=%s lat=%f lng=%f", status, userId, latitude, longitude);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("status", status);
obj.put("userId", userId);
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() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
public static void callBackNotifyLocationStatus ( int status, String userId,double latitude,
double longitude, String time){
}
/**
@ -220,7 +170,7 @@ public class CallBackUtil {
* status true成功 false失败
* */
public static void callBackNotifyUploadLocation ( boolean status){
Timber.tag(TAG).d("callBackNotifyUploadLocation status=%s", status);
}
/**
@ -232,7 +182,7 @@ public class CallBackUtil {
* inv=11勾选单呼0不勾选单呼
* */
public static void callBackNotifyCodeInfo (String codeInfo){
Timber.tag(TAG).d("callBackNotifyCodeInfo: %s", codeInfo);
}
/**
@ -242,7 +192,7 @@ public class CallBackUtil {
* pwd=111111 账号密码111111
* */
public static void callBackNotifyPwdInfo (String pwdInfo){
Timber.tag(TAG).d("callBackNotifyPwdInfo: %s", pwdInfo);
}
/**
@ -250,7 +200,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackNotifyCodeOpenStatus ( boolean status){
Timber.tag(TAG).d("callBackNotifyCodeOpenStatus status=%s", status);
}
/**
@ -258,7 +208,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackNotifyCodeExiteStatus ( boolean status){
Timber.tag(TAG).d("callBackNotifyCodeExiteStatus status=%s", status);
}
/**
@ -266,7 +216,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackNotifyUploadAgentPwd ( boolean status){
Timber.tag(TAG).d("callBackNotifyUploadAgentPwd status=%s", status);
}
/**
@ -274,7 +224,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackRequestReportAlarm ( boolean status){
Timber.tag(TAG).d("callBackRequestReportAlarm status=%s", status);
}
/**
@ -282,7 +232,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackRequestStopAlarm ( boolean status){
Timber.tag(TAG).d("callBackRequestStopAlarm status=%s", status);
}
/**
@ -292,22 +242,14 @@ public class CallBackUtil {
* longitude 经度
* */
public static void callBackEnotifyAlarm (String memberName,double latitude, double longitude){
Timber.tag(TAG).w("callBackEnotifyAlarm memberName=%s lat=%f lng=%f", memberName, latitude, longitude);
try {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("memberName", memberName);
obj.put("latitude", latitude);
obj.put("longitude", longitude);
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('Alarm', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
/**
* 收到群成员停止警告通知
* */
public static void callBackEnotifyStopAlarm () {
Timber.tag(TAG).d("callBackEnotifyStopAlarm invoked");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('StopAlarm', {});}");
}
/**
@ -315,7 +257,7 @@ public class CallBackUtil {
* status true成功false失败
* */
public static void callBackRequestNfc ( boolean status){
Timber.tag(TAG).d("callBackRequestNfc status=%s", status);
}
}

View File

@ -2,11 +2,7 @@ package com.example.kingway.ptt;
/**
* <pre>
* author : GaryLiang
* e-mail : 595184932@qq.com
* time : 2017/11/21 上午9:09
* desc :
* version: 1.0
* </pre>
*/

View File

@ -4,11 +4,7 @@ import java.io.Serializable;
/**
* <pre>
* author : GaryLiang
* e-mail : 595184932@qq.com
* time : 2017/11/21 上午9:09
* desc : 成员
* version: 1.0
* </pre>
*/

View File

@ -0,0 +1,60 @@
package com.example.kingway.ptt;
import android.content.Context;
import android.util.Log;
public class MyApplication {
private static Context mContext;
public static String pageName = "";
public static pttNative pNative;
private static MyApplication mAppInstance = new MyApplication();
public static void init(Context context) {
if (mContext != null) return;
mContext = context.getApplicationContext();
pageName = mContext.getPackageName();
initPTT();
}
public static MyApplication getAppInstance() {
if (pNative == null && mContext != null) {
initPTT();
}
return mAppInstance;
}
public pttNative getpNative() {
return pNative;
}
public static void initPTT() {
if (pNative != null) return;
try {
pNative = new pttNative();
pNative.pttNativeSetPackageName(pageName);
pNative.pttNativeSetAtCallback();
pNative.pttNativeSetPlayCallback();
pNative.pttNativeSetPalyStartCallback();
pNative.pttNativeSetPlayStopCallback();
pNative.pttNativeSetRecordStartCallback();
pNative.pttNativeSetRecordStopCallback();
pNative.pttNativeSetTTSCallback();
pNative.pttNativePocTaskStart();
Log.i("PTT_INIT", "PTT init success");
} catch (Throwable e) {
Log.e("PTT_INIT", "Failed to init PTT: " + e.getMessage());
}
}
public static void destroy() {
if (pNative != null) {
pNative.destory();
pNative = null;
}
mContext = null;
mAppInstance = new MyApplication();
CallBackResolution.resetPttLoginStatus();
}
}

View File

@ -1,7 +1,5 @@
package com.example.kingway.ptt;
import com.stand.standapp.MyApplication;
import timber.log.Timber;
/**
@ -18,20 +16,6 @@ public class SendAtUtil {
sendRawCmd(cmd);
}
/**
* 获取登录参数
*/
public static void toGetParam() {
send("020000\r\n");
}
/**
* 取消登录接口 (清除服务端/本地登录状态)
*/
public static void toCancelLogin() {
send("050000\r\n");
}
/**
* 账号注销接口
*/
@ -48,7 +32,6 @@ public class SendAtUtil {
* tempCall 是否开启单呼 1开启单呼0关闭单呼
* */
public static void toLogin(String account, String pwd, String ip, int location, int tempCall){
Timber.tag("PTT").i("Login: account=%s, ip=%s, gps=%d, tempCall=%d", account, ip, location, tempCall);
String ipHex = str2HexStr("ip=" + ip + ";");
String accountHex = str2HexStr("id=" + account + ";");
String pwdHex = str2HexStr("pwd=" + pwd + ";");
@ -63,26 +46,22 @@ public class SendAtUtil {
* 按下PTT开始呼叫
* */
public static void speakPlay(){
Timber.tag("PTT").i("PTT speak START (press)");
send("0B0000");
}
/** 松开PTT结束呼叫*/
public static void speakRelease(){
Timber.tag("PTT").i("PTT speak STOP (release)");
AudioRecordManager.getInstance().stopRecord();
send("0C0000");
}
/** 发送TCP包 登录成功后需要定时发送TCP包*/
public static void sendTCP(){
Timber.tag("PTT").v("TCP keepalive sent");
send("570000\r\n");
}
/** 发送UDP包 登录后需要定时发送UDP包*/
public static void sendUDP(){
Timber.tag("PTT").v("UDP keepalive sent");
send("580000\r\n");
}
@ -102,7 +81,6 @@ public class SendAtUtil {
* groupId 十六进制群组Id
* */
public static void jumpGroup(String groupId){
Timber.tag("PTT").i("Jump to group: %s", groupId);
send("090000" + groupId);
}
@ -111,11 +89,6 @@ public class SendAtUtil {
send("0E000000000000\r\n");
}
/** 结束多成员临时呼叫 */
public static void stopTempCall(){
send("5500000100000000\r\n");
}
/**
* 字符串转换成十六进制字符串

View File

@ -1,49 +1,36 @@
package com.example.kingway.ptt;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import android.widget.Button;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import com.hjq.permissions.Permission;
import com.hjq.permissions.XXPermissions;
import com.stand.standapp.R;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
public class pttActivity extends AppCompatActivity {
private Button pttBtn;
private AutoCompleteTextView etIp, etAccount, etPassword, etGroupId;
private static final String PREFS_NAME = "ptt_history";
private static final String KEY_IP_HISTORY = "ip_history";
private static final String KEY_ACCOUNT_HISTORY = "account_history";
private static final String KEY_PASSWORD_HISTORY = "password_history";
private static final String KEY_GROUP_HISTORY = "group_history";
private static final String SEPARATOR = ",";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_ptt);
etIp = findViewById(R.id.etIp);
etAccount = findViewById(R.id.etAccount);
etPassword = findViewById(R.id.etPassword);
etGroupId = findViewById(R.id.etGroupId);
loadHistory();
pttBtn = (Button) findViewById(R.id.pttBtn);
// 初始化 PTT
MyApplication.init(this);
XXPermissions.with(this)
.permission(Permission.RECORD_AUDIO)
.request(null);
@ -61,38 +48,16 @@ public class pttActivity extends AppCompatActivity {
}
});
findViewById(R.id.login).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SendAtUtil.toLogin("gzzdweb01", "123456", "61.243.1.123", 1, 1);
}
});
findViewById(R.id.btnCustomLogin).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String ip = etIp.getText().toString().trim();
String account = etAccount.getText().toString().trim();
String password = etPassword.getText().toString().trim();
String groupId = etGroupId.getText().toString().trim();
if (ip.isEmpty() || account.isEmpty() || password.isEmpty() || groupId.isEmpty()) {
Toast.makeText(pttActivity.this, "请填写所有字段", Toast.LENGTH_SHORT).show();
return;
}
saveToHistory(ip, account, password, groupId);
SendAtUtil.toLogin(account, password, ip, 1, 1);
}
});
findViewById(R.id.login1).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// SendAtUtil.toLogin("gzzdweb01", "123456", "61.243.1.123", 1, 1);
SendAtUtil.toLogin("test004", "123456", "220.154.131.231", 1, 1);
}
});
/** 获取群组 */
findViewById(R.id.button2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
@ -100,6 +65,7 @@ public class pttActivity extends AppCompatActivity {
}
});
/** 获取群成员 */
findViewById(R.id.button3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
@ -107,65 +73,12 @@ public class pttActivity extends AppCompatActivity {
}
});
/** 切换群组 */
findViewById(R.id.btnJumpGroup).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SendAtUtil.jumpGroup("00000001");
}
});
findViewById(R.id.btnCancel1).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SendAtUtil.toCancelLogin();
}
});
findViewById(R.id.btnCancel2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SendAtUtil.toLogout();
}
});
}
private void loadHistory() {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
setupAdapter(etIp, prefs.getString(KEY_IP_HISTORY, ""));
setupAdapter(etAccount, prefs.getString(KEY_ACCOUNT_HISTORY, ""));
setupAdapter(etPassword, prefs.getString(KEY_PASSWORD_HISTORY, ""));
setupAdapter(etGroupId, prefs.getString(KEY_GROUP_HISTORY, ""));
}
private void setupAdapter(AutoCompleteTextView view, String historyStr) {
List<String> items = new ArrayList<>();
if (!historyStr.isEmpty()) {
items.addAll(Arrays.asList(historyStr.split(SEPARATOR)));
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_dropdown_item_1line, items);
view.setAdapter(adapter);
view.setThreshold(1);
}
private void saveToHistory(String ip, String account, String password, String groupId) {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString(KEY_IP_HISTORY, addToHistory(prefs.getString(KEY_IP_HISTORY, ""), ip));
editor.putString(KEY_ACCOUNT_HISTORY, addToHistory(prefs.getString(KEY_ACCOUNT_HISTORY, ""), account));
editor.putString(KEY_PASSWORD_HISTORY, addToHistory(prefs.getString(KEY_PASSWORD_HISTORY, ""), password));
editor.putString(KEY_GROUP_HISTORY, addToHistory(prefs.getString(KEY_GROUP_HISTORY, ""), groupId));
editor.apply();
loadHistory();
}
private String addToHistory(String existing, String newValue) {
LinkedHashSet<String> set = new LinkedHashSet<>();
if (!existing.isEmpty()) {
set.addAll(Arrays.asList(existing.split(SEPARATOR)));
}
set.remove(newValue);
set.add(newValue);
return String.join(SEPARATOR, set);
}
}

View File

@ -5,7 +5,7 @@ import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import timber.log.Timber;
import android.util.Log;
import java.util.ArrayList;
import java.util.List;
@ -15,9 +15,8 @@ public class pttNative {
static {
try {
System.loadLibrary("ptt");
Timber.tag(TAG).d("Native library 'ptt' loaded successfully");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to load ptt library");
Log.e(TAG, "Failed to load ptt library: " + e.getMessage());
}
}
@ -37,8 +36,8 @@ public class pttNative {
//Callback Function for Recv the message from POC Lib
public void OEM_AT_cb(String sData)
{
Timber.tag(TAG).d("Poc2 AT Callback: %s", sData);
int len, i, j=0;
Log.e(TAG,"Poc2 AT Callback: " + sData);
int len, i;
char[] bytes = sData.toCharArray();
List<String> lisHex = new ArrayList<>();
@ -51,15 +50,12 @@ public class pttNative {
lisHex.add(temp.length() == 1 ? "0" + temp : temp);
}
}
} else {
Timber.tag(TAG).w("OEM_AT_cb: unexpected format, len=%d, data=%s", bytes.length, sData);
}
CallBackResolution.at_OEM_AT_cb(lisHex);
}
public void OEM_Play_cb(byte[] bytes){
Timber.tag(TAG).d("OEM_Play_cb: %d bytes", bytes.length);
if (mAudioTrack == null) {
initAudioTrack();
}
@ -72,27 +68,27 @@ public class pttNative {
public void OEM_Play_Start_cb()
{
Timber.tag(TAG).e("Play Start!!!!");
Log.e("Play Start!!!!\r\n","");
}
public void OEM_Play_Stop_cb()
{
Timber.tag(TAG).e("Play Stop!!!!");
Log.e("Play Stop!!!!\r\n","");
}
public void OEM_Record_Start_cb()
{
Timber.tag(TAG).e("Record Start!!!!");
Log.e("Record Start!!!!\r\n","");
}
public void OEM_Record_Stop_cb()
{
Timber.tag(TAG).e("Record Stop!!!!");
Log.e("Record Stop!!!!\r\n","");
}
public void OEM_Play_TTS_cb(String sData)
{
Timber.tag(TAG).e("Play TTS " + sData);
Log.e(TAG , "Play TTS " + sData);
CallBackResolution.at_Play_TTS_cb(sData);
}
@ -116,17 +112,16 @@ public class pttNative {
private native int OEM_Set_Package_Name(String name);
public void pttNativeSendRecordCmd(byte[] data) {
Timber.tag(TAG).d("pttNativeSendRecordCmd: %d bytes", data.length);
OEM_Push_Record_Data(data);
}
public void pttNativeSendAtCmd(String strAtCmd)
{
try {
Timber.tag(TAG).i("AT Send: %s", strAtCmd);
Log.i(TAG,"strAtCmd " + strAtCmd);
OEM_AT_Send(strAtCmd);
} catch (Throwable e) {
Timber.tag(TAG).e( "Native AT Send failed: " + e.getMessage());
Log.e(TAG, "Native AT Send failed: " + e.getMessage());
}
}
@ -135,7 +130,7 @@ public class pttNative {
try {
OEM_Poc_Task_Start();
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Native PocTaskStart failed");
Log.e(TAG, "Native PocTaskStart failed");
}
}
@ -144,7 +139,6 @@ public class pttNative {
try {
return OEM_Set_AT_CallBack("OEM_AT_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set AT callback");
return -1;
}
}
@ -154,7 +148,6 @@ public class pttNative {
try {
return OEM_Set_Play_CallBack("OEM_Play_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set Play callback");
return -1;
}
}
@ -164,7 +157,6 @@ public class pttNative {
try {
return OEM_Set_Play_Start_Callback("OEM_Play_Start_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set PlayStart callback");
return -1;
}
}
@ -174,7 +166,6 @@ public class pttNative {
try {
return OEM_Set_Play_Stop_Callback("OEM_Play_Stop_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set PlayStop callback");
return -1;
}
}
@ -184,7 +175,6 @@ public class pttNative {
try {
return OEM_Set_Record_Start_Callback("OEM_Record_Start_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set RecordStart callback");
return -1;
}
}
@ -194,7 +184,6 @@ public class pttNative {
try {
return OEM_Set_Record_Stop_Callback("OEM_Record_Stop_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set RecordStop callback");
return -1;
}
}
@ -204,7 +193,6 @@ public class pttNative {
try {
return OEM_Set_TTS_Callback("OEM_Play_TTS_cb");
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set TTS callback");
return -1;
}
}
@ -214,25 +202,22 @@ public class pttNative {
try {
return OEM_Set_Package_Name(name);
} catch (Throwable e) {
Timber.tag(TAG).e(e, "Failed to set package callback");
return -1;
}
}
public pttNative() {
Timber.tag(TAG).d("pttNative instance created");
initAudioTrack();
}
private AudioTrack mAudioTrack;
public void initAudioTrack() {
int mAudioMinBufSize = AudioTrack.getMinBufferSize(8000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT);
Timber.tag(TAG).d("AudioTrack init: minBufSize=%d", mAudioMinBufSize);
AudioAttributes audioAttributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) // 通信场景
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) // 内容类型
.setLegacyStreamType(AudioManager.STREAM_MUSIC) // 设置通话流类型
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setLegacyStreamType(AudioManager.STREAM_MUSIC)
.build();
AudioFormat audioFormat = new AudioFormat.Builder()
@ -248,7 +233,7 @@ public class pttNative {
AudioTrack.MODE_STREAM,
AudioManager.AUDIO_SESSION_ID_GENERATE
);
mAudioTrack.setVolume(1.0f);// 设置音量
mAudioTrack.setVolume(1.0f);
}
@ -260,7 +245,7 @@ public class pttNative {
}
mAudioTrack.release();
mAudioTrack = null;
} catch (Exception e) { Timber.tag(TAG).e(e, "AudioTrack destroy failed"); }
} catch (Exception e) {}
}
}

View File

@ -31,7 +31,6 @@ class AndroidInterface(private val activity: MainActivity) {
"pttSpeakPlay" -> { pttSpeakPlay(); "" }
"pttGetLoginStatus" -> pttGetLoginStatus()
"pttGetLoginId" -> pttGetLoginId()
"pttGetCurrentGroup" -> pttGetCurrentGroup()
"pttSpeakRelease" -> { pttSpeakRelease(); "" }
"pttForceRelease" -> { pttForceRelease(); "" }
"pttGetGroupInfo" -> { pttGetGroupInfo(); "" }
@ -81,10 +80,6 @@ class AndroidInterface(private val activity: MainActivity) {
"getMessages" -> getMessages()
"clearMessages" -> { clearMessages(); "" }
"onPageFinished" -> { onPageFinished(); "" }
"showRestartDialog" -> { showRestartDialog(args.optString(0, ""), args.optString(1, "提示")); "" }
"showJsonDialog" -> { showJsonDialog(args.optString(0, "")); "" }
"getTempCallStatus" -> getTempCallStatus()
"maximizeTempCall" -> { maximizeTempCall(); "" }
else -> {
Timber.tag("Bridge").w("Unknown method: $method")
""
@ -107,8 +102,6 @@ class AndroidInterface(private val activity: MainActivity) {
SendAtUtil.speakPlay()
Timber.tag("PTT").d("Speak Play")
MainActivity.executeJs("updatePttTalkStatus(true)")
} else {
Timber.tag("PTT").w("Speak Play ignored: already speaking")
}
}
@ -120,20 +113,7 @@ class AndroidInterface(private val activity: MainActivity) {
return com.example.kingway.ptt.CallBackResolution.getPttLoginId()
}
fun pttGetCurrentGroup(): String {
return try {
val json = JSONObject()
json.put("groupId", com.example.kingway.ptt.CallBackResolution.getCurrentGroupId())
json.put("groupName", com.example.kingway.ptt.CallBackResolution.getCurrentGroupName())
json.toString()
} catch (e: Exception) {
Timber.tag("PTT").e(e, "pttGetCurrentGroup failed")
"{\"groupId\":\"\",\"groupName\":\"\"}"
}
}
fun pttSpeakRelease() {
Timber.tag("PTT").d("pttSpeakRelease called, isSpeaking=%s", isSpeaking)
if (isSpeaking) {
val now = System.currentTimeMillis()
val duration = now - lastSpeakTime
@ -156,42 +136,34 @@ class AndroidInterface(private val activity: MainActivity) {
SendAtUtil.speakRelease()
Timber.tag("PTT").d("Real Speak Release")
MainActivity.executeJs("updatePttTalkStatus(false)")
} else {
Timber.tag("PTT").w("realRelease skipped: already released")
}
}
fun pttForceRelease() {
val wasSpeaking = isSpeaking
isSpeaking = false
SendAtUtil.speakRelease()
Timber.tag("PTT").d("Force Release (wasSpeaking=%s)", wasSpeaking)
Timber.tag("PTT").d("Force Release")
}
fun pttGetGroupInfo() {
Timber.tag("PTT").d("pttGetGroupInfo called")
SendAtUtil.getGroupInfo()
}
fun pttGetGroupMemberInfo(gid: String) {
Timber.tag("PTT").d("pttGetGroupMemberInfo: gid=%s", gid)
SendAtUtil.getGroupMemberInfo(gid)
}
fun simulatePttConflict() {
Timber.tag("PTT").w("simulatePttConflict: showing conflict dialog via JS bridge")
Handler(Looper.getMainLooper()).post {
MainActivity.showPttConflictDialog()
}
}
fun pttSendAtCmd(cmd: String) {
Timber.tag("PTT").d("pttSendAtCmd: %s", cmd)
SendAtUtil.sendRawCmd(cmd)
}
fun pttJumpGroup(gid: String) {
Timber.tag("PTT").d("pttJumpGroup: gid=%s", gid)
SendAtUtil.jumpGroup(gid)
}
@ -269,10 +241,10 @@ class AndroidInterface(private val activity: MainActivity) {
fun cutPaper(allCut: Boolean) {
try {
if (allCut){
PrinterManager.getFactory()?.PaperAllCut();
}else{
PrinterManager.getFactory()?.PaperCut();
PrinterManager.getFactory()?.let {
if (it.isConnection) {
if (allCut) it.PaperAllCut() else it.PaperCut()
}
}
} catch (e: Throwable) {
Timber.tag("JS_Interface").e(e, "cutPaper failed")
@ -299,12 +271,12 @@ class AndroidInterface(private val activity: MainActivity) {
val zipPath = LogManager.zipLogs(activity)
if (zipPath != null) {
Handler(Looper.getMainLooper()).post {
Toast.makeText(activity.applicationContext, "日志已打包: $zipPath", Toast.LENGTH_LONG).show()
Toast.makeText(activity, "日志已打包: $zipPath", Toast.LENGTH_LONG).show()
}
return zipPath
} else {
Handler(Looper.getMainLooper()).post {
Toast.makeText(activity.applicationContext, "暂无日志或打包失败", Toast.LENGTH_SHORT).show()
Toast.makeText(activity, "暂无日志或打包失败", Toast.LENGTH_SHORT).show()
}
return ""
}
@ -313,7 +285,7 @@ class AndroidInterface(private val activity: MainActivity) {
fun uploadLogs() {
LogManager.uploadLogs(activity) { success, message ->
Handler(Looper.getMainLooper()).post {
Toast.makeText(activity.applicationContext, if (success) "日志上传成功" else "上传失败: $message", Toast.LENGTH_SHORT).show()
Toast.makeText(activity, if (success) "日志上传成功" else "上传失败: $message", Toast.LENGTH_SHORT).show()
}
}
}
@ -324,7 +296,7 @@ class AndroidInterface(private val activity: MainActivity) {
*/
fun printPage() {
Handler(Looper.getMainLooper()).post {
Toast.makeText(activity.applicationContext, "GeckoView 暂不支持系统打印,请使用热敏打印功能", Toast.LENGTH_SHORT).show()
Toast.makeText(activity, "GeckoView 暂不支持系统打印,请使用热敏打印功能", Toast.LENGTH_SHORT).show()
}
}
@ -334,7 +306,7 @@ class AndroidInterface(private val activity: MainActivity) {
Timber.tag("Print").d("收到 H5 原始打印数据: $data")
Handler(Looper.getMainLooper()).post {
try {
Toast.makeText(activity.applicationContext, "H5指令打印: $data", Toast.LENGTH_SHORT).show()
Toast.makeText(activity, "H5指令打印: $data", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Timber.tag("Print").e(e, "Toast显示失败")
}
@ -370,7 +342,6 @@ class AndroidInterface(private val activity: MainActivity) {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
packageInfo.versionName ?: "1.0.0"
} catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getVersionName failed")
"1.0.0"
}
}
@ -396,7 +367,6 @@ class AndroidInterface(private val activity: MainActivity) {
json.toString()
} catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")
"{ \"appName\": \"消防值守台\", \"versionName\": \"1.0.0\" }"
}
}
@ -419,7 +389,6 @@ class AndroidInterface(private val activity: MainActivity) {
val prefs = activity.getSharedPreferences("msg_records", Context.MODE_PRIVATE)
prefs.getString("history", "") ?: ""
} catch (e: Exception) {
Timber.tag("Storage").e(e, "getMessages failed")
""
}
}
@ -428,7 +397,7 @@ class AndroidInterface(private val activity: MainActivity) {
try {
activity.getSharedPreferences("msg_records", Context.MODE_PRIVATE).edit().clear().apply()
Handler(Looper.getMainLooper()).post {
Toast.makeText(activity.applicationContext, "记录已清空", Toast.LENGTH_SHORT).show()
Toast.makeText(activity, "记录已清空", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Timber.tag("Storage").e(e, "清空记录异常")
@ -440,67 +409,9 @@ class AndroidInterface(private val activity: MainActivity) {
try {
activity.cancelTimeoutTimer()
activity.findViewById<android.view.View>(R.id.loading_layout)?.visibility = android.view.View.GONE
// 正式界面模式下,页面加载完成后传递登录数据给前端
val isOfficial = activity.isOfficialMode()
val data = activity.getLoginData()
Timber.tag("AndroidInterface").d("onPageFinished: isOfficial=$isOfficial, data=$data")
if (isOfficial && data.isNotEmpty()) {
val escaped = data.replace("'", "\\'").replace("\n", "\\n")
com.stand.standapp.MainActivity.executeJs("if(window.appLogin){window.appLogin('$escaped');}")
Timber.tag("AndroidInterface").d("Called appLogin")
}
} catch (e: Exception) {
Timber.tag("UI").e(e, "隐藏加载布局失败")
}
}
}
fun showRestartDialog(message: String, title: String) {
Timber.tag("AndroidInterface").w("showRestartDialog from JS: title=%s msg=%s", title, message)
Handler(Looper.getMainLooper()).post {
com.stand.standapp.MainActivity.showPttUpdateDialog(message, title)
}
}
fun showJsonDialog(jsonStr: String) {
Handler(Looper.getMainLooper()).post {
try {
val formatted = try {
val obj = org.json.JSONObject(jsonStr)
obj.toString(2)
} catch (_: Exception) {
jsonStr
}
androidx.appcompat.app.AlertDialog.Builder(activity)
.setTitle("JSON 数据")
.setMessage(formatted)
.setPositiveButton("确认", null)
.show()
} catch (e: Exception) {
Timber.tag("UI").e(e, "showJsonDialog error")
}
}
}
fun getTempCallStatus(): String {
return try {
val json = JSONObject()
json.put("isOpen", TempCallActivity.isOpen())
json.put("isMinimized", TempCallActivity.isMinimizedState())
json.toString()
} catch (e: Exception) {
"{\"isOpen\":false,\"isMinimized\":false}"
}
}
fun maximizeTempCall() {
Handler(Looper.getMainLooper()).post {
try {
TempCallActivity.maximize()
} catch (e: Exception) {
Timber.tag("TempCall").e(e, "maximizeTempCall error")
}
}
}
}

View File

@ -10,7 +10,6 @@ object AppConfig {
private const val KEY_REMEMBER_LOGIN = "remember_login"
private const val KEY_USERNAME = "saved_username"
private const val KEY_PASSWORD = "saved_password"
private const val KEY_ACCESS_TOKEN = "access_token"
// 默认配置
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
@ -69,12 +68,4 @@ object AppConfig {
fun setSavedPassword(context: Context, password: String) {
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
}
fun getAccessToken(context: Context): String {
return getPrefs(context).getString(KEY_ACCESS_TOKEN, "") ?: ""
}
fun setAccessToken(context: Context, token: String) {
getPrefs(context).edit().putString(KEY_ACCESS_TOKEN, token).apply()
}
}

View File

@ -3,7 +3,6 @@ package com.stand.standapp
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Process
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
@ -32,7 +31,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)
@ -56,30 +54,18 @@ class LoginActivity : AppCompatActivity() {
val password = etPassword.text.toString().trim()
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(applicationContext, "请输入账号和密码", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "请输入账号和密码", Toast.LENGTH_SHORT).show()
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)
performLogin(username, password, cbRemember.isChecked)
}
// 4. 退出按钮
btnExit.setOnClickListener {
com.stand.standapp.utils.AppKiller.killApp(this)
pttLogoutAndDestroy()
finish()
}
// 5. 彩蛋:点击底部版权 10 次进入开发者模式
@ -97,16 +83,15 @@ class LoginActivity : AppCompatActivity() {
if (clickCount >= 10) {
clickCount = 0
Toast.makeText(applicationContext, "开发者模式已激活", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "开发者模式已激活", Toast.LENGTH_SHORT).show()
startActivity(Intent(this, DeveloperActivity::class.java))
} else if (clickCount > 5) {
Toast.makeText(applicationContext, "再点击 ${10 - clickCount} 次进入调试模式", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "再点击 ${10 - clickCount} 次进入调试模式", Toast.LENGTH_SHORT).show()
}
}
}
private fun performLogin(username: String, password: String, remember: Boolean, officialMode: Boolean) {
Timber.tag("Login").d("开始登录: username=%s, server=%s", username, AppConfig.getServerUrl(this))
private fun performLogin(username: String, password: String, remember: Boolean) {
val serverUrl = AppConfig.getServerUrl(this)
val loginUrl = "$serverUrl/api/auth/login"
@ -129,10 +114,9 @@ class LoginActivity : AppCompatActivity() {
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Timber.tag("Login").e(e, "登录网络请求失败")
runOnUiThread {
loadingDialog.dismiss()
Toast.makeText(applicationContext, "网络异常: ${e.message}", Toast.LENGTH_SHORT).show()
Toast.makeText(this@LoginActivity, "网络异常: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
@ -153,23 +137,32 @@ class LoginActivity : AppCompatActivity() {
AppConfig.setSavedPassword(this@LoginActivity, "")
}
// 提取登录数据
val data = json.optJSONObject("data")
val loginData = data?.toString() ?: "{}"
val accessToken = data?.optString("access_token", "") ?: ""
AppConfig.setAccessToken(this@LoginActivity, accessToken)
Toast.makeText(this@LoginActivity, "登录成功", Toast.LENGTH_SHORT).show()
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
// 获取独立的 PTT 账号并初始化
Timber.tag("PTT").d("开始PTT初始化流程: username=%s, hasToken=%b", username, accessToken.isNotEmpty())
initPttWithAccount(username, accessToken)
// 1. PTT 引擎初始化并异步登录 (避免阻塞 UI)
try {
com.example.kingway.ptt.MyApplication.init(this@LoginActivity)
com.example.kingway.ptt.SendAtUtil.toLogout()
} catch (e: Exception) {
Timber.tag("PTT").e(e, "PTT init error")
}
Thread {
try {
Thread.sleep(500)
runOnUiThread {
Toast.makeText(this@LoginActivity, "PTT初始化中", Toast.LENGTH_SHORT).show()
}
Thread.sleep(1500)
com.example.kingway.ptt.SendAtUtil.toLogin("gzzdweb01", "123456", "61.243.1.123", 1, 1)
} catch (e: Exception) {
Timber.tag("PTT").e(e, "PTT login error")
}
}.start()
// 2. 启动硬件 GPIO 监听
try {
com.stand.standapp.utils.GpioManager.init(this@LoginActivity)
com.stand.standapp.utils.GpioManager.startListening()
Timber.tag("GPIO").d("GPIO初始化并启动监听成功")
} catch (e: Exception) {
Timber.tag("GPIO").e(e, "GPIO init error")
}
@ -177,23 +170,16 @@ class LoginActivity : AppCompatActivity() {
// 3. 初始化打印机 (异步)
PrinterManager.init(this@LoginActivity)
// 4. 检查是否有临时群组,有则打开临时呼叫页面
checkTempGroup(username, data)
// 5. 启动 MainActivity传递登录模式和数据
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.putExtra("official_mode", officialMode)
intent.putExtra("login_data", loginData)
startActivity(intent)
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
} else {
val msg = json.optString("msg", "登录失败")
Toast.makeText(applicationContext, msg, Toast.LENGTH_SHORT).show()
Toast.makeText(this@LoginActivity, msg, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Timber.tag("Login").e(e, "解析响应失败")
Toast.makeText(applicationContext, "服务器响应异常", Toast.LENGTH_SHORT).show()
Toast.makeText(this@LoginActivity, "服务器响应异常", Toast.LENGTH_SHORT).show()
}
}
}
@ -217,7 +203,7 @@ class LoginActivity : AppCompatActivity() {
btnReset.setOnClickListener {
etServerUrl.setText(AppConfig.DEFAULT_SERVER_URL)
etSerialPort.setText(AppConfig.DEFAULT_SERIAL_PORT)
Toast.makeText(applicationContext, "已重置数值 (待保存)", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "已重置数值 (待保存)", Toast.LENGTH_SHORT).show()
}
dialogView.findViewById<TextView>(R.id.btn_cancel).setOnClickListener {
@ -236,7 +222,7 @@ class LoginActivity : AppCompatActivity() {
AppConfig.setServerUrl(this, serverUrl)
AppConfig.setSerialPort(this, serialPort)
Toast.makeText(applicationContext, "参数已生效", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "参数已生效", Toast.LENGTH_SHORT).show()
dialog.dismiss()
}
@ -254,135 +240,25 @@ class LoginActivity : AppCompatActivity() {
.setTitle("提示")
.setMessage("确定要退出应用吗?")
.setPositiveButton("确定") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(this)
pttLogoutAndDestroy()
finish()
}
.setNegativeButton("取消", null)
.show()
}
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) {
private fun pttLogoutAndDestroy() {
try {
val body = response.body?.string() ?: return
val json = JSONObject(body)
val dataObj = json.optJSONObject("data")
val list = dataObj?.optJSONArray("data") ?: return
for (i in 0 until list.length()) {
val group = list.optJSONObject(i) ?: continue
val gtype = group.optString("gtype", "")
if (gtype == "2") {
val gname = group.optString("gname", "临时群组")
Timber.tag("TempGroup").d("发现临时群组: $gname")
// 打开临时呼叫页面
val appCtx = com.stand.standapp.MyApplication.getAppInstance()
val intent = android.content.Intent(
appCtx,
TempCallActivity::class.java
)
intent.putExtra("group_name", gname)
intent.addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
appCtx.startActivity(intent)
break
}
}
} catch (e: Exception) {
Timber.tag("TempGroup").e(e, "解析临时群组响应失败")
}
}
})
}
private fun initPttWithAccount(username: String, accessToken: String) {
if (accessToken.isEmpty()) {
Timber.tag("PTT").w("accessToken为空使用默认PTT密码: username=%s", username)
doPttInit(username, "123456")
return
}
val serverUrl = AppConfig.getServerUrl(this)
val url = "$serverUrl/api/poc-card/account/$username"
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("PTT").w(e, "获取 PTT 账号失败,使用默认账号")
doPttInit(username, "123456")
}
override fun onResponse(call: Call, response: Response) {
try {
val body = response.body?.string() ?: ""
val json = JSONObject(body)
if (json.optInt("code") == 0) {
val data = json.optJSONObject("data")
val pttUsername = data?.optString("cardNumber", username) ?: username
val pttPassword = data?.optString("pocPassword", "123456") ?: "123456"
Timber.tag("PTT").d("获取到服务器PTT账号: cardNumber=%s", pttUsername)
doPttInit(pttUsername, pttPassword)
} else {
Timber.tag("PTT").w("获取PTT账号失败, code=%d", json.optInt("code"))
doPttInit(username, "123456")
}
} catch (e: Exception) {
Timber.tag("PTT").e(e, "解析 PTT 账号失败")
doPttInit(username, "123456")
}
}
})
}
private fun doPttInit(pttUsername: String, pttPassword: String) {
Timber.tag("PTT").d("PTT初始化开始: username=%s", pttUsername)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(applicationContext, "PTT初始化中...", Toast.LENGTH_SHORT).show()
}
// 1. PTT 引擎强制重置后登录 (销毁旧 session确保干净状态)
try {
Timber.tag("PTT").d("发送PTT取消登录指令")
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
Timber.tag("PTT").d("发送PTT注销指令")
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("LoginActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("PTT").e(e, "PTT init error")
Timber.tag("LoginActivity").e(e, "PTT logout failed")
}
// 使用 Handler 替代硬编码 Thread.sleep避免阻塞或随意开辟线程
Timber.tag("PTT").d("等待1500ms以确保旧session完全清理")
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try {
Timber.tag("PTT").d("发送PTT登录指令: username=%s, ip=%s", pttUsername, "220.154.131.231")
com.example.kingway.ptt.SendAtUtil.toLogin(pttUsername, pttPassword, "220.154.131.231", 1, 1)
com.example.kingway.ptt.MyApplication.destroy()
Timber.tag("LoginActivity").i("PTT native destroyed")
} catch (e: Exception) {
Timber.tag("PTT").e(e, "PTT login error")
}
}, 1500)
Timber.tag("LoginActivity").e(e, "PTT destroy failed")
}
}
}

View File

@ -4,13 +4,11 @@ import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import android.widget.FrameLayout
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSessionSettings
import org.mozilla.geckoview.GeckoView
import org.mozilla.geckoview.StorageController.ClearFlags
import org.mozilla.geckoview.WebRequestError
import timber.log.Timber
@ -19,21 +17,12 @@ class MainActivity : AppCompatActivity() {
companion object {
private var instance: MainActivity? = null
// 防止对话框堆叠
@Volatile
private var isConflictDialogShowing = false
// 待显示的弹窗instance 为 null 时暂存)
@Volatile
private var pendingDialog: Pair<String, String>? = null
// 待执行的 JS 代码(由 bridge.js 轮询获取)
@Volatile
private var pendingJsCode: String? = null
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
@ -49,7 +38,6 @@ class MainActivity : AppCompatActivity() {
// 硬件按键触发
@JvmStatic
fun onHardwarePttDown() {
Timber.tag("MainActivity").d("onHardwarePttDown triggered")
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakPlay()
executeJs("onHardwarePttDown()")
@ -58,7 +46,6 @@ class MainActivity : AppCompatActivity() {
@JvmStatic
fun onHardwarePttUp() {
Timber.tag("MainActivity").d("onHardwarePttUp triggered")
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakRelease()
executeJs("onHardwarePttUp()")
@ -67,55 +54,28 @@ class MainActivity : AppCompatActivity() {
@JvmStatic
fun showPttConflictDialog() {
Timber.tag("PTT").w("showPttConflictDialog: account conflict detected")
if (isConflictDialogShowing) return
isConflictDialogShowing = true
instance?.runOnUiThread {
val ctx = instance ?: return@runOnUiThread
androidx.appcompat.app.AlertDialog.Builder(ctx)
.setTitle("账号冲突")
.setMessage("您的 PTT 账号已在其他设备登录,当前设备已被迫下线。\n请点击确认重启应用")
.setMessage("您的 PTT 账号已在其他设备登录,当前设备已被迫下线。\n请点击确认退出并重新登录")
.setCancelable(false)
.setPositiveButton("确认") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(ctx, restart = true)
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
com.example.kingway.ptt.MyApplication.destroy()
com.stand.standapp.utils.GpioManager.stopListening()
} catch (_: Exception) {}
val pm = ctx.packageManager
val intent = pm.getLaunchIntentForPackage(ctx.packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
ctx.startActivity(intent)
android.os.Process.killProcess(android.os.Process.myPid())
}
.setOnDismissListener { isConflictDialogShowing = false }
.show()
}
}
@JvmStatic
fun showPttUpdateDialog(msg: String, title: String) {
Timber.tag("PTT").w("showPttUpdateDialog: title=%s msg=%s", title, msg)
if (isConflictDialogShowing) return
val ctx = instance
if (ctx == null) {
pendingDialog = title to msg
Timber.tag("PTT").d("Pending dialog stored (instance null): title=%s", title)
return
}
isConflictDialogShowing = true
ctx.runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(ctx)
.setTitle(title)
.setMessage(msg)
.setCancelable(false)
.setPositiveButton("确认") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(ctx, restart = true)
}
.setOnDismissListener { isConflictDialogShowing = false }
.show()
}
}
@JvmStatic
fun showPendingDialogIfNeeded() {
val pending = pendingDialog ?: return
Timber.tag("PTT").d("Flushing pending dialog: %s", pending.second)
pendingDialog = null
showPttUpdateDialog(pending.second, pending.first)
}
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
@ -133,11 +93,8 @@ class MainActivity : AppCompatActivity() {
private var canGoBack = false
private lateinit var geckoView: GeckoView
private var officialMode = false
private var loginData = ""
private val mHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val TIMEOUT_MS = 10000L
private val TIMEOUT_MS = 30000L
private val timeoutRunnable = Runnable {
showErrorPage()
@ -146,12 +103,6 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
showPendingDialogIfNeeded()
// 读取登录模式和数据
officialMode = intent.getBooleanExtra("official_mode", false)
loginData = intent.getStringExtra("login_data") ?: ""
try {
setContentView(R.layout.activity_main)
@ -167,50 +118,22 @@ class MainActivity : AppCompatActivity() {
// 创建 GeckoRuntime
geckoRuntime = createGeckoRuntime()
// 安装桥接扩展
geckoRuntime?.webExtensionController?.installBuiltIn(
"resource://android/assets/extensions/bridge/"
)
// 创建 AndroidInterface
pttInterface = AndroidInterface(this)
// 创建 GeckoSession 并配置(但不加载页面)
val session = createSession()
geckoSession = session
geckoView.setSession(session)
// 创建 GeckoSession 并配置
createSessionAndLoad()
startTimeoutTimer()
// 安装桥接扩展,等安装完成后再加载页面
geckoRuntime?.webExtensionController?.installBuiltIn(
"resource://android/assets/extensions/bridge/"
)?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread {
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
} else {
"$serverUrl/example/print_demo.html?t=${System.currentTimeMillis()}"
}
Timber.tag("MainActivity").d("Loading URL: $finalUrl (officialMode=$officialMode)")
session.loadUri(finalUrl)
}
}
// 启动时检查更新
UpdateManager(this).checkUpdate()
// 👈 动态注入 Compose 容器,让 FloatingChatWidget 呈现在最上层!
val composeView = androidx.compose.ui.platform.ComposeView(this).apply {
setContent {
com.stand.standapp.ui.chat.FloatingChatWidget()
}
}
findViewById<FrameLayout>(android.R.id.content)?.addView(
composeView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
)
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Fatal Crash")
}
@ -220,17 +143,10 @@ class MainActivity : AppCompatActivity() {
val runtime = GeckoRuntime.getDefault(this)
runtime.settings.aboutConfigEnabled = true
runtime.settings.consoleOutputEnabled = true
// 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
}
return runtime
}
private fun createSession(): GeckoSession {
private fun createSessionAndLoad() {
val session = GeckoSession()
val sessionSettings = session.settings
@ -238,7 +154,7 @@ class MainActivity : AppCompatActivity() {
sessionSettings.allowJavascript = true
sessionSettings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_MOBILE
val runtime = geckoRuntime ?: return session
val runtime = geckoRuntime ?: return
// 设置各种代理
setupDelegates(session)
@ -246,7 +162,16 @@ class MainActivity : AppCompatActivity() {
// 打开会话
session.open(runtime)
return session
geckoSession = session
// 将 session 绑定到 GeckoView
geckoView.setSession(session)
// 加载页面
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = "$serverUrl/example/print_demo.html?t=${System.currentTimeMillis()}"
Timber.tag("MainActivity").d("Loading URL: $finalUrl")
session.loadUri(finalUrl)
}
private fun setupDelegates(session: GeckoSession) {
@ -278,15 +203,6 @@ class MainActivity : AppCompatActivity() {
override fun onPageStop(session: GeckoSession, success: Boolean) {
Timber.tag("MainActivity").d("Page stopped, success: $success")
// 正式界面模式下,页面加载完成后传递登录数据给前端
// if (success && officialMode && loginData.isNotEmpty()) {
// // 延迟 500ms 确保 JS 上下文就绪
// mHandler.postDelayed({
// val escaped = loginData.replace("'", "\\'").replace("\n", "\\n")
// executeJs("if(window.appLogin){window.appLogin('$escaped');}")
// Timber.tag("MainActivity").d("Called appLogin with loginData")
// }, 500)
// }
}
}
@ -309,81 +225,17 @@ class MainActivity : AppCompatActivity() {
val result = handleBridgeCall(payload, prompt.defaultValue ?: "")
return GeckoResult.fromValue(prompt.confirm(result))
}
// 默认处理(显示对话框)
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
val input = android.widget.EditText(this@MainActivity)
input.setText(prompt.defaultValue)
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title ?: "")
.setMessage(prompt.message)
.setView(input)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.confirm(input.text.toString())) }
.setNegativeButton("取消") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
return null
}
override fun onAlertPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.AlertPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setMessage(prompt.message)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
override fun onAuthPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.AuthPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
Timber.tag("WebConsole").d("Alert: ${prompt.message}")
return null
}
override fun onChoicePrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.ChoicePrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
val choices = prompt.choices.map { it.label }.toTypedArray()
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title)
.setSingleChoiceItems(choices, -1) { dialog, which ->
dialog.dismiss()
res.complete(prompt.confirm(prompt.choices[which]))
}
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
override fun onButtonPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.ButtonPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title ?: "提示")
.setMessage(prompt.message)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.confirm(GeckoSession.PromptDelegate.ButtonPrompt.Type.POSITIVE)) }
.setNegativeButton("取消") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
}
}
@ -411,23 +263,19 @@ class MainActivity : AppCompatActivity() {
.setTitle("提示")
.setMessage("确定要退出应用吗?")
.setPositiveButton("确定") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(this)
pttLogoutAndDestroy()
finish()
}
.setNegativeButton("取消", null)
.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")
Timber.tag("MainActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT logout failed")
Timber.tag("MainActivity").e(e, "PTT logout failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
@ -435,27 +283,10 @@ class MainActivity : AppCompatActivity() {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
com.example.kingway.ptt.MyApplication.destroy()
Timber.tag("MainActivity").i("PTT native destroyed")
} 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")
Timber.tag("MainActivity").e(e, "PTT destroy failed")
}
}
@ -490,10 +321,6 @@ class MainActivity : AppCompatActivity() {
mHandler.removeCallbacks(timeoutRunnable)
}
fun isOfficialMode(): Boolean = officialMode
fun getLoginData(): String = loginData
private fun showErrorPage() {
findViewById<android.view.View>(R.id.loading_layout)?.visibility = android.view.View.GONE
findViewById<android.view.View>(R.id.error_layout)?.visibility = android.view.View.VISIBLE

View File

@ -1,109 +1,50 @@
package com.stand.standapp
import android.app.Application
import android.util.Log
import com.example.kingway.ptt.SendAtUtil
import com.example.kingway.ptt.pttNative
import com.stand.standapp.utils.LogManager
import com.example.kingway.ptt.MyApplication as PTTMyApplication
import timber.log.Timber
import java.io.File
class MyApplication : Application() {
// 单例实例
companion object {
private lateinit var mApp: MyApplication
@JvmStatic
fun getAppInstance(): MyApplication {
if (pNative == null) {
initPTT()
}
return mApp
}
@JvmStatic
fun initPTT() {
if (pNative != null) return
ensureCfgDat()
try {
pNative = pttNative()
pNative?.pttNativeSetPackageName(pageName)
pNative?.pttNativeSetAtCallback()
pNative?.pttNativeSetPlayCallback()
pNative?.pttNativeSetPalyStartCallback()
pNative?.pttNativeSetPlayStopCallback()
pNative?.pttNativeSetRecordStartCallback()
pNative?.pttNativeSetRecordStopCallback()
pNative?.pttNativeSetTTSCallback()
pNative?.pttNativePocTaskStart()
Log.i("PTT_INIT", "PTT init success")
} catch (e: Throwable) {
Log.e("PTT_INIT", "Failed to init PTT: ${e.message}")
}
}
var pageName: String = ""
var pNative: pttNative? = null
/**
* 首次安装时从 assets 复制一份有效的 cfg.dat
* 避免 native 读不到配置报 zpoc_init_cfg 13
*/
private fun ensureCfgDat() {
try {
val cfgFile = File("/data/data/$pageName/cfg.dat")
if (cfgFile.exists()) return
val app = mApp
app.assets.open("cfg.dat").use { input ->
cfgFile.outputStream().use { output ->
input.copyTo(output)
}
}
Timber.tag("PTT_INIT").i("Copied cfg.dat from assets")
} catch (e: Exception) {
Timber.tag("PTT_INIT").w(e, "Failed to copy cfg.dat from assets")
}
}
}
/**
* 获取 pttNative 实例非空
*/
fun getpNative(): pttNative? {
return pNative
private var instance: MyApplication? = null
fun getAppInstance(): MyApplication = instance!!
}
override fun onCreate() {
super.onCreate()
mApp = this
pageName = packageName
com.stand.standapp.utils.AppKiller.setupUncaughtExceptionHandler(this)
initPTT()
// 第一步:初始化日志
LogManager.init(this)
// 初始化 xCrash 以捕获 Native (SO) 崩溃
// 【核心】初始化 xCrash 以捕获 Native (SO) 崩溃
xcrash.XCrash.init(this, xcrash.XCrash.InitParameters().apply {
setAppVersion(packageManager.getPackageInfo(packageName, 0).versionName)
setLogDir(getExternalFilesDir("tombstones")?.absolutePath)
setNativeDumpAllThreads(true)
// Java 崩溃回调
setJavaCallback { logPath, emergency ->
LogManager.onCrashDetected(logPath, "Java-xCrash", null)
}
// Native 崩溃回调
setNativeCallback { logPath, emergency ->
LogManager.onCrashDetected(logPath, "Native-xCrash", null)
android.util.Log.e("MyApplication", "NATIVE CRASH DETECTED at: $logPath")
}
})
super.onCreate()
instance = this
Timber.tag("MyApplication").i("=== Application Starting ===")
// try {
// // 第二步:异步或保护性初始化 PTT
// PTTMyApplication.init(this)
// Timber.tag("MyApplication").i("PTT Module init sequence finished")
// } catch (e: Throwable) {
// Timber.tag("MyApplication").e(e, "PTT Module CRASH during init")
// }
}
}

View File

@ -1,285 +0,0 @@
package com.stand.standapp
import android.animation.ObjectAnimator
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.example.kingway.ptt.SendAtUtil
import timber.log.Timber
class TempCallActivity : AppCompatActivity() {
private var isSpeaking = false
private var lastSpeakTime = 0L
private val MIN_SPEAK_DURATION = 200L
private val handler = Handler(Looper.getMainLooper())
private var pulseAnimator: ObjectAnimator? = null
companion object {
@Volatile
var instance: TempCallActivity? = null
private set
@Volatile
var isMinimized = false
private set
// 存储当前临时群组名称,用于恢复时传递
@Volatile
var currentGroupName: String = "临时会话"
@JvmStatic
fun isOpen(): Boolean = instance != null
@JvmStatic
fun isMinimizedState(): Boolean = isMinimized
@JvmStatic
fun maximize() {
if (isMinimized) {
isMinimized = false
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)
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent)
}
}
@JvmStatic
fun closeWithMessage(message: String) {
val appCtx = com.stand.standapp.MyApplication.getAppInstance()
instance?.runOnUiThread {
Toast.makeText(appCtx, message, Toast.LENGTH_SHORT).show()
instance?.finish()
} ?: run {
// Activity 已销毁但状态还在,清除状态
clearMinimizedState()
}
}
@JvmStatic
fun clearMinimizedState() {
isMinimized = false
currentGroupName = "临时会话"
MainActivity.executeJs("onTempCallStateChanged('destroyed')")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 全屏覆盖 + 常亮 + 锁屏显示
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
setContentView(R.layout.activity_temp_call)
instance = this
isMinimized = false
val groupName = intent.getStringExtra("group_name") ?: "临时会话"
currentGroupName = groupName
findViewById<TextView>(R.id.tv_group_name).text = groupName
setupMinimizeButton()
setupPttButton()
setupHangupButton()
startPulseAnimation()
}
override fun onResume() {
super.onResume()
isMinimized = false
}
override fun onDestroy() {
super.onDestroy()
pulseAnimator?.cancel()
if (instance == this) {
instance = null
// 如果不是最小化状态,说明是真正结束,清除所有状态
if (!isMinimized) {
currentGroupName = "临时会话"
}
// 通知 web 页面状态变化
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
}
}
// 拦截返回键
override fun onBackPressed() {
showMinimizeDialog()
}
// 不要在 recent tasks 中显示
override fun finish() {
super.finish()
overridePendingTransition(0, android.R.anim.fade_out)
}
fun updateGroupName(name: String) {
findViewById<TextView>(R.id.tv_group_name)?.text = name
}
private fun startPulseAnimation() {
val pulse = findViewById<ImageView>(R.id.iv_pulse) ?: return
pulseAnimator = ObjectAnimator.ofFloat(pulse, "alpha", 1f, 0.3f).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.REVERSE
start()
}
}
private fun setupPttButton() {
val btnPtt = findViewById<Button>(R.id.btn_ptt)
val viewGlow = findViewById<View>(R.id.view_glow)
val startSpeak = {
if (!isSpeaking) {
isSpeaking = true
lastSpeakTime = System.currentTimeMillis()
SendAtUtil.speakPlay()
btnPtt.text = "正在\n说话..."
btnPtt.setBackgroundResource(R.drawable.circle_ptt_speaking)
viewGlow.setBackgroundResource(R.drawable.circle_ptt_glow_active)
findViewById<TextView>(R.id.tv_talk_info).text = "正在通话"
findViewById<TextView>(R.id.tv_talk_status).apply {
visibility = View.VISIBLE
text = "● 对讲中"
setTextColor(0xFFEF4444.toInt())
}
Timber.tag("TempCall").d("Speak Play")
}
}
val stopSpeak = {
if (isSpeaking) {
val now = System.currentTimeMillis()
val duration = now - lastSpeakTime
if (duration < MIN_SPEAK_DURATION) {
handler.postDelayed({ realRelease(btnPtt, viewGlow) }, MIN_SPEAK_DURATION - duration)
} else {
realRelease(btnPtt, viewGlow)
}
}
}
btnPtt.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startSpeak()
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
stopSpeak()
true
}
else -> false
}
}
}
private fun realRelease(btnPtt: Button, viewGlow: View) {
if (isSpeaking) {
isSpeaking = false
SendAtUtil.speakRelease()
btnPtt.text = "按住\n说话"
btnPtt.setBackgroundResource(R.drawable.circle_ptt_button)
viewGlow.setBackgroundResource(R.drawable.circle_ptt_glow)
findViewById<TextView>(R.id.tv_talk_info).text = "按下按钮讲话"
findViewById<TextView>(R.id.tv_talk_status).apply {
visibility = View.GONE
}
Timber.tag("TempCall").d("Speak Release")
}
}
private fun setupHangupButton() {
findViewById<Button>(R.id.btn_hangup).setOnClickListener {
try {
SendAtUtil.stopTempCall()
} catch (e: Exception) {
Timber.tag("TempCall").e(e, "stopTempCall error")
}
finish()
}
}
private fun setupMinimizeButton() {
findViewById<View>(R.id.ll_minimize).setOnClickListener {
minimizeAndFinish()
}
}
private fun showMinimizeDialog() {
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("临时呼叫")
.setMessage("是否最小化临时呼叫页面?")
.setPositiveButton("最小化") { _, _ ->
minimizeAndFinish()
}
.setNegativeButton("取消", null)
.show()
}
private fun minimizeAndFinish() {
isMinimized = true
// 通知 web 页面临时呼叫页面已最小化
MainActivity.executeJs("onTempCallStateChanged('minimized')")
// finish 但保持 isMinimized = true
finish()
}
// 由 GpioManager 调用,同步 GPIO 按下状态
fun onGpioPressed() {
val btnPtt = findViewById<Button>(R.id.btn_ptt) ?: return
val viewGlow = findViewById<View>(R.id.view_glow) ?: return
if (!isSpeaking) {
isSpeaking = true
lastSpeakTime = System.currentTimeMillis()
SendAtUtil.speakPlay()
btnPtt.text = "正在\n说话..."
btnPtt.setBackgroundResource(R.drawable.circle_ptt_speaking)
viewGlow.setBackgroundResource(R.drawable.circle_ptt_glow_active)
findViewById<TextView>(R.id.tv_talk_info).text = "正在通话"
findViewById<TextView>(R.id.tv_talk_status).apply {
visibility = View.VISIBLE
text = "● 对讲中"
setTextColor(0xFFEF4444.toInt())
}
}
}
// 由 GpioManager 调用,同步 GPIO 松开状态
fun onGpioReleased() {
val btnPtt = findViewById<Button>(R.id.btn_ptt) ?: return
val viewGlow = findViewById<View>(R.id.view_glow) ?: return
if (isSpeaking) {
val now = System.currentTimeMillis()
val duration = now - lastSpeakTime
if (duration < MIN_SPEAK_DURATION) {
handler.postDelayed({ realRelease(btnPtt, viewGlow) }, MIN_SPEAK_DURATION - duration)
} else {
realRelease(btnPtt, viewGlow)
}
}
}
}

View File

@ -70,7 +70,7 @@ class UpdateManager(private val context: Context) {
try {
val body = response.body?.string() ?: throw IOException("返回内容为空")
if (!response.isSuccessful) {
handler.post { Toast.makeText(context.applicationContext, "更新检查失败: ${response.code}", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "更新检查失败: ${response.code}", Toast.LENGTH_SHORT).show() }
return
}
@ -89,23 +89,23 @@ class UpdateManager(private val context: Context) {
try {
showUpdateDialog(versionName, content, id)
} catch (e: Exception) {
Toast.makeText(context.applicationContext, "显示更新弹窗失败", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "显示更新弹窗失败", Toast.LENGTH_SHORT).show()
}
}
}
} else {
handler.post { Toast.makeText(context.applicationContext, "当前已是最新版本", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "当前已是最新版本", Toast.LENGTH_SHORT).show() }
}
} else {
handler.post { Toast.makeText(context.applicationContext, "当前已是最新版本", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "当前已是最新版本", Toast.LENGTH_SHORT).show() }
}
} else {
val msg = json.optString("msg", "未知错误")
handler.post { Toast.makeText(context.applicationContext, "更新服务提示: $msg", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "更新服务提示: $msg", Toast.LENGTH_SHORT).show() }
}
} catch (e: Exception) {
Timber.tag("UpdateManager").e(e, "解析或处理更新结果失败")
handler.post { Toast.makeText(context.applicationContext, "更新信息解析失败", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "更新信息解析失败", Toast.LENGTH_SHORT).show() }
}
}
})
@ -150,7 +150,7 @@ class UpdateManager(private val context: Context) {
override fun onFailure(call: Call, e: IOException) {
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "连接服务器失败", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "连接服务器失败", Toast.LENGTH_SHORT).show()
}
}
@ -158,7 +158,7 @@ class UpdateManager(private val context: Context) {
if (!response.isSuccessful) {
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "下载失败: ${response.code}", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "下载失败: ${response.code}", Toast.LENGTH_SHORT).show()
}
return
}
@ -169,7 +169,7 @@ class UpdateManager(private val context: Context) {
} catch (e: Exception) {
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "保存失败", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "保存失败", Toast.LENGTH_SHORT).show()
}
}
}
@ -210,13 +210,13 @@ class UpdateManager(private val context: Context) {
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "下载完成,文件已保存到系统下载目录", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "下载完成,文件已保存到系统下载目录", Toast.LENGTH_SHORT).show()
installApk(targetUri)
}
} catch (e: Exception) {
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
}
} finally {
inputStream?.close()
@ -246,7 +246,7 @@ class UpdateManager(private val context: Context) {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
intent.data = Uri.parse("package:" + context.packageName)
context.startActivity(intent)
Toast.makeText(context.applicationContext, "请先授予安装权限", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "请先授予安装权限", Toast.LENGTH_SHORT).show()
return
}
}
@ -267,7 +267,7 @@ class UpdateManager(private val context: Context) {
context.startActivity(intent)
} catch (e: Exception) {
Timber.tag("UpdateManager").e(e, "安装失败")
Toast.makeText(context.applicationContext, "无法自动调起安装,请在系统下载目录中手动安装", Toast.LENGTH_LONG).show()
Toast.makeText(context, "无法自动调起安装,请在系统下载目录中手动安装", Toast.LENGTH_LONG).show()
}
}
}

View File

@ -26,14 +26,9 @@ object PrinterManager {
// 在 Android 系统层面主动提示
mContext?.let {
if (!hasPaper) {
android.widget.Toast.makeText(it.applicationContext, "⚠️ 警告:打印机缺纸!", android.widget.Toast.LENGTH_LONG).show()
} else {
android.widget.Toast.makeText(it.applicationContext, "打印机状态:正常 (有纸)", android.widget.Toast.LENGTH_SHORT).show()
android.widget.Toast.makeText(it, "⚠️ 警告:打印机缺纸!", android.widget.Toast.LENGTH_LONG).show()
}
}
// 通知前端页面
com.stand.standapp.MainActivity.executeJs("if(window.onPaperStateChanged) { window.onPaperStateChanged($hasPaper); }")
}
true
}

View File

@ -1,260 +0,0 @@
package com.stand.standapp.ui.chat
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.stand.standapp.AppConfig
import com.stand.standapp.ui.chat.model.ChatMessage
import com.stand.standapp.ui.chat.model.ChatSession
import com.stand.standapp.ui.chat.repo.AiChatRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val repository = AiChatRepository()
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
private val _currentSessionId = MutableStateFlow<String?>(null)
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.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)
init {
// 👈 将数据加载移到IO线程避免阻塞主线程导致UI卡顿
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
loadCacheFromLocal()
}
}
private fun loadCacheFromLocal() {
try {
val sessionsJson = prefs.getString("cache_sessions", "")
val messagesJson = prefs.getString("cache_messages", "")
if (!sessionsJson.isNullOrBlank()) {
val sessionsArray = JSONArray(sessionsJson)
val restoredSessions = mutableListOf<ChatSession>()
for (i in 0 until sessionsArray.length()) {
val obj = sessionsArray.getJSONObject(i)
restoredSessions.add(
ChatSession(
id = obj.getString("id"),
title = obj.getString("title"),
timestamp = obj.getLong("timestamp")
)
)
}
_sessions.value = restoredSessions
if (restoredSessions.isNotEmpty()) {
_currentSessionId.value = restoredSessions.first().id
}
}
if (!messagesJson.isNullOrBlank()) {
val messagesArray = JSONArray(messagesJson)
val restoredMessages = mutableListOf<ChatMessage>()
val maxMessages = 200 // 👈 限制最多加载200条消息避免大数据解析卡顿
val startIndex = (messagesArray.length() - maxMessages).coerceAtLeast(0)
for (i in startIndex until messagesArray.length()) {
val obj = messagesArray.getJSONObject(i)
restoredMessages.add(
ChatMessage(
id = obj.getString("id"),
sessionId = obj.getString("sessionId"),
role = ChatMessage.Role.valueOf(obj.getString("role")),
content = obj.getString("content"),
isStreaming = false
)
)
}
_messages.value = restoredMessages
}
} catch (e: Exception) {
e.printStackTrace()
}
if (_sessions.value.isEmpty()) {
createNewSession()
}
}
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)
}
// 保存消息列表
val messagesArray = JSONArray()
_messages.value.forEach { msg ->
val obj = JSONObject()
obj.put("id", msg.id)
obj.put("sessionId", msg.sessionId)
obj.put("role", msg.role.name)
obj.put("content", msg.content)
messagesArray.put(obj)
}
prefs.edit()
.putString("cache_sessions", sessionsArray.toString())
.putString("cache_messages", messagesArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
fun cancelActiveStreaming() {
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
activeChatJob = null
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
_messages.value = _messages.value.map { msg ->
if (msg.isStreaming) {
msg.copy(isStreaming = false)
} else msg
}
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
}
fun createNewSession() {
val newSession = ChatSession()
// 👈 限制最多保留10组对话历史超出时删除最旧的会话
val trimmedSessions = if (_sessions.value.size >= 10) {
val sessionsToKeep = _sessions.value.take(9)
// 同时删除最旧会话的消息
val removedSessionIds = _sessions.value.drop(9).map { it.id }.toSet()
_messages.value = _messages.value.filter { it.sessionId !in removedSessionIds }
sessionsToKeep
} else {
_sessions.value
}
_sessions.value = listOf(newSession) + trimmedSessions
_currentSessionId.value = newSession.id
saveCacheToLocal()
}
fun switchSession(sessionId: String) {
_currentSessionId.value = sessionId
}
fun clearAllSessions() {
_sessions.value = emptyList()
_messages.value = emptyList()
_currentSessionId.value = null
prefs.edit().clear().apply()
createNewSession()
}
fun sendMessage(content: String) {
if (_isLoading.value) return // 👈 拦截提问:上一个回答正在流式写时,绝对不允许重复提交!
val sessionId = _currentSessionId.value ?: return
_isLoading.value = true // 👈 锁定发送状态
// 1. 如果是新会话,更新标题
updateSessionTitleIfFirstMessage(sessionId, content)
// 2. 添加用户消息
val userMsg = ChatMessage(sessionId = sessionId, role = ChatMessage.Role.USER, content = content)
// 3. 准备 AI 空消息,处于 streaming 状态
val aiMsgId = UUID.randomUUID().toString()
val aiMsg = ChatMessage(id = aiMsgId, sessionId = sessionId, role = ChatMessage.Role.AI, content = "", isStreaming = true)
_messages.value = _messages.value + userMsg + aiMsg
saveCacheToLocal() // 👈 用户发出消息,存入本地历史记录
// 4. 发起网络请求,流式追加内容
activeChatJob = viewModelScope.launch {
val token = AppConfig.getAccessToken(getApplication())
// 提取当前会话除了刚发出的这条和尚未生成的AI回复之外的历史对话上下文
// 👈 限制最多取最近10轮对话20条消息避免token超限
val currentSessionMsgs = _messages.value
.filter { it.sessionId == sessionId && it.id != userMsg.id && it.id != aiMsgId && !it.isStreaming }
.takeLast(6)
// 👈 使用 JSONArray 构建历史记录,确保特殊字符被正确转义
val historyArray = JSONArray()
currentSessionMsgs.forEach { chatMessage ->
val roleStr = if (chatMessage.role == ChatMessage.Role.USER) "user" else "assistant"
val historyItem = JSONObject()
historyItem.put("role", roleStr)
historyItem.put("content", chatMessage.content)
historyArray.put(historyItem)
}
val historyJson = if (currentSessionMsgs.isEmpty()) "" else historyArray.toString()
repository.streamChat(getApplication(), content, token, historyJson)
.catch { e ->
appendAiMessageChunk(aiMsgId, "\n[请求异常: ${e.message}]")
finalizeAiMessage(aiMsgId)
_isLoading.value = false // 发生异常释放锁
}
.onCompletion {
finalizeAiMessage(aiMsgId)
_isLoading.value = false // 传输完成释放锁
}
.collect { chunk ->
appendAiMessageChunk(aiMsgId, chunk)
}
}
}
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
}
}
private fun finalizeAiMessage(messageId: String) {
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
msg.copy(isStreaming = false)
} else msg
}
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
}
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
val isFirst = _messages.value.none { it.sessionId == sessionId }
if (isFirst) {
val title = if (content.length > 10) content.take(10) + "..." else content
_sessions.value = _sessions.value.map {
if (it.id == sessionId) it.copy(title = title) else it
}
}
}
}

View File

@ -1,147 +0,0 @@
package com.stand.standapp.ui.chat
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.stand.standapp.ui.chat.components.ChatScaffold
import kotlin.math.roundToInt
@Composable
fun FloatingChatWidget() {
var isExpanded by remember { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
val maxWidth = (configuration.screenWidthDp * 0.45f).dp
val maxHeight = (configuration.screenHeightDp * 0.75f).dp
val cardWidthPx = with(density) { maxWidth.toPx() }
val cardHeightPx = with(density) { maxHeight.toPx() }
val fabSizePx = with(density) { 56.dp.toPx() }
val paddingPx = with(density) { 16.dp.toPx() }
val navBarPx = with(density) { 32.dp.toPx() }
// 👈 FAB偏移量全屏幕可拖动
var fabOffsetX by remember { mutableStateOf(0f) }
var fabOffsetY by remember { mutableStateOf(0f) }
// 👈 对话框额外偏移(拖动对话框产生)
var cardDragX by remember { mutableStateOf(0f) }
var cardDragY by remember { mutableStateOf(0f) }
if (isExpanded) {
BackHandler {
isExpanded = false
}
}
Box(modifier = Modifier.fillMaxSize()) {
// FAB - 全屏幕可拖动
if (!isExpanded) {
FloatingActionButton(
onClick = { isExpanded = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
.padding(bottom = 32.dp)
.offset {
IntOffset(
fabOffsetX.roundToInt()
.coerceIn(-(screenWidthPx - fabSizePx - paddingPx * 2).toInt(), 0),
fabOffsetY.roundToInt()
.coerceIn(-(screenHeightPx - fabSizePx - paddingPx * 2 - navBarPx).toInt(), 0)
)
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { },
onDragEnd = { },
onDragCancel = { },
onDrag = { change, dragAmount ->
change.consume()
fabOffsetX += dragAmount.x
fabOffsetY += dragAmount.y
}
)
},
containerColor = Color(0xFF10B981)
) {
Text("AI", color = Color.White, style = MaterialTheme.typography.titleMedium)
}
}
// 对话框 - 展开后必须在界面内
if (isExpanded) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
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 {
IntOffset(
(clampedX + cardDragX).roundToInt()
.coerceIn(0, (screenWidthPx - cardWidthPx).toInt()),
(clampedY + cardDragY).roundToInt()
.coerceIn(0, (screenHeightPx - cardHeightPx).toInt())
)
}
.width(maxWidth)
.height(maxHeight)
.shadow(8.dp, RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
) {
ChatScaffold(
onClose = {
visible = false
isExpanded = false
// 👈 关闭时把FAB偏移同步对话框拖动带动FAB
fabOffsetX += cardDragX
fabOffsetY += cardDragY
cardDragX = 0f
cardDragY = 0f
},
onDrag = { dx, dy ->
cardDragX += dx
cardDragY += dy
}
)
}
}
}
}

View File

@ -1,202 +0,0 @@
package com.stand.standapp.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.stand.standapp.ui.chat.model.ChatMessage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatArea(
messages: List<ChatMessage>,
onSendMessage: (String) -> Unit,
onOpenDrawer: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
onDrag: (Float, Float) -> Unit = { _, _ -> },
onCancelStreaming: () -> Unit = {}
) {
var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.scrollToItem(messages.size - 1, Int.MAX_VALUE)
}
}
val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } }
if (isAnyStreaming) {
val totalLength = messages.map { it.content.length }.sum()
LaunchedEffect(totalLength) {
if (messages.isNotEmpty()) {
listState.scrollToItem(messages.size - 1, Int.MAX_VALUE)
}
}
}
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
TopAppBar(
title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) },
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
},
actions = {
TextButton(onClick = onClose) { Text("关闭") }
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFFF3F4F6)),
modifier = Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { },
onDragEnd = { },
onDragCancel = { },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
}
)
}
)
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(messages, key = { it.id }) { msg ->
val isUser = msg.role == ChatMessage.Role.USER
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB),
contentColor = if (isUser) Color.White else Color.Black
) {
if (isUser) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
SafeSelectableTextView(ctx).apply {
setTextColor(android.graphics.Color.WHITE)
textSize = 14f
setTextIsSelectable(true)
setPadding(0, 0, 0, 0)
}
},
update = { view ->
view.text = msg.content
},
modifier = Modifier.padding(12.dp)
)
} else {
val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
MarkwonRenderer(
markdown = contentWithCursor,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.navigationBarsPadding()
.imePadding(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = { Text("输入你想问的问题...") },
shape = RoundedCornerShape(20.dp),
enabled = !isAnyStreaming
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (isAnyStreaming) {
onCancelStreaming()
} else if (inputText.isNotBlank()) {
onSendMessage(inputText)
inputText = ""
}
},
modifier = Modifier.background(
if (isAnyStreaming) Color.Red else Color(0xFF10B981),
RoundedCornerShape(50)
)
) {
Icon(
if (isAnyStreaming) Icons.Default.Close else Icons.Default.Send,
contentDescription = if (isAnyStreaming) "停止" else "发送",
tint = Color.White
)
}
}
}
}
@Composable
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply {
setTextColor(android.graphics.Color.parseColor("#1F2937"))
textSize = 14f
setTextIsSelectable(true) // 👈 启用选择复制功能
movementMethod = android.text.method.LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.15f)
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)
} else {
view.text = markdown
}
} catch (e: Exception) {
view.text = markdown
}
},
modifier = modifier
)
}

View File

@ -1,140 +0,0 @@
package com.stand.standapp.ui.chat.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.stand.standapp.ui.chat.ChatViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScaffold(
viewModel: ChatViewModel = viewModel(),
onClose: () -> Unit,
onDrag: (Float, Float) -> Unit = { _, _ -> }
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val sessions by viewModel.sessions.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val messages by viewModel.messages.collectAsState()
var showClearDialog by remember { mutableStateOf(false) }
val currentMessages = messages.filter { it.sessionId == currentSessionId }
// 清除确认对话框
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清除所有记录") },
text = { Text("确定要清除所有对话记录吗?此操作不可撤销。") },
confirmButton = {
TextButton(
onClick = {
viewModel.clearAllSessions()
showClearDialog = false
scope.launch { drawerState.close() }
}
) { Text("确定", color = Color.Red) }
},
dismissButton = {
TextButton(onClick = { showClearDialog = false }) { Text("取消") }
}
)
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = false,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(240.dp)) {
Column(Modifier.fillMaxSize().padding(12.dp)) {
// 👈 顶部关闭按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("历史对话", style = MaterialTheme.typography.titleSmall)
TextButton(onClick = { scope.launch { drawerState.close() } }) {
Text("关闭")
}
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
viewModel.createNewSession()
scope.launch { drawerState.close() }
},
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("新建话题", style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(16.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(sessions, key = { it.id }) { session ->
val isSelected = session.id == currentSessionId
Surface(
onClick = {
viewModel.switchSession(session.id)
scope.launch { drawerState.close() }
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
color = if (isSelected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = session.title,
modifier = Modifier.padding(12.dp),
maxLines = 1
)
}
}
}
// 底部清除按钮
OutlinedButton(
onClick = { showClearDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red)
) {
Icon(Icons.Default.Delete, contentDescription = null, tint = Color.Red)
Spacer(Modifier.width(8.dp))
Text("清除所有记录")
}
}
}
}
) {
ChatArea(
messages = currentMessages,
onSendMessage = { viewModel.sendMessage(it) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onClose = onClose,
onDrag = onDrag,
onCancelStreaming = { viewModel.cancelActiveStreaming() }
)
}
}

View File

@ -1,25 +0,0 @@
package com.stand.standapp.ui.chat.components
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
/**
* 安全的可选择文本 TextView捕获 setTextIsSelectable 导致的 IndexOutOfBoundsException 崩溃
* 问题根源当文本更新时用户正在拖动选择手柄resetDragAcceleratorState 尝试设置无效范围 (-1, -1)
*/
class SafeSelectableTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextView(context, attrs, defStyleAttr) {
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
return try {
super.onTouchEvent(event)
} catch (e: IndexOutOfBoundsException) {
// 吞掉异常,避免崩溃
true
}
}
}

View File

@ -1,21 +0,0 @@
package com.stand.standapp.ui.chat.model
import java.util.UUID
data class ChatSession(
val id: String = UUID.randomUUID().toString(),
val title: String = "新对话",
val timestamp: Long = System.currentTimeMillis()
)
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val sessionId: String,
val role: Role,
val content: String,
val isStreaming: Boolean = false
) {
enum class Role {
USER, AI
}
}

View File

@ -1,103 +0,0 @@
package com.stand.standapp.ui.chat.repo
import com.stand.standapp.AppConfig
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
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()
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关
val serverUrl = AppConfig.getServerUrl(context)
val requestUrl = "$serverUrl/api/chat/stream"
val jsonRequestBody = if (historyJson.isNotBlank()) {
"""{"message": "$message", "history": $historyJson}"""
} else {
"""{"message": "$message"}"""
}
val request = Request.Builder()
.url(requestUrl)
.post(jsonRequestBody.toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream")
.build()
val call = client.newCall(request)
// 2. 在 IO 线程池中执行网络流式读取,杜绝 EventSource 找不到依赖的编译期痛点
val job = CoroutineScope(Dispatchers.IO).launch {
try {
call.execute().use { response ->
if (!response.isSuccessful) {
trySend("[请求失败: 状态码 ${response.code}]")
close()
return@launch
}
val body = response.body
if (body == null) {
trySend("[空响应]")
close()
return@launch
}
// 3. 逐行读取后端流式推送字符,手动解析 data 属性
// 👈 添加行缓冲确保完整的markdown行被发送
BufferedReader(InputStreamReader(body.byteStream(), Charsets.UTF_8)).use { reader ->
var line: String?
var lastcontent = "";
while (reader.readLine().also { line = it } != null) {
val curLine = line ?: continue
if (curLine.startsWith("data:")) {
val content = curLine.substring(5)
// Timber.d("内容111111111"+content)
if (content.contains("|") && lastcontent.contains("|")){
trySend("\n")
// Timber.d("1发送11111111111换行")
trySend(content)
// Timber.d("2发送11111111111"+content)
}else if (content.length==0) {
// 空data行表示换行
trySend("\n")
// Timber.d("3发送11111111111换行")
}else {
trySend(content)
// Timber.d("4发送11111111111"+content)
}
lastcontent = content;
}
}
}
}
close()
} catch (e: Exception) {
Timber.e(e, "SSE Stream error")
trySend("\n[连接异常: ${e.message}]")
close(e)
}
}
awaitClose {
call.cancel()
job.cancel()
}
}
}

View File

@ -1,77 +0,0 @@
package com.stand.standapp.utils
import android.content.Context
import timber.log.Timber
object AppKiller {
private var isKilling = false
fun killApp(context: Context, isCrash: Boolean = false, restart: Boolean = false) {
if (isKilling) return
isKilling = true
Timber.tag("AppKiller").i("Starting app cleanup. isCrash: $isCrash, restart: $restart")
try {
GpioManager.stopListening()
Timber.tag("AppKiller").i("GPIO stopped")
} catch (e: Exception) {
Timber.tag("AppKiller").e(e, "GPIO stop failed")
}
try {
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("AppKiller").i("PTT logged out")
} catch (e: Exception) {
Timber.tag("AppKiller").e(e, "PTT logout failed")
}
try {
com.stand.standapp.printer.PrinterManager.getFactory()?.ClosePort()
Timber.tag("AppKiller").i("Printer port closed")
} catch (e: Exception) {
Timber.tag("AppKiller").e(e, "Printer close failed")
}
Timber.tag("AppKiller").i("Cleanup finished. Killing process.")
if (!isCrash) {
try {
Thread.sleep(50)
} catch (e: Exception) {}
}
try {
// LogManager.shutdown() 会关闭其内部的线程池
// 调用后绝对不能再使用 Timber 打印日志,否则会抛出 RejectedExecutionException 导致死循环
LogManager.shutdown()
} catch (e: Exception) {}
if (restart) {
try {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
if (intent != null) {
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)
val flags = android.app.PendingIntent.FLAG_ONE_SHOT or android.app.PendingIntent.FLAG_IMMUTABLE
val pendingIntent = android.app.PendingIntent.getActivity(context, 223344, intent, flags)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as android.app.AlarmManager
mgr.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
}
} catch (e: Exception) {}
}
android.os.Process.killProcess(android.os.Process.myPid())
System.exit(0)
}
fun setupUncaughtExceptionHandler(context: Context) {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Timber.tag("AppKiller").e(throwable, "Uncaught exception detected! Force cleanup.")
killApp(context, true)
defaultHandler?.uncaughtException(thread, throwable)
}
}
}

View File

@ -5,7 +5,6 @@ import android.content.Context
import android.os.Handler
import android.os.Looper
import com.stand.standapp.MainActivity
import com.stand.standapp.TempCallActivity
import timber.log.Timber
/**
@ -13,7 +12,6 @@ import timber.log.Timber
*/
object GpioManager {
private var mZysjSystemManager: ZysjSystemManager? = null
@Volatile
private var mIsRunning = false
private var mThread: Thread? = null
private var lastGpioValue = 1 // 默认松开状态 (1)
@ -56,7 +54,6 @@ object GpioManager {
fun stopListening() {
mIsRunning = false
mThread?.interrupt()
mThread = null
}
@ -76,16 +73,10 @@ object GpioManager {
Timber.tag("GPIO").d("PTT Speak Play (Hardware)")
// 统一使用 updatePttTalkStatus 更新 UI
MainActivity.executeJs("updatePttTalkStatus(true)")
// 同步 TempCallActivity
TempCallActivity.instance?.onGpioPressed()
Timber.tag("GPIO").d("Notified TempCallActivity: onGpioPressed")
} else {
Timber.tag("GPIO").w("GPIO pressed but isSpeaking already true — ignoring")
}
} else {
// 硬件松开 PTT
Timber.tag("GPIO").d("GPIO released, isSpeaking=%s", isSpeaking)
if (isSpeaking) {
val now = System.currentTimeMillis()
val duration = now - lastSpeakTime
@ -111,11 +102,6 @@ object GpioManager {
Timber.tag("GPIO").d("PTT Speak Release (Hardware)")
// 统一使用 updatePttTalkStatus 更新 UI
MainActivity.executeJs("updatePttTalkStatus(false)")
// 同步 TempCallActivity
TempCallActivity.instance?.onGpioReleased()
Timber.tag("GPIO").d("Notified TempCallActivity: onGpioReleased")
} else {
Timber.tag("GPIO").w("GPIO realRelease skipped: already released")
}
}
}

View File

@ -161,16 +161,6 @@ object LogManager {
}
}
fun shutdown() {
Timber.tag("LogManager").i("Shutting down...")
try {
executor.shutdown()
executor.awaitTermination(3, java.util.concurrent.TimeUnit.SECONDS)
} catch (e: Exception) {
Timber.tag("LogManager").e(e, "Executor shutdown failed")
}
}
fun uploadLogs(context: Context, callback: (Boolean, String) -> Unit) {
executor.execute {
val zipPath = zipLogs(context) ?: return@execute callback(false, "打包失败")

Binary file not shown.

View File

@ -1,8 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="135"
android:startColor="#020810"
android:centerColor="#051120"
android:endColor="#091D35"
android:angle="45"
android:endColor="#004A8F"
android:startColor="#001F3F"
android:type="linear" />
</shape>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#66000000" />
<corners android:radius="16dp" />
<padding
android:left="8dp"
android:right="8dp"
android:top="4dp"
android:bottom="4dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#EF4444" />
<size android:width="64dp" android:height="64dp" />
</shape>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#66FFFFFF" />
<size
android:width="40dp"
android:height="40dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#3B82F6" />
<size android:width="150dp" android:height="150dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#223B82F6" />
<size android:width="180dp" android:height="180dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#333B82F6" />
<size android:width="180dp" android:height="180dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#2563EB" />
<size android:width="150dp" android:height="150dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#10B981" />
<size android:width="10dp" android:height="10dp" />
</shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@ -0,0 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<!-- 背景圆盘 (纯净白) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,100m-90,0a90,90 0,1 1,180 0a90,90 0,1 1,-180 0" />
<!-- 消防盾牌外廓 (消防红) -->
<path
android:fillColor="#D62828"
android:pathData="M100,40 L155,62 L155,115 C155,145 130,170 100,180 C70,170 45,145 45,115 L45,62 Z" />
<!-- 盾牌内嵌线条 (深红) -->
<path
android:fillColor="#9B2226"
android:pathData="M100,50 L145,68 L145,115 C145,140 125,160 100,170 C75,160 55,140 55,115 L55,68 Z" />
<!-- 核心星芒/十字 (象征调度指挥) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,75 L112,98 L138,98 L118,115 L125,140 L100,125 L75,140 L82,115 L62,98 L88,98 Z" />
<!-- 外部环绕科技蓝装饰条 -->
<path
android:strokeColor="#004A8F"
android:strokeWidth="5"
android:strokeLineCap="round"
android:pathData="M100,20 A80,80 0,0 1,180,100" />
<path
android:strokeColor="#004A8F"
android:strokeWidth="5"
android:strokeLineCap="round"
android:pathData="M100,180 A80,80 0,0 1,20,100" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#004A8F"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@ -0,0 +1,46 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 消防盾牌图标,适配 Android 108dp 规范 -->
<group
android:translateX="18.5"
android:translateY="18.5"
android:scaleX="0.355"
android:scaleY="0.355">
<!-- 背景白底,确保中心可见 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,100m-90,0a90,90 0,1 1,180 0a90,90 0,1 1,-180 0" />
<!-- 消防盾牌外廓 -->
<path
android:fillColor="#D62828"
android:pathData="M100,40 L155,62 L155,115 C155,145 130,170 100,180 C70,170 45,145 45,115 L45,62 Z" />
<!-- 盾牌内嵌线条 -->
<path
android:fillColor="#9B2226"
android:pathData="M100,50 L145,68 L145,115 C145,140 125,160 100,170 C75,160 55,140 55,115 L55,68 Z" />
<!-- 核心星芒/十字 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,75 L112,98 L138,98 L118,115 L125,140 L100,125 L75,140 L82,115 L62,98 L88,98 Z" />
<!-- 科技蓝环绕装饰 -->
<path
android:strokeColor="#004A8F"
android:strokeWidth="5"
android:strokeLineCap="round"
android:pathData="M100,20 A80,80 0,0 1,180,100" />
<path
android:strokeColor="#004A8F"
android:strokeWidth="5"
android:strokeLineCap="round"
android:pathData="M100,180 A80,80 0,0 1,20,100" />
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>

View File

@ -1,10 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="0"
android:startColor="#0055AA"
android:endColor="#00AAEE"
android:type="linear" />
<corners android:radius="2dp" />
<stroke android:width="1dp" android:color="#00CFFF" />
<solid android:color="#3B82F6" />
<corners android:radius="8dp" />
</shape>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/transparent" />
<corners android:radius="2dp" />
<stroke android:width="1dp" android:color="#1A4A6B" />
</shape>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#00BFFF" />
</shape>

View File

@ -1,6 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#0D2035" />
<corners android:radius="2dp" />
<stroke android:width="1dp" android:color="#1A4A6B" />
<solid android:color="#FFFFFF" />
<corners android:radius="8dp" />
</shape>

View File

@ -1,18 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 深色半透明背景 -->
<item>
<shape>
<solid android:color="#D0071525" />
<corners android:radius="4dp" />
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#22FFFFFF" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#44FFFFFF" />
</shape>
</item>
<!-- 青色发光边框 -->
<item>
<shape>
<solid android:color="@android:color/transparent" />
<stroke android:width="1dp" android:color="#00BFFF" />
<corners android:radius="4dp" />
</shape>
</item>
</layer-list>

View File

@ -3,9 +3,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_login">
android:background="@drawable/bg_login"
android:padding="20dp">
<!-- 服务器配置入口(右上角) -->
<!-- 服务器配置入口 (右上角) -->
<ImageView
android:id="@+id/iv_settings"
android:layout_width="40dp"
@ -14,280 +15,128 @@
android:layout_marginTop="10dp"
android:padding="8dp"
android:src="@android:drawable/ic_menu_manage"
android:tint="#2A6A8A"
android:contentDescription="配置服务器" />
<!-- 顶部 LOGO + 标题区域 -->
<!-- 顶部标题 -->
<LinearLayout
android:id="@+id/ll_header"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="20dp"
android:gravity="center"
android:orientation="vertical">
<!-- LOGO + 两侧装饰线 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:layout_width="50dp"
android:layout_height="1dp"
android:background="#00BFFF" />
<View
android:layout_width="5dp"
android:layout_height="5dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:background="@drawable/shape_dot_accent" />
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:src="@drawable/ic_fire_logo"
android:contentDescription="Logo" />
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_fire_logo" />
<View
android:layout_width="5dp"
android:layout_height="5dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:background="@drawable/shape_dot_accent" />
<View
android:layout_width="50dp"
android:layout_height="1dp"
android:background="#00BFFF" />
</LinearLayout>
<!-- 系统名称 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="消防值守台"
android:textColor="#00CFFF"
android:textColor="#FFFFFF"
android:textSize="26sp"
android:textStyle="bold"
android:letterSpacing="0.15" />
<!-- 英文副标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="FIRE WATCH MONITORING PLATFORM"
android:textColor="#2A6A8A"
android:textSize="10sp"
android:letterSpacing="0.1" />
android:textStyle="bold" />
</LinearLayout>
<!-- 登录卡片(居中) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true">
<LinearLayout
android:id="@+id/login_card"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/shape_login_box"
android:elevation="8dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="28dp"
android:padding="30dp"
app:layout_constraintWidth_max="450dp"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.88"
app:layout_constraintWidth_percent="0.85"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<!-- 卡片标题(带装饰线) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="20dp">
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1"
android:background="#1A4A6B" />
<View
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:background="@drawable/shape_dot_accent" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:layout_marginBottom="25dp"
android:text="用户登录"
android:textColor="#00CFFF"
android:textSize="15sp"
android:textStyle="bold" />
android:textColor="#FFFFFF"
android:textSize="20sp" />
<View
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:background="@drawable/shape_dot_accent" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1"
android:background="#1A4A6B" />
</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" />
<!-- 用户名输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:background="@drawable/shape_edit_text">
<TextView
android:layout_width="44dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="账号"
android:textColor="#2A7A9A"
android:textSize="12sp"
android:layout_alignParentStart="true" />
<View
android:layout_width="1dp"
android:layout_height="20dp"
android:layout_centerVertical="true"
android:layout_marginStart="44dp"
android:background="#1A4A6B" />
android:layout_marginBottom="15dp"
android:text="测试账号: admin 密码: 123456"
android:textColor="#CCFFFFFF"
android:textSize="14sp" />
<EditText
android:id="@+id/et_username"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="52dp"
android:background="@android:color/transparent"
android:hint="请输入用户名"
android:paddingRight="12dp"
android:singleLine="true"
android:textColor="#DDEEFF"
android:textColorHint="#2A6A8A"
android:textSize="14sp" />
</RelativeLayout>
<!-- 密码输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:background="@drawable/shape_edit_text">
<TextView
android:layout_width="44dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="密码"
android:textColor="#2A7A9A"
android:textSize="12sp"
android:layout_alignParentStart="true" />
<View
android:layout_width="1dp"
android:layout_height="20dp"
android:layout_centerVertical="true"
android:layout_marginStart="44dp"
android:background="#1A4A6B" />
android:layout_marginBottom="15dp"
android:background="@drawable/shape_edit_text"
android:hint="请输入用户名"
android:paddingLeft="15dp"
android:paddingRight="15dp"
android:singleLine="true"
android:textColor="#333333"
android:textColorHint="#999999" />
<EditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="52dp"
android:background="@android:color/transparent"
android:layout_height="50dp"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_edit_text"
android:hint="请输入密码"
android:inputType="textPassword"
android:paddingRight="12dp"
android:paddingLeft="15dp"
android:paddingRight="15dp"
android:singleLine="true"
android:textColor="#DDEEFF"
android:textColorHint="#2A6A8A"
android:textSize="14sp" />
</RelativeLayout>
android:textColor="#333333"
android:textColorHint="#999999" />
<!-- 记住登录 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp">
android:layout_marginBottom="25dp">
<CheckBox
android:id="@+id/cb_remember"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="记住登录"
android:textColor="#3A7A9A"
android:textSize="13sp" />
android:textColor="#FFFFFF" />
</RelativeLayout>
<!-- 登录按钮 -->
<Button
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="10dp"
android:layout_height="55dp"
android:layout_marginBottom="15dp"
android:background="@drawable/shape_button_login"
android:text="登录"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold"
android:letterSpacing="0.2" />
android:textSize="18sp" />
<!-- 正式界面登录按钮 -->
<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="50dp"
android:background="@android:color/transparent"
android:text="退出应用"
android:textColor="#1A4A6B"
android:textSize="13sp" />
android:textColor="#CCFFFFFF"
android:textSize="14sp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@ -299,10 +148,10 @@
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="16dp"
android:layout_marginBottom="20dp"
android:text="Copyright © 2020-2026 Telewave. All Rights Reserved."
android:textColor="#1A4A6B"
android:textSize="11sp"
android:textColor="#88FFFFFF"
android:textSize="12sp"
android:padding="10dp"
android:clickable="true"
android:focusable="true" />

View File

@ -5,97 +5,18 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.kingway.ptt.pttActivity">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="服务器IP"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<AutoCompleteTextView
android:id="@+id/etIp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="服务器IP"
android:inputType="text"
android:text="220.154.131.231"
android:dropDownWidth="match_parent"
android:layout_marginBottom="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="账号"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<AutoCompleteTextView
android:id="@+id/etAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="账号"
android:inputType="text"
android:text="test004"
android:dropDownWidth="match_parent"
android:layout_marginBottom="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="密码"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<AutoCompleteTextView
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="密码"
android:inputType="textPassword"
android:text="123456"
android:dropDownWidth="match_parent"
android:layout_marginBottom="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="群组ID"
android:textSize="14sp"
android:layout_marginBottom="4dp"/>
<AutoCompleteTextView
android:id="@+id/etGroupId"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="群组ID"
android:inputType="text"
android:text="00000001"
android:dropDownWidth="match_parent"
android:layout_marginBottom="16dp"/>
app:layout_constraintTop_toTopOf="parent"
>
<Button
android:id="@+id/login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="默认登录(gzzdweb01)"/>
<Button
android:id="@+id/btnCustomLogin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="自定义登录"/>
android:text="login"/>
<Button
android:id="@+id/pttBtn"
@ -132,25 +53,5 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="切换群组" />
<Button
android:id="@+id/btnCancel1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="取消登录" />
<Button
android:id="@+id/btnCancel2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="注销" />
<Button
android:id="@+id/login1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="login1" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,183 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_login">
<!-- 最小化按钮 (右上角,明显的按钮样式) -->
<LinearLayout
android:id="@+id/ll_minimize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:layout_marginTop="40dp"
android:layout_marginRight="16dp"
android:background="@drawable/btn_minimize_bg"
android:gravity="center"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:tint="#FFFFFF" />
<TextView
android:id="@+id/btn_minimize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:text="最小化"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
<!-- 顶部状态栏区域 -->
<LinearLayout
android:id="@+id/ll_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="50dp"
android:paddingBottom="16dp">
<!-- 临时呼叫图标 -->
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_menu_call"
android:tint="#FFFFFF" />
<!-- 状态文字 -->
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="临时呼叫中"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold"
android:letterSpacing="0.1" />
<!-- 脉冲动画指示器 -->
<ImageView
android:id="@+id/iv_pulse"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginTop="6dp"
android:src="@drawable/circle_pulse" />
</LinearLayout>
<!-- 中间信息卡片 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
android:background="@drawable/shape_login_box"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="临时群组"
android:textColor="#99FFFFFF"
android:textSize="12sp"
android:letterSpacing="0.15" />
<TextView
android:id="@+id/tv_group_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="临时群组"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold" />
<!-- 分隔线 -->
<View
android:layout_width="60dp"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="#33FFFFFF" />
<TextView
android:id="@+id/tv_talk_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="按下按钮讲话"
android:textColor="#CCFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_talk_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text=""
android:textColor="#10B981"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<!-- 底部按钮区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:gravity="center"
android:orientation="vertical"
android:paddingBottom="48dp">
<!-- PTT 大按钮 -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp">
<!-- 外圈光晕 -->
<View
android:id="@+id/view_glow"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_glow" />
<!-- PTT 按钮 -->
<Button
android:id="@+id/btn_ptt"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_button"
android:text="按住\n说话"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textStyle="bold" />
</FrameLayout>
<!-- 挂断按钮 -->
<Button
android:id="@+id/btn_hangup"
android:layout_width="64dp"
android:layout_height="64dp"
android:background="@drawable/circle_hangup_button"
android:text="挂断"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
</RelativeLayout>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
img_1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB