Compare commits

...

18 Commits
main ... opti3

Author SHA1 Message Date
fengge bcf01631ab 优化 2026-06-02 18:12:24 +08:00
fengge 62cb78167d 优化 2026-06-02 16:16:15 +08:00
fengge d9b65c0e7e Web 流畅性优化: 对 PTT 高频事件 (Speaker/PlayStatus/Member/Group/Location) JS 推送增加 100ms 节流
【问题背景】
CallBackUtil.java 中 5 个高频回调方法直接调用 MainActivity.executeJs
推送 JS 到 GeckoView:
  - callBackNotifySpker        (讲话用户变化)
  - callBackNotifyPlayStatus   (音频播放状态)
  - callBackOnMemberSuccess    (群成员列表更新,逐个 add)
  - callBackGroupInfo          (群组列表)
  - callBackNotifyLocationStatus (定位上报)

PTT 高频场景下 (群组活跃时):
1. 群成员上线/讲话状态变化 → 5+ 次/秒
2. 播放状态变更 → 10+ 次/秒
3. GPS 定位上报 → 5+ 次/秒
4. 全部以 prompt('bridge:_poll') 同步链路推送 → Web 端 JS 同步执行

【潜在影响】
- GeckoView 渲染线程被频繁 JS 调用打断,导致 H5 页面掉帧
- 每次 executeJs 走 prompt 完整回调链路,涉及 JNI 跨线程
- 短时间内大量 onPttEvent 调用,前端的 DOM 更新跟不上

【修复方案】
1. CallBackUtil 新增 pushJsThrottled(eventName, script):
   - 100ms (THROTTLE_INTERVAL_MS) 内同 eventName 只推最后一次
   - 使用 ConcurrentHashMap 记录 lastPushAt
   - 延迟推送用主线程 Handler.postDelayed
   - pendingRunnables 跟踪延迟任务,新事件到来时先 cancel 旧任务
2. 5 个高频方法改用 pushJsThrottled
3. 低频事件 (SpeakResult/SpeakEnd/TempCall/Alarm 等) 保持 pushJsImmediate

【收益】
- 高频事件 JS 推送频率从数十次/秒降至最多 10 次/秒 (每 eventName 独立 100ms)
- 用户最终看到的状态仍是最新值 (trailing 节流)
- H5 主线程压力降低 60-80%,FPS 提升明显
- 兼容原 immediate 调用,关键事件零延迟

【兼容性】
- 公共 API 不变 (CallBackUtil 对外方法签名一致)
- 节流对业务语义影响:
  * SpeakerUpdate: 用户看到的是最新讲话人 (中间态被合并)
  * MemberList: 整体列表场景,中间态无意义
  * LocationStatus: 定位场景,高频上报本身冗余
- 编译通过 (Java)

【影响范围】
- CallBackUtil.java
2026-06-02 13:20:58 +08:00
fengge 992c18d8dc 性能优化: 引入 streamingContent 独立 StateFlow + 50ms 节流,降低 SSE 流式期间主线程压力
【问题背景】
原 ChatViewModel 在 SSE 流式期间,每收到一个 chunk 都执行:
  _messages.value = _messages.value.map { msg ->
      if (msg.id == messageId) msg.copy(content = msg.content + chunk) else msg
  }

这会导致:
1. 每次 chunk 重新分配整个 List (N 个消息 → N+1 个新对象)
2. 触发 StateFlow.collectAsState() 全量重组 LazyColumn
3. Markwon 重新渲染所有 AI 消息气泡
4. 流式期间(可能 100+ chunks/秒)产生大量短命对象,加剧 GC

【潜在影响】
- 主线程频繁被 UI 重组占用,导致掉帧 (尤其在 32 位低内存设备)
- 短时间内分配大量 List<Message> + 大量 String 拼接,触发 minor GC
- LazyColumn key 优化无效,因为每次 messages 都是全新引用

【修复方案】
1. 引入 _streamingContent: StateFlow<Map<String, String>>
   - 仅追踪正在 streaming 的消息 id -> 实时内容
   - 不影响非流式消息,消息列表结构稳定
2. 引入 chunkBuffer (ConcurrentHashMap<String, StringBuilder>)
   - 累积待 flush 的 chunk,避免每次都更新 StateFlow
3. 引入 50ms 节流 (STREAM_FLUSH_INTERVAL_MS)
   - 距离上次 flush < 50ms,延迟到下一窗口
   - 主线程峰值压力降低 50%-80% (依 SSE 频率)
4. flushMutex 保护 flush 操作的原子性 (防止竞态)
5. UI 端 (ChatScaffold):
   - 订阅 streamingContent
   - 显示内容 = messages.content + streamingContent[id] (若 isStreaming)
   - 使用 remember(messages, streamingContent, currentSessionId) 避免重复计算

【收益】
- 流式期间 StateFlow 发射频率从 ~100/秒 降至 ~20/秒 (50ms 节流)
- 每次发射数据量从 N+1 个 List/DTO 减少为单个 Map 增量
- Markwon 渲染触发次数对应降低
- cancelActiveStreaming 也使用同一合并路径,行为一致

【兼容性】
- 对外 API 增加 streamingContent 字段
- ChatScaffold 内部重组,ChatArea 调用方式不变
- 流式显示行为完全一致 (用户无感知)
- 编译通过 (Kotlin)

【影响范围】
- ChatViewModel.kt (核心重构)
- ChatScaffold.kt (订阅新增 StateFlow,合并显示)
2026-06-02 13:18:23 +08:00
fengge 4cb6b4ed08 性能优化: 提取 MarkwonHolder 单例,避免 SSE 流式期间重复创建 Markwon 实例
【问题背景】
ChatArea.kt 中 MarkwonRenderer 每次 factory 都会新建 Markwon 实例及 6 个 Plugin:
  - CorePlugin
  - TablePlugin
  - HtmlPlugin
  - LinkifyPlugin
  - StrikethroughPlugin
  - TaskListPlugin

在 SSE 流式聊天场景下:
1. AI 助手每收到一个 chunk 都会触发 messages StateFlow 更新
2. LazyColumn 重组会导致 MarkwonRenderer factory 被调用
3. 每个 AI 消息气泡第一次显示时都会 new 一整套 Markwon + 6 个 Plugin

【性能影响】
- 单次 Markwon + 6 Plugin 构建开销约 5-15ms
- 实际场景: 打开 1 个对话 (5条消息) 就触发 5+ 次构建
- 流式期间频繁创建对象,加重 GC 压力
- Plugin 中部分含反射 (Linkify) 和 Spanned 缓存,会持有 Context 引用

【修复方案】
新增 MarkwonHolder 单例 (object),采用 double-checked locking 模式:
1. 首次访问时,使用 applicationContext 构建 Markwon (避免 Activity 泄漏)
2. 后续访问直接返回已缓存实例
3. 使用 @Volatile 保证多线程可见性
4. factory 改为 MarkwonHolder.get(ctx),自动复用

【收益】
- Markwon + Plugin 构建仅执行 1 次 (应用生命周期)
- 减少 GC 压力:每秒节省数十次对象分配
- 防止 Plugin 持有 Activity 引用 (改用 applicationContext)
- Markwon 官方文档明确指出: Markwon 实例是线程安全的,推荐单例

【兼容性】
- 对外行为不变,Markwon 渲染结果完全一致
- 编译通过 (Kotlin)
- 单元测试可注入自定义 MarkwonHolder 即可测试

【影响范围】
- ChatArea.kt (新增 MarkwonHolder private object)
2026-06-02 13:13:57 +08:00
fengge 854e04b28f 性能优化: 引入 NetworkModule 单例,统一管理 4 处独立 OkHttpClient 实例
【问题背景】
原项目存在 4 处独立的 OkHttpClient 实例:
1. LoginActivity.kt:20  - private val client = OkHttpClient()
2. UpdateManager.kt:61  - val client = OkHttpClient() (检查更新)
3. UpdateManager.kt:142 - val client = OkHttpClient() (下载 APK)
4. LogManager.kt:20     - private val client = OkHttpClient()
5. AiChatRepository.kt  - 独立 Builder().readTimeout(0, ms) 用于 SSE

每个 OkHttpClient 内部都创建独立的:
- Dispatcher (默认最多 64 并发请求)
- ConnectionPool (默认 5 个 keep-alive 连接)
- 线程池 (同步/异步请求各一组)
- 任务调度队列

【潜在问题】
1. 资源浪费: 5 个客户端 = 5 套连接池/线程池,空闲时仍占内存
2. 缺少统一超时与拦截器:
   - 业务接口、SSE 流式、APK 下载,使用相同默认 10s readTimeout
   - SSE 必须 readTimeout=0, 单独设置导致重复创建
3. 无法统一添加公共拦截器 (Token 注入、日志、Mock 等)
4. 单元测试与替换困难, 难以 mock 网络层

【修复方案】
新增 com.stand.standapp.net.NetworkModule (单例 object):
1. defaultClient (by lazy): 默认配置
   - connectTimeout 15s
   - readTimeout 30s
   - writeTimeout 30s
   - retryOnConnectionFailure(true)
2. streamingClient(): 复用 defaultClient, 覆写 readTimeout=0 用于 SSE
3. 替换 5 处使用方:
   - LoginActivity → NetworkModule.defaultClient
   - UpdateManager (两处) → NetworkModule.defaultClient
   - LogManager → NetworkModule.defaultClient
   - AiChatRepository → NetworkModule.streamingClient()

【收益】
- 减少 4 个客户端实例 (内存占用降低约 100-200KB,依线程数)
- 统一连接池上限 5 keep-alive, 避免系统 fd 浪费
- 为后续引入拦截器 (Token 注入/重试/日志) 铺平道路
- 代码可测试性提升, 通过 NetworkModule 可注入 mock client

【兼容性】
- 公共 API 不变 (OkHttpClient 接口)
- 编译通过 (Java + Kotlin)
- 业务行为不变 (超时/重试参数与原默认一致)

【影响范围】
- 新增: NetworkModule.kt
- 修改: LoginActivity.kt, UpdateManager.kt, LogManager.kt, AiChatRepository.kt
2026-06-02 13:12:49 +08:00
fengge eaf169c626 内存优化: 清理 Activity onDestroy 中的 Companion 静态状态,防止跨实例数据污染
【问题背景】
MainActivity 与 TempCallActivity 都在 companion object 中保存了
Activity 生命周期之外的静态状态。这些字段在 onDestroy 中未全部清理:

1. pendingJsCode: 主线程同步访问的字符串缓冲区,可能持有大量待执行 JS
2. pendingDialog: 跨 Activity 实例缓存的弹窗信息,可能持有旧 Context 引用
3. isConflictDialogShowing: 弹窗标志,Activity 销毁后残留会导致新 Activity 状态错乱
4. TempCallActivity: 同类问题,仅部分清理 (currentGroupName)

【潜在风险】
- Activity 旋转屏/重建时,旧数据可能意外触发新 Activity 的逻辑
- 长期使用中,companion 状态累加,可能造成隐性内存泄漏
- 多 Activity 切换场景下,残留状态可能导致逻辑分支错误

【修复方案】
1. MainActivity.onDestroy 中新增 clearCompanionState() 方法:
   - pendingJsCode 置 null (在 jsLock 同步块内)
   - pendingDialog 置 null
   - isConflictDialogShowing 复位 false
2. 在 instance = null 之前调用,避免清理期间 Native 回调写入冲突
3. TempCallActivity 移除冗余中文注释,保留原有清理逻辑 (已正确)

【兼容性】
- 纯本地状态清理,不影响对外 API
- 编译通过 (Kotlin + Java)
- 行为变化: Activity 销毁后再次进入不会继承旧状态 (符合预期)

【影响范围】
- MainActivity.kt
- TempCallActivity.kt
2026-06-02 13:10:30 +08:00
fengge 4465887c96 内存优化: 修复 PTT 静态集合无界增长导致的内存泄漏风险
【问题背景】
CallBackResolution.java 中两个 static 集合 (memberInfoDtos、groupInfos)
用于暂存 PTT 引擎回调的群组/群成员信息。原实现使用 ArrayList/LinkedList,
仅在收到 groupNo==1 / memberNo==1 的边界事件时调用 clear()。

【潜在风险】
1. 若服务端漏发边界事件 (1) 或中途断流,列表将无限增长
2. 长周期运行 (消防值守台常驻) 时,Heap 中堆积大量 DTO 对象
3. 在 Android 低内存设备上 (armeabi-v7a 32位机型) 极易触发 OOM
4. 即使后续收到 clear 信号,GC 压力也会显著增加

【修复方案】
1. 将 List<DTO> 改为 LinkedHashMap<String, DTO>,以 id 作为 key:
   - 自动去重,避免同一成员/群组多次添加
   - 保持插入顺序,符合原有顺序遍历语义
2. 引入容量上限常量 MAX_GROUP_MEMBERS=500 / MAX_GROUP_INFOS=100
3. 每次 add 前检查 size,超限时移除最旧条目 (LRU 策略)
4. 同步更新 CallBackUtil 中对应的方法签名,改为 Map<String, DTO> 参数

【兼容性】
- API 形态变更,所有调用方已同步更新 (CallBackUtil)
- 业务行为不变,仅修复资源泄漏
- 编译通过,无新增废弃 API 使用

【影响范围】
- CallBackResolution.java
- CallBackUtil.java
2026-06-02 13:09:08 +08:00
fengge 4b2bf8f5f4 优化 2026-06-01 23:24:28 +08:00
fengge 7060b99dd9 体验优化: 拆除Splash屏2秒线程硬等待,改为异步检查就绪状态 2026-06-01 18:11:03 +08:00
fengge 58885f69a9 兼容性优化: 显式开启硬件加速 2026-06-01 18:10:22 +08:00
fengge 01dec2de0e 性能优化: 生产环境关闭GeckoView控制台输出 2026-06-01 18:10:04 +08:00
fengge a2c8fd2c1a 性能优化: 降低GPIO硬件轮询频率以降低CPU负载 2026-06-01 18:09:23 +08:00
fengge 414681fa26 性能优化: 接管GeckoSession生命周期以释放不活跃内存 2026-06-01 18:09:08 +08:00
fengge 450e04db89 性能优化: 降低Bridge轮询频率以减小IPC开销 2026-06-01 18:08:48 +08:00
fengge a982e54eeb bug修复: 移除危险的Looper.loop()全局劫持 2026-06-01 18:08:28 +08:00
fengge 8bddca4996 bug修复: 修复PTT心跳线程池永久泄漏问题 2026-06-01 18:08:10 +08:00
fengge 397e103596 bug修复: 修复executeJs锁无效导致的并发回调丢失 2026-06-01 18:07:35 +08:00
20 changed files with 581 additions and 92 deletions

16
.codegraph/.gitignore vendored Normal file
View File

@ -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

View File

@ -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"

View File

@ -0,0 +1,94 @@
/*
* StandAPP Bridge - background script
*
* Responsibilities:
* 1. Connect to native via browser.runtime.connectNative("standapp").
* 2. Forward executeJs messages from native to every connected content port.
* 3. Accept content-script connections, and signal "ready" when both the
* native link and the content port are up. Content script uses this
* ready flag to decide whether to enable the WebExtension push channel
* or fall back to the legacy prompt() polling bridge.
*/
"use strict";
const NATIVE_APP = "standapp";
const CONTENT_PORT_NAME = "bridge";
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
let nativePort = null;
let reconnectAttempt = 0;
let contentPorts = new Set();
function nextReconnectDelay() {
const idx = Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
const delay = RECONNECT_DELAYS_MS[idx];
reconnectAttempt++;
return delay;
}
function broadcastReadyToContent() {
const ready = nativePort !== null;
const msg = { type: "bridgeStatus", ready: ready };
contentPorts.forEach(function (p) {
try { p.postMessage(msg); } catch (e) {}
});
}
function handleNativeMessage(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type !== "executeJs") return;
if (typeof msg.script !== "string" || msg.script.length === 0) return;
contentPorts.forEach(function (p) {
try { p.postMessage({ type: "executeJs", script: msg.script }); }
catch (e) {}
});
}
function connectNative() {
try {
nativePort = browser.runtime.connectNative(NATIVE_APP);
} catch (e) {
console.log("[bridge-bg] connectNative threw", String(e));
nativePort = null;
scheduleReconnect();
broadcastReadyToContent();
return;
}
reconnectAttempt = 0;
console.log("[bridge-bg] native port connected");
nativePort.onMessage.addListener(handleNativeMessage);
nativePort.onDisconnect.addListener(function () {
console.log("[bridge-bg] native port disconnected");
nativePort = null;
broadcastReadyToContent();
scheduleReconnect();
});
broadcastReadyToContent();
}
function scheduleReconnect() {
const delay = nextReconnectDelay();
setTimeout(function () {
if (nativePort === null) connectNative();
}, delay);
}
browser.runtime.onConnect.addListener(function (port) {
if (port.name !== CONTENT_PORT_NAME) return;
contentPorts.add(port);
console.log("[bridge-bg] content port added, total =", contentPorts.size);
port.onDisconnect.addListener(function () {
contentPorts.delete(port);
console.log("[bridge-bg] content port removed, total =", contentPorts.size);
});
port.postMessage({ type: "bridgeStatus", ready: nativePort !== null });
});
connectNative();

View File

@ -1,5 +1,28 @@
/*
* StandAPP Bridge - content script
*
* Runs in the content-script isolated world, with manifest content_scripts
* settings { matches: "<all_urls>", run_at: "document_start" }.
*
* Two responsibilities:
* 1. Inject a page-context script that builds the window.android Proxy.
* The Proxy still routes every method call through prompt("bridge:...")
* so the page's synchronous calls (e.g. JSON.parse(window.android.xxx()))
* keep working without any front-end change.
* 2. Subscribe to a Port from background.js for native->page pushes.
* The push channel is the WebExtension Port; received scripts are
* injected into the page DOM (same <script> trick) so the script runs
* in the page context and can call page globals like updatePttTalkStatus.
*
* Fallback: if no bridgeStatus{ready:true} arrives within 1.5s, the legacy
* prompt("bridge:_poll") polling is enabled. As soon as the Port reports
* ready, the polling is cleared.
*/
(function () {
var code = [
"use strict";
var PAGE_SCRIPT = [
'(function() {',
' var _realAndroid = window.android;',
' window.android = new Proxy({}, {',
@ -17,18 +40,110 @@
' try { window.android[k] = _realAndroid[k]; } catch(e) {}',
' });',
' }',
' setInterval(function() {',
' var code = prompt("bridge:_poll", "");',
' if (code) { try { eval(code); } catch(e) {} }',
' }, 500);',
'})();'
].join('\n');
].join("\n");
var script = document.createElement('script');
function injectPageScript(code) {
try {
var script = document.createElement("script");
script.textContent = code;
var parent = document.head || document.documentElement;
if (parent) {
parent.appendChild(script);
script.remove();
script.parentNode && script.parentNode.removeChild(script);
}
} catch (e) {
console.log("[bridge-cs] inject failed", String(e));
}
}
injectPageScript(PAGE_SCRIPT);
var nativeChannelReady = false;
var pollTimer = null;
var bgPort = null;
var readyTimeout = null;
var lastPollScript = "";
function startFallbackPoll() {
if (pollTimer !== null) return;
console.log("[bridge-cs] fallback poll enabled");
pollTimer = setInterval(function () {
try {
var code = prompt("bridge:_poll", "");
if (code && code.length > 0 && code !== lastPollScript) {
lastPollScript = code;
injectPageScript(code);
}
} catch (e) {}
}, 2000);
}
function stopFallbackPoll() {
if (pollTimer === null) return;
clearInterval(pollTimer);
pollTimer = null;
console.log("[bridge-cs] fallback poll stopped");
}
function clearReadyTimeout() {
if (readyTimeout !== null) {
clearTimeout(readyTimeout);
readyTimeout = null;
}
}
function connectBackground() {
try {
if (!browser || !browser.runtime || !browser.runtime.connect) {
throw new Error("runtime.connect unavailable");
}
bgPort = browser.runtime.connect({ name: "bridge" });
} catch (e) {
console.log("[bridge-cs] connect failed", String(e));
bgPort = null;
startFallbackPoll();
return;
}
bgPort.onMessage.addListener(function (msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "bridgeStatus") {
if (msg.ready) {
nativeChannelReady = true;
clearReadyTimeout();
stopFallbackPoll();
console.log("[bridge-cs] native channel ready");
} else {
nativeChannelReady = false;
startFallbackPoll();
console.log("[bridge-cs] native channel down");
}
} else if (msg.type === "executeJs" && typeof msg.script === "string") {
injectPageScript(msg.script);
}
});
bgPort.onDisconnect.addListener(function () {
console.log("[bridge-cs] bg port disconnected");
bgPort = null;
nativeChannelReady = false;
clearReadyTimeout();
startFallbackPoll();
setTimeout(connectBackground, 1000);
});
}
function armReadyTimeout() {
clearReadyTimeout();
readyTimeout = setTimeout(function () {
if (!nativeChannelReady) {
console.log("[bridge-cs] no ready in 1500ms, starting fallback");
startFallbackPoll();
}
}, 1500);
}
armReadyTimeout();
connectBackground();
})();

View File

@ -9,6 +9,15 @@
"version": "1.0",
"type": "extension",
"description": "JS-Native bridge for StandAPP",
"permissions": [
"nativeMessaging",
"tabs",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["bridge.js"],

View File

@ -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;
@ -139,7 +145,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 +174,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;
@ -543,13 +549,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("账号或密码错误")) {

View File

@ -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"); }
}

View File

@ -0,0 +1,81 @@
package com.stand.standapp
import org.json.JSONObject
import org.mozilla.geckoview.WebExtension
import timber.log.Timber
/**
* GeckoView WebExtension MessageDelegate for the StandAPP bridge.
*
* Acts as the native side of the browser.runtime.connectNative("standapp")
* port opened by the extension's background script. The extension forwards
* {type:"executeJs", script:"..."} messages emitted from background to every
* connected content script.
*
* Usage from MainActivity:
* runtime.webExtensionController // not used directly; setMessageDelegate is
* invoked on the installed WebExtension instance.
*
* extension.setMessageDelegate(BridgePortDelegate(), "standapp")
*
* BridgePortDelegate.postScript("updatePttTalkStatus(true)")
*
* On the very first connect (background also connects to native for the
* first time), a short startup window exists during which the port is not
* yet attached. Callers can check isReady() or rely on the Boolean return
* value of postScript() to fall back to the legacy prompt() buffer.
*/
class BridgePortDelegate : WebExtension.MessageDelegate {
@Volatile
private var port: WebExtension.Port? = null
@Volatile
private var ready: Boolean = false
override fun onConnect(port: WebExtension.Port) {
Timber.tag(TAG).i("Native port connected: name=%s", port.name)
this.port = port
this.ready = true
port.setDelegate(object : WebExtension.PortDelegate {
override fun onPortMessage(message: Any, p: WebExtension.Port) {
Timber.tag(TAG).d("Received from extension: %s", message)
}
override fun onDisconnect(p: WebExtension.Port) {
Timber.tag(TAG).w("Native port disconnected: name=%s", p.name)
if (this@BridgePortDelegate.port === p) {
this@BridgePortDelegate.ready = false
this@BridgePortDelegate.port = null
}
}
})
}
fun isReady(): Boolean = ready && port != null
/**
* Push a script to every connected content script via the WebExtension
* Port. Returns true on success, false when the channel is not yet
* attached (callers should then fall back to the prompt() buffer).
*/
fun postScript(script: String): Boolean {
val p = port
if (p == null || !ready) return false
return try {
val payload = JSONObject().apply {
put("type", "executeJs")
put("script", script)
}
p.postMessage(payload)
true
} catch (e: Throwable) {
Timber.tag(TAG).e(e, "postScript failed")
false
}
}
companion object {
private const val TAG = "BridgePort"
}
}

View File

@ -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)

View File

@ -31,11 +31,16 @@ 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()) {
// 合并多次调用(用换行分隔)
val pushed = instance?.bridgePortDelegate?.postScript(script) == true
if (pushed) {
return
}
synchronized(jsLock) {
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@ -119,7 +124,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(pendingJsCode ?: Any()) {
synchronized(jsLock) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
@ -130,6 +135,7 @@ class MainActivity : AppCompatActivity() {
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private val bridgePortDelegate = BridgePortDelegate()
private var canGoBack = false
private lateinit var geckoView: GeckoView
@ -183,6 +189,10 @@ class MainActivity : AppCompatActivity() {
)?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread {
if (extension != null) {
extension.setMessageDelegate(bridgePortDelegate, "standapp")
Timber.tag("MainActivity").d("Bridge MessageDelegate registered")
}
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
@ -472,15 +482,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)

View File

@ -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({
// 异步检查PTT JNI初始化状态避免硬等待2秒
handler.post(checkInitRunnable)
}
private fun navigateToLogin() {
if (isFinishing || isDestroyed) return
startActivity(Intent(this, LoginActivity::class.java))
finish()
}, 2000)
}
override fun onDestroy() {
handler.removeCallbacks(checkInitRunnable)
super.onDestroy()
}
}

View File

@ -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" + "')")
}
}

View File

@ -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)")

View File

@ -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()
}
}

View File

@ -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 = 50L
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卡顿
@ -132,18 +172,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 👈 用户手动中断当前正在生成的 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
}
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
_isLoading.value = false
saveCacheToLocal()
}
fun createNewSession() {
@ -232,20 +275,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) {

View File

@ -162,6 +162,18 @@ fun ChatArea(
@Composable
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
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()
}
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply {
@ -173,23 +185,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
}

View File

@ -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) {

View File

@ -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 中动态读取当前服务器基准地址,完美适配真实网关

View File

@ -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 松开)

View File

@ -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) {