Compare commits

...

24 Commits
main ... opti6

Author SHA1 Message Date
fengge 8a7531428b Merge remote-tracking branch 'origin/opti4' into opti6 2026-06-16 19:39:17 +08:00
fengge d937ad854d 优化2 2026-06-16 19:28:16 +08:00
fengge ea7c977f62 优化2 2026-06-16 19:23:58 +08:00
fengge 9ed1e31700 优化(架构与渲染): 提升流式更新性能并修复 SP 持久化阻塞主线程
1. 将 ChatViewModel 中 AI 的流式刷新频率 (STREAM_FLUSH_INTERVAL_MS) 从 50ms 调整为 150ms,以降低低端机中高频触发文本重绘(Text Layout)的沉重 CPU 负担,肉眼仍旧保持平滑。
2. 将 SharedPreferences 持久化全量对话记录(saveCacheToLocal)的操作,利用协程移动至 Dispatchers.IO 线程中,防止读写巨大 JSON 字符串时彻底堵死主线程引发应用 ANR 或冻结。
2026-06-03 14:48:58 +08:00
fengge e266a4e690 优化(UI): 修复 Markdown 渲染导致的内存抖动
将 Markwon 解析引擎的 builder 创建逻辑提取到 LazyColumn 的外部并记忆化(remember),将其作为参数传入每一条消息的渲染组件中。
这避免了在聊天列表滚动时,每条消息都会高频实例化一个极度消耗资源的全新的 Markwon 引擎,从而极大减少了短命对象的创建和 GC 压力。
2026-06-03 14:48:58 +08:00
fengge 89f1df9a9b 优化(UI): 修复 Compose 的悬浮窗灾难性重组
利用延迟读取(Deferred State Reading)的特性,将 fabOffsetX 等坐标变量的读取放置在 Modifier.offset 的 lambda 内部。
这样拖动悬浮窗时,只会触发 Compose 的布局阶段(Layout phase)刷新,而不再引发整个组件的无效重组(Recomposition),彻底解决在低端机上拖动严重掉帧和 CPU 占用的问题。
2026-06-03 14:48:56 +08:00
fengge 96068d56b2 优化(日志): 修复极端的日志 I/O 性能问题
1. 过滤掉高频的 VERBOSE 级别日志写入文件(例如录音帧),但依然让其能够在控制台中输出。
2. 保持日志的正常记录逻辑不变,仅作频率控制拦截,减少低端机因为频繁磁盘读写而引发的阻塞、卡顿与 OOM 问题。
2026-06-03 14:48:56 +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
21 changed files with 595 additions and 141 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

@ -20,7 +20,7 @@
' setInterval(function() {',
' var code = prompt("bridge:_poll", "");',
' if (code) { try { eval(code); } catch(e) {} }',
' }, 500);',
' }, 2000);',
'})();'
].join('\n');

View File

@ -524,13 +524,80 @@
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;

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

@ -378,22 +378,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")

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,10 +31,12 @@ 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
}
@ -119,7 +121,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(pendingJsCode ?: Any()) {
synchronized(jsLock) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
@ -472,15 +474,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 = 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,6 +139,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun saveCacheToLocal() {
// 将数据保存放到IO线程中防止SharedPreferences写大文本阻塞主线程卡顿
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
// 保存会话列表
val sessionsArray = JSONArray()
@ -129,21 +171,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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
}
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
_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) {

View File

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

View File

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

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

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

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