Compare commits
34 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
74b10cd583 | |
|
|
2e3de6b968 | |
|
|
b0a5eb7234 | |
|
|
b12cefeb5a | |
|
|
71ba9f3e77 | |
|
|
15b04be06f | |
|
|
9340aa319d | |
|
|
4b10fea5b5 | |
|
|
c531965106 | |
|
|
9d5f4dc3a9 | |
|
|
8a7531428b | |
|
|
d937ad854d | |
|
|
ea7c977f62 | |
|
|
9ed1e31700 | |
|
|
e266a4e690 | |
|
|
89f1df9a9b | |
|
|
96068d56b2 | |
|
|
62cb78167d | |
|
|
d9b65c0e7e | |
|
|
992c18d8dc | |
|
|
4cb6b4ed08 | |
|
|
854e04b28f | |
|
|
eaf169c626 | |
|
|
4465887c96 | |
|
|
4b2bf8f5f4 | |
|
|
7060b99dd9 | |
|
|
58885f69a9 | |
|
|
01dec2de0e | |
|
|
a2c8fd2c1a | |
|
|
414681fa26 | |
|
|
450e04db89 | |
|
|
a982e54eeb | |
|
|
8bddca4996 | |
|
|
397e103596 |
|
|
@ -0,0 +1,16 @@
|
|||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.StandAPP">
|
||||
android:theme="@style/Theme.StandAPP"
|
||||
android:hardwareAccelerated="true">
|
||||
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@
|
|||
<button class="btn" style="background: #10B981;" onclick="callUploadLogs()">4. 上传系统日志</button>
|
||||
<button class="btn" style="background: #64748b;" onclick="callAbout()">5. 关于应用</button>
|
||||
<button class="btn btn-red" onclick="callExit()">6. 退出应用</button>
|
||||
<button class="btn" style="background: #e11d48;" onclick="callForceExit()">7. 强制退出</button>
|
||||
<button class="btn btn-orange" onclick="callLogout()">8. 注销登录</button>
|
||||
|
||||
<!-- WebSocket 控制区 -->
|
||||
<div class="ws-card">
|
||||
|
|
@ -519,18 +521,101 @@
|
|||
}
|
||||
}
|
||||
|
||||
function callForceExit() {
|
||||
if (window.android && window.android.forceExit) {
|
||||
window.android.forceExit();
|
||||
} else {
|
||||
alert("App 强制退出接口不可用");
|
||||
}
|
||||
}
|
||||
|
||||
function callLogout() {
|
||||
if (window.android && window.android.logout) {
|
||||
window.android.logout();
|
||||
} else {
|
||||
alert("App 注销接口不可用");
|
||||
}
|
||||
}
|
||||
|
||||
function callAbout() {
|
||||
if (window.android && window.android.getAboutInfo) {
|
||||
var infoStr = window.android.getAboutInfo();
|
||||
try {
|
||||
var info = JSON.parse(infoStr);
|
||||
var html = '<strong>应用名称:</strong> ' + info.appName + '<br>' +
|
||||
'<strong>版本号:</strong> <span style="color: #3b82f6;">' + info.versionName + '</span><br>' +
|
||||
'<strong>设备型号:</strong> ' + info.model + '<br>' +
|
||||
'<strong>系统版本:</strong> Android ' + info.osVersion + '<br>' +
|
||||
'<strong>CPU 架构:</strong> ' + info.cpuAbi + '<br>' +
|
||||
'<strong>运行模式:</strong> ' + (info.is64Bit ? "64位" : "32位兼容模式");
|
||||
document.getElementById('aboutBody').innerHTML = html;
|
||||
|
||||
var friendlyNames = {
|
||||
appName: '应用名称',
|
||||
versionName: '版本号',
|
||||
model: '设备型号',
|
||||
osVersion: '系统版本',
|
||||
cpuAbi: 'CPU 架构',
|
||||
is64Bit: '运行模式',
|
||||
screen: '屏幕与显示',
|
||||
width: '宽度像素',
|
||||
height: '高度像素',
|
||||
density: '屏幕密度',
|
||||
densityDpi: '屏幕DPI',
|
||||
physicalSize: '物理尺寸 (英寸)',
|
||||
hardware: '硬件与厂商',
|
||||
manufacturer: '制造商',
|
||||
brand: '品牌',
|
||||
fingerprint: '系统指纹',
|
||||
system: '系统环境',
|
||||
sdkVersion: 'SDK 版本',
|
||||
language: '系统语言',
|
||||
country: '国家代码',
|
||||
securityPatch: '安全补丁',
|
||||
memory: '内存信息',
|
||||
totalRam: '总内存 (RAM)',
|
||||
availRam: '可用内存',
|
||||
storage: '存储信息',
|
||||
totalInternal: '总内部存储',
|
||||
availInternal: '可用内部存储',
|
||||
network: '网络状态',
|
||||
type: '网络类型',
|
||||
ipAddress: 'IP 地址',
|
||||
battery: '电池状态',
|
||||
level: '电量百分比',
|
||||
status: '充电状态',
|
||||
plugged: '供电类型'
|
||||
};
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
var k = 1024;
|
||||
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function buildHtml(data, prefix) {
|
||||
var keys = Object.keys(data);
|
||||
var res = '';
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var val = data[key];
|
||||
var label = friendlyNames[key] || key;
|
||||
var indent = prefix ? 'margin-left: 15px;' : '';
|
||||
|
||||
if (val !== null && typeof val === 'object') {
|
||||
res += '<div style="' + indent + 'margin-top: 8px; margin-bottom: 4px; border-bottom: 1px dashed #eee; padding-bottom: 2px;"><strong>' + label + '</strong></div>';
|
||||
res += buildHtml(val, true);
|
||||
} else {
|
||||
var displayVal = val;
|
||||
if (key === 'is64Bit') {
|
||||
displayVal = val ? '64位' : '32位兼容模式';
|
||||
} else if (key === 'totalRam' || key === 'availRam' || key === 'totalInternal' || key === 'availInternal') {
|
||||
displayVal = formatBytes(val);
|
||||
} else if (key === 'level') {
|
||||
displayVal = val >= 0 ? val + '%' : '未知';
|
||||
}
|
||||
res += '<div style="' + indent + 'line-height: 1.6;"><strong>' + label + ':</strong> ' + displayVal + '</div>';
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
document.getElementById('aboutBody').innerHTML = buildHtml(info, false);
|
||||
document.getElementById('aboutModal').style.display = 'flex';
|
||||
} catch (e) {
|
||||
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import android.util.Log;
|
|||
import timber.log.Timber;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
|
@ -17,17 +20,20 @@ public class CallBackResolution {
|
|||
private static String TAG = "PTT_RX";
|
||||
private static String indexGroupId = "";//用户所在群组Id
|
||||
private static String indexGroupName = "";//用户所在群组名称
|
||||
private static List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
|
||||
private static final int MAX_GROUP_MEMBERS = 500;
|
||||
private static final int MAX_GROUP_INFOS = 100;
|
||||
private static final Map<String, GroupMemberInfoDto> memberInfoDtos = new LinkedHashMap<>(MAX_GROUP_MEMBERS);
|
||||
private static int speckType = 1;
|
||||
private static String talkId = "";
|
||||
private static String talkName = "";
|
||||
private static ArrayList<GroupInfoDto> groupInfos = new ArrayList<>();
|
||||
private static final Map<String, GroupInfoDto> groupInfos = new LinkedHashMap<>(MAX_GROUP_INFOS);
|
||||
private static String oldLoginSate = "00";
|
||||
private static int logNum = 0;
|
||||
private static long offlineTime = 0;
|
||||
private static boolean loginState = false;
|
||||
private static String pttLoginStatus = "00"; // 00-未登录 01-登录中 02-已登录
|
||||
private static String pttLoginId = "";
|
||||
private static ScheduledExecutorService keepAliveExecutor = null;
|
||||
|
||||
public static String getPttLoginStatus() {
|
||||
return pttLoginStatus;
|
||||
|
|
@ -38,8 +44,12 @@ public class CallBackResolution {
|
|||
}
|
||||
|
||||
public static void resetPttLoginStatus() {
|
||||
loginState = false;
|
||||
pttLoginStatus = "00";
|
||||
pttLoginId = "";
|
||||
indexGroupId ="";
|
||||
indexGroupName = "";
|
||||
keepAliveExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
public static String getCurrentGroupId() {
|
||||
|
|
@ -139,7 +149,7 @@ public class CallBackResolution {
|
|||
Timber.tag(TAG).d( "at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
|
||||
if (1 == groupNo) groupInfos.clear();
|
||||
GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount);
|
||||
groupInfos.add(groupInfo);
|
||||
groupInfos.put(groupId, groupInfo);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -168,7 +178,7 @@ public class CallBackResolution {
|
|||
String tempTxt = unicodeToString(groupMemberName.toString());
|
||||
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
|
||||
if (memberNo == 1) memberInfoDtos.clear();
|
||||
memberInfoDtos.add(infoDto);
|
||||
memberInfoDtos.put(memberId, infoDto);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -335,6 +345,8 @@ public class CallBackResolution {
|
|||
indexGroupId = userGroupId;
|
||||
indexGroupName = userGroupName;
|
||||
CallBackUtil.callBackNotifyUpdateGroup(userGroupId, userGroupName);
|
||||
// 触发首次进入群组状态
|
||||
com.stand.standapp.MainActivity.onFirstGroupEntered();
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -543,13 +555,16 @@ public class CallBackResolution {
|
|||
if (tempTxt.contains("已登录")) {
|
||||
String showName = tempTxt.substring(3, tempTxt.length());
|
||||
CallBackUtil.callBackLogin(true, showName);
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
|
||||
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
|
||||
keepAliveExecutor.shutdownNow();
|
||||
}
|
||||
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
Runnable task = () -> {
|
||||
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
|
||||
SendAtUtil.sendUDP();
|
||||
SendAtUtil.sendTCP();
|
||||
};
|
||||
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
} else if (tempTxt.contains("账号已更新")) {
|
||||
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
|
||||
} else if (tempTxt.contains("账号或密码错误")) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
package com.example.kingway.ptt;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**指令返回类
|
||||
* 说明:操作UI请在主线程进行
|
||||
|
|
@ -11,6 +16,45 @@ import java.util.List;
|
|||
public class CallBackUtil {
|
||||
private static String TAG = "TYT_CallBackUtil";
|
||||
|
||||
private static final long THROTTLE_INTERVAL_MS = 100L;
|
||||
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
|
||||
private static final ConcurrentHashMap<String, Long> lastPushAt = new ConcurrentHashMap<>();
|
||||
private static final ConcurrentHashMap<String, Runnable> pendingRunnables = new ConcurrentHashMap<>();
|
||||
|
||||
private static void pushJsThrottled(String eventName, String script) {
|
||||
long now = SystemClock.uptimeMillis();
|
||||
Long last = lastPushAt.get(eventName);
|
||||
if (last == null || now - last >= THROTTLE_INTERVAL_MS) {
|
||||
lastPushAt.put(eventName, now);
|
||||
cancelPending(eventName);
|
||||
com.stand.standapp.MainActivity.executeJs(script);
|
||||
return;
|
||||
}
|
||||
Runnable prev = pendingRunnables.get(eventName);
|
||||
if (prev != null) {
|
||||
MAIN_HANDLER.removeCallbacks(prev);
|
||||
}
|
||||
final long scheduledAt = now;
|
||||
Runnable task = () -> {
|
||||
lastPushAt.put(eventName, SystemClock.uptimeMillis());
|
||||
pendingRunnables.remove(eventName);
|
||||
com.stand.standapp.MainActivity.executeJs(script);
|
||||
};
|
||||
pendingRunnables.put(eventName, task);
|
||||
MAIN_HANDLER.postDelayed(task, THROTTLE_INTERVAL_MS - (now - last));
|
||||
}
|
||||
|
||||
private static void cancelPending(String eventName) {
|
||||
Runnable r = pendingRunnables.remove(eventName);
|
||||
if (r != null) {
|
||||
MAIN_HANDLER.removeCallbacks(r);
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushJsImmediate(String script) {
|
||||
com.stand.standapp.MainActivity.executeJs(script);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 返回登录
|
||||
|
|
@ -68,11 +112,11 @@ public class CallBackUtil {
|
|||
* 查询群组成员返回
|
||||
* state true-成功, false-失败
|
||||
*/
|
||||
public static void callBackOnMemberSuccess(boolean state, List<GroupMemberInfoDto> dtos) {
|
||||
public static void callBackOnMemberSuccess(boolean state, java.util.Map<String, GroupMemberInfoDto> dtos) {
|
||||
try {
|
||||
org.json.JSONArray arr = new org.json.JSONArray();
|
||||
if (dtos != null && state) {
|
||||
for (GroupMemberInfoDto dto : dtos) {
|
||||
for (GroupMemberInfoDto dto : dtos.values()) {
|
||||
org.json.JSONObject obj = new org.json.JSONObject();
|
||||
obj.put("memberId", dto.getMemberId());
|
||||
obj.put("memberName", dto.getGmemberName());
|
||||
|
|
@ -80,7 +124,7 @@ public class CallBackUtil {
|
|||
arr.put(obj);
|
||||
}
|
||||
}
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
|
||||
pushJsThrottled("MemberList", "if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -128,17 +172,17 @@ public class CallBackUtil {
|
|||
* 返回群组集合信息
|
||||
* GroupInfoDto 群组信息
|
||||
* */
|
||||
public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
|
||||
public static void callBackGroupInfo (java.util.Map<String, GroupInfoDto> groupInfoDtos) {
|
||||
try {
|
||||
org.json.JSONArray arr = new org.json.JSONArray();
|
||||
for (GroupInfoDto dto : groupInfoDtos) {
|
||||
for (GroupInfoDto dto : groupInfoDtos.values()) {
|
||||
org.json.JSONObject obj = new org.json.JSONObject();
|
||||
obj.put("groupId", dto.getGroupId());
|
||||
obj.put("groupName", dto.getGroupName());
|
||||
obj.put("groupNo", dto.getGroupNo());
|
||||
arr.put(obj);
|
||||
}
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
|
||||
pushJsThrottled("GroupList", "if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +201,7 @@ public class CallBackUtil {
|
|||
obj.put("groupName", groupName);
|
||||
obj.put("talkId", talkId);
|
||||
obj.put("talkName", talkName);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
|
||||
pushJsThrottled("SpeakerUpdate", "if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +215,7 @@ public class CallBackUtil {
|
|||
obj.put("groupName", groupName);
|
||||
obj.put("talkId", talkId);
|
||||
obj.put("talkName", talkName);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
|
||||
pushJsThrottled("PlayStatus", "if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +255,7 @@ public class CallBackUtil {
|
|||
obj.put("latitude", latitude);
|
||||
obj.put("longitude", longitude);
|
||||
obj.put("time", time);
|
||||
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
|
||||
pushJsThrottled("LocationStatus", "if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
|
||||
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class SendAtUtil {
|
|||
*/
|
||||
public static void toCancelLogin() {
|
||||
send("050000\r\n");
|
||||
send("05000000\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +38,7 @@ public class SendAtUtil {
|
|||
*/
|
||||
public static void toLogout() {
|
||||
send("040000\r\n");
|
||||
send("04000000\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
"printRawData" -> { printRawData(optStringOrNull(args, 0)); "" }
|
||||
"startUpdate" -> { startUpdate(optStringOrNull(args, 0)); "" }
|
||||
"exitApp" -> { exitApp(); "" }
|
||||
"forceExit" -> { forceExit(); "" }
|
||||
"getVersionName" -> getVersionName()
|
||||
"getAboutInfo" -> getAboutInfo()
|
||||
"saveMessage" -> { saveMessage(optStringOrNull(args, 0)); "" }
|
||||
|
|
@ -85,6 +86,7 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
"showJsonDialog" -> { showJsonDialog(args.optString(0, "")); "" }
|
||||
"getTempCallStatus" -> getTempCallStatus()
|
||||
"maximizeTempCall" -> { maximizeTempCall(); "" }
|
||||
"logout" -> { logout(); "" }
|
||||
else -> {
|
||||
Timber.tag("Bridge").w("Unknown method: $method")
|
||||
""
|
||||
|
|
@ -193,6 +195,9 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
fun pttJumpGroup(gid: String) {
|
||||
Timber.tag("PTT").d("pttJumpGroup: gid=%s", gid)
|
||||
SendAtUtil.jumpGroup(gid)
|
||||
if (gid.isNotEmpty()) {
|
||||
AppConfig.setLastGroupId(activity, gid)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPrinterStatus(): String {
|
||||
|
|
@ -365,6 +370,17 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
}
|
||||
}
|
||||
|
||||
fun forceExit() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
com.stand.standapp.utils.AppKiller.killApp(activity)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("App").e(e, "强制退出失败")
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getVersionName(): String {
|
||||
return try {
|
||||
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
|
||||
|
|
@ -378,22 +394,9 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
fun getAboutInfo(): String {
|
||||
return try {
|
||||
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
|
||||
val json = JSONObject()
|
||||
val json = com.stand.standapp.utils.DeviceInfoUtils.getDeviceInfoJson(activity)
|
||||
json.put("appName", activity.getString(R.string.app_name))
|
||||
json.put("versionName", packageInfo.versionName)
|
||||
json.put("model", android.os.Build.MODEL)
|
||||
json.put("osVersion", android.os.Build.VERSION.RELEASE)
|
||||
|
||||
val abis = android.os.Build.SUPPORTED_ABIS
|
||||
json.put("cpuAbi", abis.joinToString(","))
|
||||
|
||||
val is64Bit = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
android.os.Process.is64Bit()
|
||||
} else {
|
||||
abis.any { it.contains("64") }
|
||||
}
|
||||
json.put("is64Bit", is64Bit)
|
||||
|
||||
json.put("versionName", packageInfo.versionName ?: "1.0.0")
|
||||
json.toString()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")
|
||||
|
|
@ -450,6 +453,9 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
com.stand.standapp.MainActivity.executeJs("if(window.appLogin){window.appLogin('$escaped');}")
|
||||
Timber.tag("AndroidInterface").d("Called appLogin")
|
||||
}
|
||||
|
||||
// 页面加载并初始化完成后触发状态
|
||||
com.stand.standapp.MainActivity.onPageLoadFinished()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("UI").e(e, "隐藏加载布局失败")
|
||||
}
|
||||
|
|
@ -503,4 +509,8 @@ class AndroidInterface(private val activity: MainActivity) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
activity.logout()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ object AppConfig {
|
|||
private const val KEY_USERNAME = "saved_username"
|
||||
private const val KEY_PASSWORD = "saved_password"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_LAST_GROUP_ID = "last_group_id"
|
||||
|
||||
// 默认配置
|
||||
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
|
||||
|
|
@ -77,4 +78,12 @@ object AppConfig {
|
|||
fun setAccessToken(context: Context, token: String) {
|
||||
getPrefs(context).edit().putString(KEY_ACCESS_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
fun getLastGroupId(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_LAST_GROUP_ID, "") ?: ""
|
||||
}
|
||||
|
||||
fun setLastGroupId(context: Context, groupId: String) {
|
||||
getPrefs(context).edit().putString(KEY_LAST_GROUP_ID, groupId).apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import android.content.Intent
|
|||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class DeveloperActivity : AppCompatActivity() {
|
||||
|
|
@ -29,6 +31,48 @@ class DeveloperActivity : AppCompatActivity() {
|
|||
startActivity(Intent(this, com.stand.standapp.printer.PrintTestActivity::class.java))
|
||||
}
|
||||
|
||||
// 本地演示界面
|
||||
findViewById<LinearLayout>(R.id.item_demo_ui).setOnClickListener {
|
||||
val builder = androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
builder.setTitle("本地演示登录")
|
||||
|
||||
val layout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(50, 40, 50, 10)
|
||||
}
|
||||
|
||||
val etUser = EditText(this).apply {
|
||||
hint = "账号"
|
||||
setText("test007")
|
||||
}
|
||||
layout.addView(etUser)
|
||||
|
||||
val etPwd = EditText(this).apply {
|
||||
hint = "密码"
|
||||
setText("123456")
|
||||
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
layout.addView(etPwd)
|
||||
|
||||
builder.setView(layout)
|
||||
builder.setPositiveButton("登录") { _, _ ->
|
||||
val username = etUser.text.toString().trim()
|
||||
val password = etPwd.text.toString().trim()
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
Toast.makeText(this, "账号密码不能为空", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val intent = Intent(this, LoginActivity::class.java).apply {
|
||||
putExtra("official_mode_login", false)
|
||||
putExtra("extra_username", username)
|
||||
putExtra("extra_password", password)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
builder.setNegativeButton("取消", null)
|
||||
builder.show()
|
||||
}
|
||||
|
||||
// 崩溃模拟
|
||||
findViewById<LinearLayout>(R.id.item_crash_test).setOnClickListener {
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.hjq.permissions.Permission
|
||||
import com.hjq.permissions.XXPermissions
|
||||
import com.stand.standapp.net.NetworkModule
|
||||
import com.stand.standapp.printer.PrinterManager
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
|
|
@ -17,7 +18,7 @@ import java.io.IOException
|
|||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val client = NetworkModule.defaultClient
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -32,7 +33,6 @@ class LoginActivity : AppCompatActivity() {
|
|||
val etPassword = findViewById<EditText>(R.id.et_password)
|
||||
val cbRemember = findViewById<CheckBox>(R.id.cb_remember)
|
||||
val btnLogin = findViewById<Button>(R.id.btn_login)
|
||||
val btnLoginOfficial = findViewById<Button>(R.id.btn_login_official)
|
||||
val btnExit = findViewById<Button>(R.id.btn_exit)
|
||||
val ivSettings = findViewById<ImageView>(R.id.iv_settings)
|
||||
val tvCopyright = findViewById<TextView>(R.id.tv_copyright)
|
||||
|
|
@ -45,12 +45,22 @@ class LoginActivity : AppCompatActivity() {
|
|||
etPassword.setText(AppConfig.getSavedPassword(this))
|
||||
}
|
||||
|
||||
// 检查是否有传递过来的用户名和密码(来自开发者界面的演示登录)
|
||||
val extraUsername = intent.getStringExtra("extra_username")
|
||||
val extraPassword = intent.getStringExtra("extra_password")
|
||||
if (!extraUsername.isNullOrEmpty() && !extraPassword.isNullOrEmpty()) {
|
||||
etUsername.setText(extraUsername)
|
||||
etPassword.setText(extraPassword)
|
||||
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
|
||||
performLogin(extraUsername, extraPassword, cbRemember.isChecked, isOfficialMode)
|
||||
}
|
||||
|
||||
// 2. 服务器配置图标点击事件
|
||||
ivSettings.setOnClickListener {
|
||||
showServerConfigDialog()
|
||||
}
|
||||
|
||||
// 3. 登录按钮点击事件
|
||||
// 3. 登录按钮点击事件(进行正式界面登录)
|
||||
btnLogin.setOnClickListener {
|
||||
val username = etUsername.text.toString().trim()
|
||||
val password = etPassword.text.toString().trim()
|
||||
|
|
@ -60,21 +70,9 @@ class LoginActivity : AppCompatActivity() {
|
|||
return@setOnClickListener
|
||||
}
|
||||
|
||||
// 执行真实接口登录
|
||||
performLogin(username, password, cbRemember.isChecked, false)
|
||||
}
|
||||
|
||||
// 3.1 正式界面登录按钮点击事件
|
||||
btnLoginOfficial.setOnClickListener {
|
||||
val username = etUsername.text.toString().trim()
|
||||
val password = etPassword.text.toString().trim()
|
||||
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
Toast.makeText(applicationContext, "请输入账号和密码", Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
performLogin(username, password, cbRemember.isChecked, true)
|
||||
// 根据传入的 Intent 决定是正式登录还是演示登录,默认是正式登录(true)
|
||||
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
|
||||
performLogin(username, password, cbRemember.isChecked, isOfficialMode)
|
||||
}
|
||||
|
||||
// 4. 退出按钮
|
||||
|
|
@ -118,6 +116,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
val request = Request.Builder()
|
||||
.url(loginUrl)
|
||||
.addHeader("X-Client-Type", "android")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.os.Bundle
|
|||
import android.view.KeyEvent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.widget.FrameLayout
|
||||
import com.example.kingway.ptt.CallBackResolution
|
||||
import org.mozilla.geckoview.GeckoResult
|
||||
import org.mozilla.geckoview.GeckoRuntime
|
||||
import org.mozilla.geckoview.GeckoSession
|
||||
|
|
@ -31,15 +32,72 @@ class MainActivity : AppCompatActivity() {
|
|||
@Volatile
|
||||
private var pendingJsCode: String? = null
|
||||
|
||||
private val jsLock = Any()
|
||||
|
||||
@JvmStatic
|
||||
fun executeJs(script: String) {
|
||||
Timber.tag("ExecuteJs").d(script)
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
// 合并多次调用(用换行分隔)
|
||||
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var hasFirstGroupEntered = false
|
||||
|
||||
@Volatile
|
||||
private var hasPageFinished = false
|
||||
|
||||
@Volatile
|
||||
private var hasAutoJumped = false
|
||||
|
||||
@JvmStatic
|
||||
fun onFirstGroupEntered() {
|
||||
Timber.tag("PTT").d("Condition met: First group entered")
|
||||
hasFirstGroupEntered = true
|
||||
checkAndAutoJumpToLastGroup()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPageLoadFinished() {
|
||||
Timber.tag("PTT").d("Condition met: Web page loaded and initialized")
|
||||
hasPageFinished = true
|
||||
checkAndAutoJumpToLastGroup()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resetAutoJumpStates() {
|
||||
hasFirstGroupEntered = false
|
||||
hasPageFinished = false
|
||||
hasAutoJumped = false
|
||||
Timber.tag("PTT").d("Auto jump states reset")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun checkAndAutoJumpToLastGroup() {
|
||||
synchronized(this) {
|
||||
if (hasFirstGroupEntered && hasPageFinished && !hasAutoJumped) {
|
||||
hasAutoJumped = true
|
||||
instance?.let { ctx ->
|
||||
val lastGroupId = AppConfig.getLastGroupId(ctx)
|
||||
if (lastGroupId.isNotEmpty()) {
|
||||
Timber.tag("PTT").d("Both conditions met. Auto-jumping to last joined group: %s after 2s delay", lastGroupId)
|
||||
ctx.runOnUiThread {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
instance?.pttInterface?.pttJumpGroup(lastGroupId)
|
||||
}, 1000)
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PTT").d("Both conditions met but lastGroupId is empty, skip auto-jump")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Timber.tag("PTT").d("Check auto jump: hasFirstGroupEntered=$hasFirstGroupEntered, hasPageFinished=$hasPageFinished, hasAutoJumped=$hasAutoJumped")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun onPttLoginSuccess() {
|
||||
Timber.tag("MainActivity").d("onPttLoginSuccess called from Native")
|
||||
|
|
@ -119,7 +177,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
|
||||
@JvmStatic
|
||||
fun pollPendingJs(): String {
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
val code = pendingJsCode ?: ""
|
||||
pendingJsCode = null
|
||||
return code
|
||||
|
|
@ -417,45 +475,79 @@ class MainActivity : AppCompatActivity() {
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun pttLogoutAndDestroy() {
|
||||
try {
|
||||
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "PTT cancel failed")
|
||||
}
|
||||
try {
|
||||
com.example.kingway.ptt.SendAtUtil.toLogout()
|
||||
Timber.tag("LoginActivity").i("PTT logout sent")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "PTT logout failed")
|
||||
}
|
||||
try {
|
||||
com.stand.standapp.utils.GpioManager.stopListening()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "GPIO stop failed")
|
||||
}
|
||||
try {
|
||||
com.stand.standapp.utils.GpioManager.stopListening()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "GPIO stop failed")
|
||||
}
|
||||
try {
|
||||
// com.example.kingway.ptt.MyApplication.destroy()
|
||||
Timber.tag("LoginActivity").i("PTT native destroyed")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "PTT destroy failed")
|
||||
}
|
||||
try {
|
||||
// PrinterManager.shutdown()
|
||||
Timber.tag("LoginActivity").i("Printer shut down")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "Printer shutdown failed")
|
||||
}
|
||||
try {
|
||||
com.stand.standapp.utils.LogManager.shutdown()
|
||||
Timber.tag("LoginActivity").i("LogManager shut down")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("LoginActivity").e(e, "LogManager shutdown failed")
|
||||
fun logout() {
|
||||
runOnUiThread {
|
||||
Timber.tag("MainActivity").i("用户注销,清理程序")
|
||||
|
||||
// 1. PTT Logout and cancel login
|
||||
try {
|
||||
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "PTT cancel failed")
|
||||
}
|
||||
try {
|
||||
com.example.kingway.ptt.SendAtUtil.toLogout()
|
||||
Timber.tag("MainActivity").i("PTT logout sent")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "PTT logout failed")
|
||||
}
|
||||
|
||||
// 2. Stop GPIO listening
|
||||
try {
|
||||
com.stand.standapp.utils.GpioManager.stopListening()
|
||||
Timber.tag("MainActivity").i("GPIO stop listening")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "GPIO stop failed")
|
||||
}
|
||||
|
||||
// 3. Destroy/release AudioTrack in PTT Native
|
||||
try {
|
||||
com.stand.standapp.MyApplication.pNative?.destory()
|
||||
com.stand.standapp.MyApplication.pNative = null
|
||||
Timber.tag("MainActivity").i("PTT native AudioTrack destroyed")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "PTT native destroy failed")
|
||||
}
|
||||
|
||||
// 4. Stop and destroy AudioRecord
|
||||
try {
|
||||
com.example.kingway.ptt.AudioRecordManager.getInstance().destroyAudio()
|
||||
Timber.tag("MainActivity").i("AudioRecordManager destroyed")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "AudioRecordManager destroy failed")
|
||||
}
|
||||
|
||||
// 5. Close printer connection
|
||||
try {
|
||||
com.stand.standapp.printer.PrinterManager.getFactory()?.ClosePort()
|
||||
Timber.tag("MainActivity").i("Printer port closed")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "Printer close failed")
|
||||
}
|
||||
|
||||
// 6. Clear Access Token
|
||||
try {
|
||||
AppConfig.setAccessToken(this, "")
|
||||
Timber.tag("MainActivity").i("Cleared access token")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "Failed to clear access token")
|
||||
}
|
||||
|
||||
// 7. Redirect back to LoginActivity and clear task stack
|
||||
try {
|
||||
val intent = Intent(this, LoginActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "Failed to redirect to LoginActivity")
|
||||
}
|
||||
try {
|
||||
resetAutoJumpStates()
|
||||
CallBackResolution.resetPttLoginStatus();
|
||||
}catch (e: Exception) {
|
||||
Timber.tag("MainActivity").e(e, "Failed to clear public value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,15 +564,34 @@ class MainActivity : AppCompatActivity() {
|
|||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
geckoSession?.setActive(false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
geckoSession?.setActive(true)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelTimeoutTimer()
|
||||
geckoView.releaseSession()
|
||||
geckoSession?.close()
|
||||
geckoSession = null
|
||||
clearCompanionState()
|
||||
instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun clearCompanionState() {
|
||||
synchronized(jsLock) {
|
||||
pendingJsCode = null
|
||||
}
|
||||
pendingDialog = null
|
||||
isConflictDialogShowing = false
|
||||
}
|
||||
|
||||
private fun startTimeoutTimer() {
|
||||
cancelTimeoutTimer()
|
||||
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,37 @@ import android.os.Looper
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var checkCount = 0
|
||||
private val maxChecks = 20 // 最多检查20次(2秒)
|
||||
|
||||
private val checkInitRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (MyApplication.pNative != null || checkCount >= maxChecks) {
|
||||
navigateToLogin()
|
||||
} else {
|
||||
checkCount++
|
||||
handler.postDelayed(this, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_splash)
|
||||
|
||||
// 延迟 2 秒进入登录界面
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}, 2000)
|
||||
// 异步检查PTT JNI初始化状态,避免硬等待2秒
|
||||
handler.post(checkInitRunnable)
|
||||
}
|
||||
|
||||
private fun navigateToLogin() {
|
||||
if (isFinishing || isDestroyed) return
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacks(checkInitRunnable)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,11 +114,9 @@ class TempCallActivity : AppCompatActivity() {
|
|||
pulseAnimator?.cancel()
|
||||
if (instance == this) {
|
||||
instance = null
|
||||
// 如果不是最小化状态,说明是真正结束,清除所有状态
|
||||
if (!isMinimized) {
|
||||
currentGroupName = "临时会话"
|
||||
}
|
||||
// 通知 web 页面状态变化
|
||||
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import android.widget.Toast
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import com.stand.standapp.net.NetworkModule
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
|
@ -58,7 +59,7 @@ class UpdateManager(private val context: Context) {
|
|||
|
||||
val url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
|
||||
|
||||
val client = OkHttpClient()
|
||||
val client = NetworkModule.defaultClient
|
||||
val request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
|
|
@ -139,7 +140,7 @@ class UpdateManager(private val context: Context) {
|
|||
private fun startManualDownload(url: String) {
|
||||
showProgressDialog()
|
||||
|
||||
val client = OkHttpClient()
|
||||
val client = NetworkModule.defaultClient
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", "Mozilla/5.0 (Android)")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.stand.standapp.net
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkModule {
|
||||
private const val DEFAULT_CONNECT_TIMEOUT_SEC = 15L
|
||||
private const val DEFAULT_READ_TIMEOUT_SEC = 30L
|
||||
private const val DEFAULT_WRITE_TIMEOUT_SEC = 30L
|
||||
|
||||
val defaultClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(DEFAULT_CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS)
|
||||
.readTimeout(DEFAULT_READ_TIMEOUT_SEC, TimeUnit.SECONDS)
|
||||
.writeTimeout(DEFAULT_WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun streamingClient(): OkHttpClient {
|
||||
return defaultClient.newBuilder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
private const val STREAM_FLUSH_INTERVAL_MS = 150L
|
||||
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = AiChatRepository()
|
||||
|
||||
|
|
@ -30,14 +35,49 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
// 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问!
|
||||
private val _streamingContent = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val streamingContent: StateFlow<Map<String, String>> = _streamingContent.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
// 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话!
|
||||
|
||||
private var activeChatJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE)
|
||||
private val chunkBuffer = java.util.concurrent.ConcurrentHashMap<String, StringBuilder>()
|
||||
private val lastFlushAt = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private val flushScope = kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
|
||||
)
|
||||
private val flushMutex = kotlinx.coroutines.sync.Mutex()
|
||||
|
||||
private fun scheduleFlush(messageId: String) {
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
val last = lastFlushAt[messageId] ?: 0
|
||||
val delta = now - last
|
||||
if (delta >= STREAM_FLUSH_INTERVAL_MS) {
|
||||
flushStreamingMessage(messageId)
|
||||
} else {
|
||||
flushScope.launch {
|
||||
kotlinx.coroutines.delay(STREAM_FLUSH_INTERVAL_MS - delta)
|
||||
flushStreamingMessage(messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushStreamingMessage(messageId: String) {
|
||||
flushScope.launch {
|
||||
flushMutex.withLock {
|
||||
val buffer = chunkBuffer.remove(messageId) ?: return@withLock
|
||||
val pending = buffer.toString()
|
||||
if (pending.isEmpty()) return@withLock
|
||||
_streamingContent.update { current ->
|
||||
current + (messageId to (current[messageId].orEmpty() + pending))
|
||||
}
|
||||
lastFlushAt[messageId] = android.os.SystemClock.uptimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// 👈 将数据加载移到IO线程,避免阻塞主线程导致UI卡顿
|
||||
|
|
@ -99,51 +139,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun saveCacheToLocal() {
|
||||
try {
|
||||
// 保存会话列表
|
||||
val sessionsArray = JSONArray()
|
||||
_sessions.value.forEach { session ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", session.id)
|
||||
obj.put("title", session.title)
|
||||
obj.put("timestamp", session.timestamp)
|
||||
sessionsArray.put(obj)
|
||||
}
|
||||
// 将数据保存放到IO线程中,防止SharedPreferences写大文本阻塞主线程卡顿
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
// 保存会话列表
|
||||
val sessionsArray = JSONArray()
|
||||
_sessions.value.forEach { session ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", session.id)
|
||||
obj.put("title", session.title)
|
||||
obj.put("timestamp", session.timestamp)
|
||||
sessionsArray.put(obj)
|
||||
}
|
||||
|
||||
// 保存消息列表
|
||||
val messagesArray = JSONArray()
|
||||
_messages.value.forEach { msg ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", msg.id)
|
||||
obj.put("sessionId", msg.sessionId)
|
||||
obj.put("role", msg.role.name)
|
||||
obj.put("content", msg.content)
|
||||
messagesArray.put(obj)
|
||||
}
|
||||
// 保存消息列表
|
||||
val messagesArray = JSONArray()
|
||||
_messages.value.forEach { msg ->
|
||||
val obj = JSONObject()
|
||||
obj.put("id", msg.id)
|
||||
obj.put("sessionId", msg.sessionId)
|
||||
obj.put("role", msg.role.name)
|
||||
obj.put("content", msg.content)
|
||||
messagesArray.put(obj)
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.putString("cache_sessions", sessionsArray.toString())
|
||||
.putString("cache_messages", messagesArray.toString())
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
prefs.edit()
|
||||
.putString("cache_sessions", sessionsArray.toString())
|
||||
.putString("cache_messages", messagesArray.toString())
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
|
||||
fun cancelActiveStreaming() {
|
||||
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
|
||||
activeChatJob?.cancel()
|
||||
activeChatJob = null
|
||||
|
||||
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
|
||||
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.isStreaming) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = _streamingContent.value[msg.id] ?: msg.content
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
|
||||
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
|
||||
saveCacheToLocal() // 强制存盘归档
|
||||
_streamingContent.value = emptyMap()
|
||||
chunkBuffer.clear()
|
||||
lastFlushAt.clear()
|
||||
|
||||
_isLoading.value = false
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
fun createNewSession() {
|
||||
|
|
@ -232,20 +278,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun appendAiMessageChunk(messageId: String, chunk: String) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(content = msg.content + chunk)
|
||||
} else msg
|
||||
}
|
||||
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
|
||||
scheduleFlush(messageId)
|
||||
}
|
||||
|
||||
private fun finalizeAiMessage(messageId: String) {
|
||||
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
|
||||
_streamingContent.update { it - messageId }
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
|
||||
lastFlushAt.remove(messageId)
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
||||
|
|
|
|||
|
|
@ -94,25 +94,19 @@ fun FloatingChatWidget() {
|
|||
visible = true
|
||||
}
|
||||
|
||||
// 👈 用 FAB 位置计算对话框位置,但强制钳制在屏幕内
|
||||
// FAB右下角为基准:对话框应该出现在 FAB 附近
|
||||
val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX // FAB右边缘
|
||||
val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY // FAB下边缘
|
||||
|
||||
// 对话框优先放在 FAB 左上方,如果放不下就贴边
|
||||
val rawX = fabRight - cardWidthPx - paddingPx // FAB左边
|
||||
val rawY = fabBottom - cardHeightPx - paddingPx // FAB上边
|
||||
|
||||
// 👈 关键:钳制到屏幕内
|
||||
val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx)
|
||||
val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)),
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX
|
||||
val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY
|
||||
val rawX = fabRight - cardWidthPx - paddingPx
|
||||
val rawY = fabBottom - cardHeightPx - paddingPx
|
||||
val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx)
|
||||
val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx)
|
||||
|
||||
IntOffset(
|
||||
(clampedX + cardDragX).roundToInt()
|
||||
.coerceIn(0, (screenWidthPx - cardWidthPx).toInt()),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ fun ChatArea(
|
|||
}
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val markwon = remember(context) {
|
||||
io.noties.markwon.Markwon.builder(context)
|
||||
.usePlugin(io.noties.markwon.core.CorePlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context))
|
||||
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
|
||||
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(context))
|
||||
.build()
|
||||
}
|
||||
|
||||
val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } }
|
||||
if (isAnyStreaming) {
|
||||
val totalLength = messages.map { it.content.length }.sum()
|
||||
|
|
@ -111,6 +123,7 @@ fun ChatArea(
|
|||
val contentWithCursor = msg.content + if (msg.isStreaming) " █" else ""
|
||||
MarkwonRenderer(
|
||||
markdown = contentWithCursor,
|
||||
markwon = markwon,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -161,7 +174,7 @@ fun ChatArea(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
|
||||
fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
val textView = SafeSelectableTextView(ctx).apply {
|
||||
|
|
@ -173,23 +186,14 @@ fun MarkwonRenderer(markdown: String, modifier: Modifier) {
|
|||
setPadding(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
val markwon = io.noties.markwon.Markwon.builder(ctx)
|
||||
.usePlugin(io.noties.markwon.core.CorePlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(ctx))
|
||||
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
|
||||
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(ctx))
|
||||
.build()
|
||||
|
||||
textView.tag = markwon
|
||||
textView
|
||||
},
|
||||
update = { view ->
|
||||
try {
|
||||
val markwon = view.tag as? io.noties.markwon.Markwon
|
||||
if (markwon != null) {
|
||||
markwon.setMarkdown(view, markdown)
|
||||
val currentMarkwon = view.tag as? io.noties.markwon.Markwon
|
||||
if (currentMarkwon != null) {
|
||||
currentMarkwon.setMarkdown(view, markdown)
|
||||
} else {
|
||||
view.text = markdown
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,19 @@ fun ChatScaffold(
|
|||
val sessions by viewModel.sessions.collectAsState()
|
||||
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val streamingContent by viewModel.streamingContent.collectAsState()
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val currentMessages = messages.filter { it.sessionId == currentSessionId }
|
||||
val currentMessages = remember(messages, streamingContent, currentSessionId) {
|
||||
val streaming = streamingContent
|
||||
messages.asSequence()
|
||||
.filter { it.sessionId == currentSessionId }
|
||||
.map { msg ->
|
||||
val live = streaming[msg.id]
|
||||
if (msg.isStreaming && live != null) msg.copy(content = live) else msg
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
// 清除确认对话框
|
||||
if (showClearDialog) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.stand.standapp.ui.chat.repo
|
|||
|
||||
import com.stand.standapp.AppConfig
|
||||
import android.content.Context
|
||||
import com.stand.standapp.net.NetworkModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
|
|
@ -15,12 +16,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AiChatRepository {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
|
||||
.build()
|
||||
private val client: OkHttpClient = NetworkModule.streamingClient()
|
||||
|
||||
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
|
||||
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ object AppKiller {
|
|||
mgr.set(android.app.AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
} else {
|
||||
// 如果不是重启,且 context 是 Activity,调用 finishAffinity() 销毁所有关联的 Activity
|
||||
if (context is android.app.Activity) {
|
||||
try {
|
||||
context.finishAffinity()
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
package com.stand.standapp.utils
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.Process
|
||||
import android.os.StatFs
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.net.NetworkInterface
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
|
||||
object DeviceInfoUtils {
|
||||
|
||||
fun getDeviceInfoJson(context: Context): JSONObject {
|
||||
val json = JSONObject()
|
||||
val appContext = context.applicationContext
|
||||
|
||||
// Base App info is typically set at the caller level, but we populate core details
|
||||
try {
|
||||
json.put("model", Build.MODEL)
|
||||
json.put("osVersion", Build.VERSION.RELEASE)
|
||||
|
||||
val abis = Build.SUPPORTED_ABIS
|
||||
json.put("cpuAbi", abis.joinToString(","))
|
||||
|
||||
val is64Bit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Process.is64Bit()
|
||||
} else {
|
||||
abis.any { it.contains("64") }
|
||||
}
|
||||
json.put("is64Bit", is64Bit)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get base device info")
|
||||
}
|
||||
|
||||
// 1. Screen and display info
|
||||
try {
|
||||
val screenObj = JSONObject()
|
||||
val metrics = appContext.resources.displayMetrics
|
||||
val widthPixels = metrics.widthPixels
|
||||
val heightPixels = metrics.heightPixels
|
||||
val xdpi = metrics.xdpi
|
||||
val ydpi = metrics.ydpi
|
||||
|
||||
screenObj.put("width", widthPixels)
|
||||
screenObj.put("height", heightPixels)
|
||||
screenObj.put("density", metrics.density)
|
||||
screenObj.put("densityDpi", metrics.densityDpi)
|
||||
|
||||
val physicalSize = if (xdpi > 0 && ydpi > 0) {
|
||||
val widthInches = widthPixels / xdpi
|
||||
val heightInches = heightPixels / ydpi
|
||||
Math.sqrt((widthInches * widthInches + heightInches * heightInches).toDouble())
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
screenObj.put("physicalSize", Math.round(physicalSize * 10.0) / 10.0) // 1 decimal place
|
||||
|
||||
json.put("screen", screenObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get screen metrics")
|
||||
}
|
||||
|
||||
// 2. Hardware and manufacturer info
|
||||
try {
|
||||
val hwObj = JSONObject()
|
||||
hwObj.put("manufacturer", Build.MANUFACTURER)
|
||||
hwObj.put("brand", Build.BRAND)
|
||||
hwObj.put("hardware", Build.HARDWARE)
|
||||
hwObj.put("fingerprint", Build.FINGERPRINT)
|
||||
json.put("hardware", hwObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get hardware info")
|
||||
}
|
||||
|
||||
// 3. System and Locale environment
|
||||
try {
|
||||
val sysObj = JSONObject()
|
||||
sysObj.put("sdkVersion", Build.VERSION.SDK_INT)
|
||||
sysObj.put("language", Locale.getDefault().language)
|
||||
sysObj.put("country", Locale.getDefault().country)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
sysObj.put("securityPatch", Build.VERSION.SECURITY_PATCH)
|
||||
} else {
|
||||
sysObj.put("securityPatch", "")
|
||||
}
|
||||
json.put("system", sysObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get system info")
|
||||
}
|
||||
|
||||
// 4. Memory Info (RAM)
|
||||
try {
|
||||
val memObj = JSONObject()
|
||||
val actManager = appContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memInfo = ActivityManager.MemoryInfo()
|
||||
actManager.getMemoryInfo(memInfo)
|
||||
memObj.put("totalRam", memInfo.totalMem)
|
||||
memObj.put("availRam", memInfo.availMem)
|
||||
json.put("memory", memObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get memory info")
|
||||
}
|
||||
|
||||
// 5. Storage Info
|
||||
try {
|
||||
val storageObj = JSONObject()
|
||||
val path = Environment.getDataDirectory()
|
||||
val stat = StatFs(path.path)
|
||||
val blockSize = stat.blockSizeLong
|
||||
val totalBlocks = stat.blockCountLong
|
||||
val availableBlocks = stat.availableBlocksLong
|
||||
|
||||
storageObj.put("totalInternal", totalBlocks * blockSize)
|
||||
storageObj.put("availInternal", availableBlocks * blockSize)
|
||||
json.put("storage", storageObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get storage info")
|
||||
}
|
||||
|
||||
// 6. Network state
|
||||
try {
|
||||
val netObj = JSONObject()
|
||||
val connManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
var networkType = "NONE"
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val activeNetwork = connManager.activeNetwork
|
||||
val capabilities = connManager.getNetworkCapabilities(activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
networkType = "WIFI"
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
networkType = "CELLULAR"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val activeNetworkInfo = connManager.activeNetworkInfo
|
||||
if (activeNetworkInfo != null && activeNetworkInfo.isConnected) {
|
||||
if (activeNetworkInfo.type == ConnectivityManager.TYPE_WIFI) {
|
||||
networkType = "WIFI"
|
||||
} else if (activeNetworkInfo.type == ConnectivityManager.TYPE_MOBILE) {
|
||||
networkType = "CELLULAR"
|
||||
}
|
||||
}
|
||||
}
|
||||
netObj.put("type", networkType)
|
||||
|
||||
var ipAddress = ""
|
||||
try {
|
||||
val interfaces = Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||
for (intf in interfaces) {
|
||||
val addrs = Collections.list(intf.inetAddresses)
|
||||
for (addr in addrs) {
|
||||
if (!addr.isLoopbackAddress) {
|
||||
val sAddr = addr.hostAddress ?: continue
|
||||
val isIPv4 = sAddr.indexOf(':') < 0
|
||||
if (isIPv4) {
|
||||
ipAddress = sAddr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ipAddress.isNotEmpty()) break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").w(e, "Failed to resolve IP address")
|
||||
}
|
||||
netObj.put("ipAddress", ipAddress)
|
||||
json.put("network", netObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get network status")
|
||||
}
|
||||
|
||||
// 7. Battery status
|
||||
try {
|
||||
val batObj = JSONObject()
|
||||
val batteryStatus: Intent? = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val level = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val scale = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
val batteryPct = if (level >= 0 && scale > 0) (level * 100 / scale.toFloat()).toInt() else -1
|
||||
batObj.put("level", batteryPct)
|
||||
|
||||
val status = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1) ?: -1
|
||||
val statusString = when (status) {
|
||||
android.os.BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
|
||||
android.os.BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging"
|
||||
android.os.BatteryManager.BATTERY_STATUS_FULL -> "full"
|
||||
android.os.BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging"
|
||||
else -> "unknown"
|
||||
}
|
||||
batObj.put("status", statusString)
|
||||
|
||||
val chargePlug = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_PLUGGED, -1) ?: -1
|
||||
val plugString = when (chargePlug) {
|
||||
android.os.BatteryManager.BATTERY_PLUGGED_AC -> "ac"
|
||||
android.os.BatteryManager.BATTERY_PLUGGED_USB -> "usb"
|
||||
android.os.BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
|
||||
else -> "none"
|
||||
}
|
||||
batObj.put("plugged", plugString)
|
||||
json.put("battery", batObj)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("DeviceInfoUtils").e(e, "Failed to get battery info")
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +35,8 @@ object GpioManager {
|
|||
Timber.tag("GPIO").i("Start GPIO listening thread")
|
||||
while (mIsRunning) {
|
||||
try {
|
||||
// 硬件轮询频率优化:50ms 既能保证响应速度,又能降低 CPU 消耗
|
||||
Thread.sleep(50)
|
||||
// 硬件轮询频率优化:150ms 既能保证响应速度,又能降低 CPU 消耗
|
||||
Thread.sleep(150)
|
||||
val manager = mZysjSystemManager ?: continue
|
||||
|
||||
// 获取 GPIO 1 的值 (0 按下, 1 松开)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.stand.standapp.utils
|
|||
|
||||
import android.content.Context
|
||||
import com.stand.standapp.AppConfig
|
||||
import com.stand.standapp.net.NetworkModule
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
|
|
@ -17,7 +18,7 @@ import kotlin.system.exitProcess
|
|||
object LogManager {
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private const val MAX_DAYS = 15
|
||||
private val client = OkHttpClient()
|
||||
private val client = NetworkModule.defaultClient
|
||||
private var logDir: File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
|
|
@ -98,19 +99,6 @@ object LogManager {
|
|||
onCrashDetected(null, thread.name, throwable)
|
||||
defaultHandler?.uncaughtException(thread, throwable) ?: exitProcess(1)
|
||||
}
|
||||
|
||||
// 2. 捕获主线程 Looper
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
while (true) {
|
||||
try {
|
||||
android.os.Looper.loop()
|
||||
} catch (e: Throwable) {
|
||||
onCrashDetected(null, "MainLooper", e)
|
||||
// 如果是 UnsatisfiedLinkError,这种错误无法恢复,必须抛出让系统处理
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanOldLogs(dir: File) {
|
||||
|
|
@ -199,6 +187,9 @@ object LogManager {
|
|||
private val fileNameFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
// 过滤掉高频的 VERBOSE 级别日志写入(例如录音帧),保留在控制台输出
|
||||
if (priority == android.util.Log.VERBOSE) return
|
||||
|
||||
executor.execute {
|
||||
try {
|
||||
val logFile = File(logDir, "${fileNameFormat.format(Date())}.log")
|
||||
|
|
|
|||
|
|
@ -149,6 +149,53 @@
|
|||
android:tint="#CBD5E1" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 演示界面入口卡片 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/item_demo_ui"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_developer_item"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@android:drawable/ic_menu_gallery"
|
||||
android:tint="#F59E0B" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="16dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="本地演示界面"
|
||||
android:textColor="#1E293B"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="启动本地 H5 打印及 PTT 控制演示页面"
|
||||
android:textColor="#94A3B8"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:src="@android:drawable/ic_media_next"
|
||||
android:tint="#CBD5E1" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 崩溃测试卡片 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/item_crash_test"
|
||||
|
|
|
|||
|
|
@ -268,17 +268,6 @@
|
|||
android:textStyle="bold"
|
||||
android:letterSpacing="0.2" />
|
||||
|
||||
<!-- 正式界面登录按钮 -->
|
||||
<Button
|
||||
android:id="@+id/btn_login_official"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/shape_button_secondary"
|
||||
android:text="正式界面登录"
|
||||
android:textColor="#3A7A9A"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- 退出应用 -->
|
||||
<Button
|
||||
android:id="@+id/btn_exit"
|
||||
|
|
|
|||
Loading…
Reference in New Issue