Compare commits

...

69 Commits
main ... 7.19

Author SHA1 Message Date
fengge 7376b902e9 声音播放 2026-07-24 12:02:55 +08:00
fengge 5f09d0646f 声音播放 2026-07-24 12:00:19 +08:00
fengge 55094fc24d 声音播放 2026-07-24 11:33:49 +08:00
fengge 6e1ff7fb56 声音播放 2026-07-24 11:12:48 +08:00
fengge 24df4cb070 临时群调整 2026-07-22 11:15:51 +08:00
fengge d570ecb57f 临时群调整 2026-07-22 10:56:03 +08:00
844143714@qq,com f7def3a7f6 注销 2026-07-19 04:01:37 +08:00
844143714@qq,com 65943334b1 注销 2026-07-19 03:37:54 +08:00
844143714@qq,com f840740a1a 注销 2026-07-19 03:20:33 +08:00
844143714@qq,com 24c8289a0d 注销 2026-07-19 03:11:20 +08:00
fengge a6cd238330 版本 2026-07-15 13:47:21 +08:00
fengge f3e6aae9c3 版本 2026-07-15 12:33:32 +08:00
fengge 75909d953c 版本 2026-07-15 12:14:39 +08:00
fengge 850550c392 版本 2026-07-15 11:23:18 +08:00
fengge 776eb9daae 版本 2026-07-15 10:54:50 +08:00
fengge b451e694f4 优化滚动条 2026-07-08 16:28:58 +08:00
fengge 8fe4a28f4c 优化分辨率 2026-07-08 12:29:12 +08:00
fengge a748d2b90b 优化分辨率 2026-07-08 12:20:15 +08:00
fengge 2e1f115c19 临时群 2026-07-08 11:03:54 +08:00
fengge 1d615bfafa 临时群,声音 2026-07-06 13:42:59 +08:00
fengge a74cdb7769 临时群,声音 2026-07-06 13:32:22 +08:00
fengge f41557db0e feat: fix potential null groupName and implement pocGid fallback callbacks 2026-07-06 13:28:02 +08:00
fengge e807974315 style: optimize pull info typography and confirm button layout 2026-07-06 13:26:16 +08:00
fengge 1733b2a971 docs: add design spec for temp call UI and GID fallback optimization 2026-07-06 13:25:30 +08:00
fengge 967700ff58 临时群,声音 2026-07-06 13:22:28 +08:00
fengge e9147b1527 临时群,声音 2026-07-06 12:45:28 +08:00
fengge cc73549ebd feat: add getTempGroupId bridge method in AndroidInterface 2026-07-06 12:43:18 +08:00
fengge 38c7494d73 feat: add currentTempGroupId caching and start/end lock JS callbacks in TempCallActivity 2026-07-06 12:42:55 +08:00
fengge bfa19d36ac docs: add design spec for temp call gid cache and callbacks 2026-07-06 12:42:11 +08:00
fengge d7e9b29513 feat: implement MediaPlayer voice alert and confirm-to-mute logic 2026-07-06 12:19:28 +08:00
fengge 59f48520a2 style: add confirm button to temp call layout 2026-07-06 11:05:56 +08:00
fengge f01cc99952 feat: add local tts audio resource pull_alert.wav 2026-07-06 11:05:38 +08:00
fengge e5a6d073cd docs: add design spec for temp call voice alert 2026-07-06 11:03:28 +08:00
fengge b3c7e243b5 缓存上次加入的群组 2026-07-01 09:43:44 +08:00
fengge 923c88c595 屏幕分辨率适配 2026-07-01 09:14:55 +08:00
fengge 74b10cd583 缓存上次加入的群组 2026-06-30 17:35:49 +08:00
fengge 2e3de6b968 缓存上次加入的群组 2026-06-30 16:56:36 +08:00
fengge b0a5eb7234 强制退出 2026-06-30 16:14:56 +08:00
fengge b12cefeb5a 增加登录限制 2026-06-30 12:27:44 +08:00
fengge 71ba9f3e77 缓存上次加入的群组 2026-06-30 12:25:20 +08:00
fengge 15b04be06f 演示界面调整 2026-06-30 12:10:27 +08:00
fengge 9340aa319d 对讲群组的选中状态是没有音浪变化的,要按住手咪说话之后才会出现音浪效果。(已添加群组通话状态监听)现在处理之后还有1-2秒延迟,
调整为500ms循环检查
2026-06-30 10:44:05 +08:00
fengge 4b10fea5b5 注销功能 2026-06-30 10:32:56 +08:00
fengge c531965106 注销功能 2026-06-18 16:09:53 +08:00
fengge 9d5f4dc3a9 强制退出 2026-06-18 15:13:18 +08:00
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
38 changed files with 1866 additions and 380 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

@ -11,8 +11,8 @@ android {
applicationId = "com.stand.standapp"
minSdk = 24
targetSdk = 34
versionCode = 13
versionName = "1.0.12"
versionCode = 14
versionName = "1.0.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@ -12,6 +12,7 @@
-dontwarn com.google.android.material.snackbar.**
-dontwarn java.beans.**
-dontwarn org.yaml.snakeyaml.**
-keep class org.yaml.snakeyaml.** { *; }
# OkHttp 混淆规则
-keepattributes Signature

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

@ -113,12 +113,18 @@
</div>
<div style="margin-bottom: 20px;">
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">App 弹窗交互</div>
<div style="font-size: 13px; font-weight: bold; color: #64748b; margin-bottom: 8px;">App 弹窗与音频交互</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #e11d48;" onclick="triggerRestartDialog()">触发重启提示</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #0d9488;" onclick="triggerJsonDialog()">发送 JSON 弹窗</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #8b5cf6;" onclick="playNativeAlert()">播放提示音</button>
<button class="btn" style="margin:0; padding:10px; font-size:13px; background: #475569;" onclick="stopNativeAlert()">停止播放</button>
</div>
<div style="margin-top: 8px;">
<span class="ptt-label">提示音播放次数:</span>
<input type="number" id="alert-count" class="ws-input" value="3" style="margin-bottom: 5px;">
<span class="ptt-label">提示音音量 (0.0 - 1.0):</span>
<input type="text" id="alert-volume" class="ws-input" value="1.0" style="margin-bottom: 5px;">
<span class="ptt-label">提示标题:</span>
<input type="text" id="dialog-title" class="ws-input" value="系统通知" style="margin-bottom: 5px;">
<span class="ptt-label">提示内容:</span>
@ -140,6 +146,8 @@
<button class="btn" style="background: #10B981;" onclick="callUploadLogs()">4. 上传系统日志</button>
<button class="btn" style="background: #64748b;" onclick="callAbout()">5. 关于应用</button>
<button class="btn btn-red" onclick="callExit()">6. 退出应用</button>
<button class="btn" style="background: #e11d48;" onclick="callForceExit()">7. 强制退出</button>
<button class="btn btn-orange" onclick="callLogout()">8. 注销登录</button>
<!-- WebSocket 控制区 -->
<div class="ws-card">
@ -236,6 +244,7 @@
<button class="btn ptt-btn ptt-action-btn" style="background: #0ea5e9;" onclick="pttGetCurrentGroup()">当前群组</button>
<button class="btn ptt-btn ptt-action-btn btn-orange" onclick="pttForceRelease()" disabled>强制释放</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #000; color: #fca5a5;" onclick="pttSimulateConflict()" disabled>模拟冲突</button>
<button class="btn ptt-btn ptt-action-btn" style="background: #10b981;" onclick="queryTempGroupId()">查询临时群 GID</button>
</div>
<div id="ptt-result" class="log-container" style="background: #0f172a; margin-top: 10px; height: 120px;">
@ -519,18 +528,101 @@
}
}
function callForceExit() {
if (window.android && window.android.forceExit) {
window.android.forceExit();
} else {
alert("App 强制退出接口不可用");
}
}
function callLogout() {
if (window.android && window.android.logout) {
window.android.logout();
} else {
alert("App 注销接口不可用");
}
}
function callAbout() {
if (window.android && window.android.getAboutInfo) {
var infoStr = window.android.getAboutInfo();
try {
var info = JSON.parse(infoStr);
var html = '<strong>应用名称:</strong> ' + info.appName + '<br>' +
'<strong>版本号:</strong> <span style="color: #3b82f6;">' + info.versionName + '</span><br>' +
'<strong>设备型号:</strong> ' + info.model + '<br>' +
'<strong>系统版本:</strong> Android ' + info.osVersion + '<br>' +
'<strong>CPU 架构:</strong> ' + info.cpuAbi + '<br>' +
'<strong>运行模式:</strong> ' + (info.is64Bit ? "64位" : "32位兼容模式");
document.getElementById('aboutBody').innerHTML = html;
var friendlyNames = {
appName: '应用名称',
versionName: '版本号',
model: '设备型号',
osVersion: '系统版本',
cpuAbi: 'CPU 架构',
is64Bit: '运行模式',
screen: '屏幕与显示',
width: '宽度像素',
height: '高度像素',
density: '屏幕密度',
densityDpi: '屏幕DPI',
physicalSize: '物理尺寸 (英寸)',
hardware: '硬件与厂商',
manufacturer: '制造商',
brand: '品牌',
fingerprint: '系统指纹',
system: '系统环境',
sdkVersion: 'SDK 版本',
language: '系统语言',
country: '国家代码',
securityPatch: '安全补丁',
memory: '内存信息',
totalRam: '总内存 (RAM)',
availRam: '可用内存',
storage: '存储信息',
totalInternal: '总内部存储',
availInternal: '可用内部存储',
network: '网络状态',
type: '网络类型',
ipAddress: 'IP 地址',
battery: '电池状态',
level: '电量百分比',
status: '充电状态',
plugged: '供电类型'
};
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function buildHtml(data, prefix) {
var keys = Object.keys(data);
var res = '';
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var val = data[key];
var label = friendlyNames[key] || key;
var indent = prefix ? 'margin-left: 15px;' : '';
if (val !== null && typeof val === 'object') {
res += '<div style="' + indent + 'margin-top: 8px; margin-bottom: 4px; border-bottom: 1px dashed #eee; padding-bottom: 2px;"><strong>' + label + '</strong></div>';
res += buildHtml(val, true);
} else {
var displayVal = val;
if (key === 'is64Bit') {
displayVal = val ? '64位' : '32位兼容模式';
} else if (key === 'totalRam' || key === 'availRam' || key === 'totalInternal' || key === 'availInternal') {
displayVal = formatBytes(val);
} else if (key === 'level') {
displayVal = val >= 0 ? val + '%' : '未知';
}
res += '<div style="' + indent + 'line-height: 1.6;"><strong>' + label + ':</strong> ' + displayVal + '</div>';
}
}
return res;
}
document.getElementById('aboutBody').innerHTML = buildHtml(info, false);
document.getElementById('aboutModal').style.display = 'flex';
} catch (e) {
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;
@ -1058,6 +1150,42 @@
showPttResult("已发送 AT 指令: " + cmd);
}
}
// 注册临时群锁定回调
window.startPullGroupLock = function(gid) {
showPttResult("收到临时群锁定回调 startPullGroupLock, GID: " + gid);
};
window.endPullGroupLock = function() {
showPttResult("收到临时群解锁回调 endPullGroupLock");
};
function queryTempGroupId() {
if (window.android && window.android.getTempGroupId) {
var gid = window.android.getTempGroupId();
showPttResult("当前在临时群的 GID: " + (gid || "无"));
} else {
showPttResult("接口 getTempGroupId 不可用");
}
}
function playNativeAlert() {
if (window.android && window.android.playAlert) {
var count = parseInt(document.getElementById('alert-count').value) || 3;
var volume = parseFloat(document.getElementById('alert-volume').value) || 1.0;
window.android.playAlert(count, volume);
} else {
alert("播放提示音接口未就绪");
}
}
function stopNativeAlert() {
if (window.android && window.android.stopAlert) {
window.android.stopAlert();
} else {
alert("停止播放接口未就绪");
}
}
</script>
</body>
</html>

View File

@ -75,6 +75,29 @@ public class AudioRecordManager {
return mInstance;
}
/**
* 彻底销毁单例及相关资源
*/
public static void destroy() {
if (mInstance != null) {
synchronized (AudioRecordManager.class) {
if (mInstance != null) {
try {
mInstance.destroyAudio();
} catch (Exception e) {
Timber.tag("AudioRecordManager").e(e, "destroyAudio error");
}
try {
mInstance.destroyThread();
} catch (Exception e) {
Timber.tag("AudioRecordManager").e(e, "destroyThread error");
}
mInstance = null;
}
}
}
}
/**
* 销毁线程方法
*/

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;
@ -38,8 +44,12 @@ public class CallBackResolution {
}
public static void resetPttLoginStatus() {
loginState = false;
pttLoginStatus = "00";
pttLoginId = "";
indexGroupId ="";
indexGroupName = "";
keepAliveExecutor.shutdownNow();
}
public static String getCurrentGroupId() {
@ -83,6 +93,10 @@ public class CallBackResolution {
com.stand.standapp.MainActivity.onPttLoginSuccess();
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('" + pttLoginStatus + "','" + pttLoginId + "')");
}
if ("01".equals(errorNum)) {
com.stand.standapp.MainActivity.onPttLoginSuccess();
com.stand.standapp.MainActivity.executeJs("updatePttLoginStatus('02','" + pttLoginId + "')");
}
break;
case "a2"://账号有视频功能
@ -139,7 +153,7 @@ public class CallBackResolution {
Timber.tag(TAG).d( "at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
if (1 == groupNo) groupInfos.clear();
GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount);
groupInfos.add(groupInfo);
groupInfos.put(groupId, groupInfo);
}
break;
@ -168,7 +182,7 @@ public class CallBackResolution {
String tempTxt = unicodeToString(groupMemberName.toString());
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
if (memberNo == 1) memberInfoDtos.clear();
memberInfoDtos.add(infoDto);
memberInfoDtos.put(memberId, infoDto);
}
break;
@ -263,34 +277,29 @@ public class CallBackResolution {
// 处理临时呼叫页面逻辑
if (code == 1) {
// 临时呼叫临时群组 打开临时呼叫页面
try {
android.content.Intent intent = new android.content.Intent(
com.stand.standapp.MyApplication.getAppInstance(),
com.stand.standapp.TempCallActivity.class
);
// 从84消息中提取临时群名称格式为"临时呼叫XXX"
String tempGroupName = "临时会话";
if (tempTxt.startsWith("临时呼叫") && tempTxt.length() > "临时呼叫".length()) {
tempGroupName = tempTxt.substring("临时呼叫".length());
}
intent.putExtra("group_name", tempGroupName);
intent.addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK |
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
);
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent);
} catch (Exception e) {
Timber.tag(TAG).e( "Failed to open TempCallActivity: " + e.getMessage());
}
com.stand.standapp.MainActivity.executeJs("onTempCallStateChanged('created')");
// try {
// android.content.Context context = com.stand.standapp.MyApplication.getAppInstance();
// String account = com.stand.standapp.AppConfig.getSavedUsername(context);
// String token = com.stand.standapp.AppConfig.getAccessToken(context);
// String tempGroupName = "临时会话";
// if (tempTxt.startsWith("临时呼叫") && tempTxt.length() > "临时呼叫".length()) {
// tempGroupName = tempTxt.substring("临时呼叫".length());
// }
// com.stand.standapp.TempCallActivity.checkAndHandleTempGroup(context, account, token, tempGroupName);
// } catch (Exception e) {
// Timber.tag(TAG).e(e, "Failed to check initiator status and handle TempCallActivity");
// }
} else if (code == 0) {
// 退出临时呼叫 关闭临时呼叫页面
if (com.stand.standapp.TempCallActivity.isOpen()) {
com.stand.standapp.TempCallActivity.closeWithMessage("临时呼叫已结束");
} else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
// 最小化状态下收到结束消息清除状态并通知前端
com.stand.standapp.TempCallActivity.clearMinimizedState();
}
com.stand.standapp.MainActivity.executeJs("onTempCallStateChanged('destroyed')");
com.stand.standapp.MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }");
// if (com.stand.standapp.TempCallActivity.isOpen()) {
// com.stand.standapp.TempCallActivity.closeWithMessage("临时呼叫已结束");
// } else if (com.stand.standapp.TempCallActivity.isMinimizedState()) {
// // 最小化状态下收到结束消息清除状态并通知前端
// com.stand.standapp.TempCallActivity.clearMinimizedState();
// }
} else if (code == 2) {
// 呼叫失败 关闭临时呼叫页面
// if (com.stand.standapp.TempCallActivity.isOpen()) {
@ -335,6 +344,8 @@ public class CallBackResolution {
indexGroupId = userGroupId;
indexGroupName = userGroupName;
CallBackUtil.callBackNotifyUpdateGroup(userGroupId, userGroupName);
// 触发首次进入群组状态
com.stand.standapp.MainActivity.onFirstGroupEntered();
}
break;
@ -543,13 +554,16 @@ public class CallBackResolution {
if (tempTxt.contains("已登录")) {
String showName = tempTxt.substring(3, tempTxt.length());
CallBackUtil.callBackLogin(true, showName);
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
keepAliveExecutor.shutdownNow();
}
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
Runnable task = () -> {
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
SendAtUtil.sendUDP();
SendAtUtil.sendTCP();
};
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
} else if (tempTxt.contains("账号已更新")) {
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
} else if (tempTxt.contains("账号或密码错误")) {

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

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

View File

@ -18,6 +18,11 @@ class AndroidInterface(private val activity: MainActivity) {
private var lastSpeakTime = 0L
private val MIN_SPEAK_DURATION = 200L
companion object {
private var alertPlayer: android.media.MediaPlayer? = null
private var alertPlayCount = 0
}
/**
* JS bridge 调度入口
* @param method JS 调用的方法名
@ -75,6 +80,7 @@ class AndroidInterface(private val activity: MainActivity) {
"printRawData" -> { printRawData(optStringOrNull(args, 0)); "" }
"startUpdate" -> { startUpdate(optStringOrNull(args, 0)); "" }
"exitApp" -> { exitApp(); "" }
"forceExit" -> { forceExit(); "" }
"getVersionName" -> getVersionName()
"getAboutInfo" -> getAboutInfo()
"saveMessage" -> { saveMessage(optStringOrNull(args, 0)); "" }
@ -85,6 +91,18 @@ class AndroidInterface(private val activity: MainActivity) {
"showJsonDialog" -> { showJsonDialog(args.optString(0, "")); "" }
"getTempCallStatus" -> getTempCallStatus()
"maximizeTempCall" -> { maximizeTempCall(); "" }
"getTempGroupId" -> getTempGroupId()
"logout" -> { logout(); "" }
"playAlert" -> {
val count = args.optInt(0, 3)
val volume = args.optDouble(1, 1.0).toFloat()
playAlert(count, volume)
""
}
"stopAlert" -> {
stopAlert()
""
}
else -> {
Timber.tag("Bridge").w("Unknown method: $method")
""
@ -193,6 +211,9 @@ class AndroidInterface(private val activity: MainActivity) {
fun pttJumpGroup(gid: String) {
Timber.tag("PTT").d("pttJumpGroup: gid=%s", gid)
SendAtUtil.jumpGroup(gid)
if (gid.isNotEmpty()) {
AppConfig.setLastGroupId(activity, gid)
}
}
fun getPrinterStatus(): String {
@ -365,6 +386,17 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
fun forceExit() {
Handler(Looper.getMainLooper()).post {
try {
com.stand.standapp.utils.AppKiller.killApp(activity)
} catch (e: Exception) {
Timber.tag("App").e(e, "强制退出失败")
activity.finish()
}
}
}
fun getVersionName(): String {
return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
@ -378,22 +410,9 @@ class AndroidInterface(private val activity: MainActivity) {
fun getAboutInfo(): String {
return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
val json = JSONObject()
val json = com.stand.standapp.utils.DeviceInfoUtils.getDeviceInfoJson(activity)
json.put("appName", activity.getString(R.string.app_name))
json.put("versionName", packageInfo.versionName)
json.put("model", android.os.Build.MODEL)
json.put("osVersion", android.os.Build.VERSION.RELEASE)
val abis = android.os.Build.SUPPORTED_ABIS
json.put("cpuAbi", abis.joinToString(","))
val is64Bit = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
android.os.Process.is64Bit()
} else {
abis.any { it.contains("64") }
}
json.put("is64Bit", is64Bit)
json.put("versionName", packageInfo.versionName ?: "1.0.0")
json.toString()
} catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")
@ -450,6 +469,9 @@ class AndroidInterface(private val activity: MainActivity) {
com.stand.standapp.MainActivity.executeJs("if(window.appLogin){window.appLogin('$escaped');}")
Timber.tag("AndroidInterface").d("Called appLogin")
}
// 页面加载并初始化完成后触发状态
com.stand.standapp.MainActivity.onPageLoadFinished()
} catch (e: Exception) {
Timber.tag("UI").e(e, "隐藏加载布局失败")
}
@ -494,6 +516,10 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
fun getTempGroupId(): String {
return TempCallActivity.currentTempGroupId
}
fun maximizeTempCall() {
Handler(Looper.getMainLooper()).post {
try {
@ -503,4 +529,63 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
}
fun logout() {
activity.logout()
}
fun playAlert(count: Int, volume: Float) {
Handler(Looper.getMainLooper()).post {
try {
realStopAlert()
alertPlayCount = 0
alertPlayer = android.media.MediaPlayer.create(activity, R.raw.pull_alert).apply {
setVolume(volume, volume)
setOnCompletionListener {
alertPlayCount++
Timber.tag("AlertPlayer").d("already alert play, count=$alertPlayCount")
if (alertPlayCount < count) {
try {
it.start()
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error repeating alert play")
realStopAlert()
}
} else {
realStopAlert()
}
}
start()
}
Timber.tag("AlertPlayer").d("Started alert play, count=$count, volume=$volume")
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error initializing alert player")
}
}
}
fun stopAlert() {
if (Looper.myLooper() == Looper.getMainLooper()) {
realStopAlert()
} else {
Handler(Looper.getMainLooper()).post {
realStopAlert()
}
}
}
private fun realStopAlert() {
try {
alertPlayer?.let { player ->
if (player.isPlaying) {
player.stop()
}
player.release()
}
} catch (e: Exception) {
Timber.tag("AlertPlayer").e(e, "Error stopping alert player")
} finally {
alertPlayer = null
}
}
}

View File

@ -11,6 +11,8 @@ object AppConfig {
private const val KEY_USERNAME = "saved_username"
private const val KEY_PASSWORD = "saved_password"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_LAST_GROUP_ID = "last_group_id"
private const val KEY_USER_ID = "user_id"
// 默认配置
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
@ -54,6 +56,7 @@ object AppConfig {
getPrefs(context).edit().putBoolean(KEY_REMEMBER_LOGIN, remember).apply()
}
@JvmStatic
fun getSavedUsername(context: Context): String {
return getPrefs(context).getString(KEY_USERNAME, "") ?: ""
}
@ -70,6 +73,7 @@ object AppConfig {
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
}
@JvmStatic
fun getAccessToken(context: Context): String {
return getPrefs(context).getString(KEY_ACCESS_TOKEN, "") ?: ""
}
@ -77,4 +81,22 @@ object AppConfig {
fun setAccessToken(context: Context, token: String) {
getPrefs(context).edit().putString(KEY_ACCESS_TOKEN, token).apply()
}
fun getLastGroupId(context: Context): String {
return getPrefs(context).getString(KEY_LAST_GROUP_ID, "") ?: ""
}
fun setLastGroupId(context: Context, groupId: String) {
getPrefs(context).edit().putString(KEY_LAST_GROUP_ID, groupId).apply()
}
@JvmStatic
fun getSavedUserId(context: Context): String {
return getPrefs(context).getString(KEY_USER_ID, "") ?: ""
}
@JvmStatic
fun setSavedUserId(context: Context, userId: String) {
getPrefs(context).edit().putString(KEY_USER_ID, userId).apply()
}
}

View File

@ -4,9 +4,11 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
class DeveloperActivity : AppCompatActivity() {
@ -29,6 +31,48 @@ class DeveloperActivity : AppCompatActivity() {
startActivity(Intent(this, com.stand.standapp.printer.PrintTestActivity::class.java))
}
// 本地演示界面
findViewById<LinearLayout>(R.id.item_demo_ui).setOnClickListener {
val builder = androidx.appcompat.app.AlertDialog.Builder(this)
builder.setTitle("本地演示登录")
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(50, 40, 50, 10)
}
val etUser = EditText(this).apply {
hint = "账号"
setText("test010")
}
layout.addView(etUser)
val etPwd = EditText(this).apply {
hint = "密码"
setText("123456")
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
}
layout.addView(etPwd)
builder.setView(layout)
builder.setPositiveButton("登录") { _, _ ->
val username = etUser.text.toString().trim()
val password = etPwd.text.toString().trim()
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(this, "账号密码不能为空", Toast.LENGTH_SHORT).show()
} else {
val intent = Intent(this, LoginActivity::class.java).apply {
putExtra("official_mode_login", false)
putExtra("extra_username", username)
putExtra("extra_password", password)
}
startActivity(intent)
}
}
builder.setNegativeButton("取消", null)
builder.show()
}
// 崩溃模拟
findViewById<LinearLayout>(R.id.item_crash_test).setOnClickListener {
androidx.appcompat.app.AlertDialog.Builder(this)

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)
@ -32,7 +33,6 @@ class LoginActivity : AppCompatActivity() {
val etPassword = findViewById<EditText>(R.id.et_password)
val cbRemember = findViewById<CheckBox>(R.id.cb_remember)
val btnLogin = findViewById<Button>(R.id.btn_login)
val btnLoginOfficial = findViewById<Button>(R.id.btn_login_official)
val btnExit = findViewById<Button>(R.id.btn_exit)
val ivSettings = findViewById<ImageView>(R.id.iv_settings)
val tvCopyright = findViewById<TextView>(R.id.tv_copyright)
@ -45,12 +45,22 @@ class LoginActivity : AppCompatActivity() {
etPassword.setText(AppConfig.getSavedPassword(this))
}
// 检查是否有传递过来的用户名和密码(来自开发者界面的演示登录)
val extraUsername = intent.getStringExtra("extra_username")
val extraPassword = intent.getStringExtra("extra_password")
if (!extraUsername.isNullOrEmpty() && !extraPassword.isNullOrEmpty()) {
etUsername.setText(extraUsername)
etPassword.setText(extraPassword)
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
performLogin(extraUsername, extraPassword, cbRemember.isChecked, isOfficialMode)
}
// 2. 服务器配置图标点击事件
ivSettings.setOnClickListener {
showServerConfigDialog()
}
// 3. 登录按钮点击事件
// 3. 登录按钮点击事件(进行正式界面登录)
btnLogin.setOnClickListener {
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString().trim()
@ -60,21 +70,9 @@ class LoginActivity : AppCompatActivity() {
return@setOnClickListener
}
// 执行真实接口登录
performLogin(username, password, cbRemember.isChecked, false)
}
// 3.1 正式界面登录按钮点击事件
btnLoginOfficial.setOnClickListener {
val username = etUsername.text.toString().trim()
val password = etPassword.text.toString().trim()
if (username.isEmpty() || password.isEmpty()) {
Toast.makeText(applicationContext, "请输入账号和密码", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
performLogin(username, password, cbRemember.isChecked, true)
// 根据传入的 Intent 决定是正式登录还是演示登录,默认是正式登录(true)
val isOfficialMode = intent.getBooleanExtra("official_mode_login", true)
performLogin(username, password, cbRemember.isChecked, isOfficialMode)
}
// 4. 退出按钮
@ -118,6 +116,7 @@ class LoginActivity : AppCompatActivity() {
val request = Request.Builder()
.url(loginUrl)
.addHeader("X-Client-Type", "android")
.post(body)
.build()
@ -159,6 +158,14 @@ class LoginActivity : AppCompatActivity() {
val accessToken = data?.optString("access_token", "") ?: ""
AppConfig.setAccessToken(this@LoginActivity, accessToken)
// 提取并保存当前用户的 userId
val userObj = data?.optJSONObject("user")
val userId = userObj?.optString("userId", "")?.takeIf { it.isNotEmpty() }
?: userObj?.optString("id", "")?.takeIf { it.isNotEmpty() }
?: data?.optString("userId", "")?.takeIf { it.isNotEmpty() }
?: data?.optString("id", "") ?: ""
AppConfig.setSavedUserId(this@LoginActivity, userId)
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
// 获取独立的 PTT 账号并初始化
@ -263,55 +270,7 @@ class LoginActivity : AppCompatActivity() {
private fun checkTempGroup(account: String, loginData: org.json.JSONObject?) {
val accessToken = loginData?.optString("access_token", "") ?: return
if (accessToken.isEmpty()) return
val serverUrl = AppConfig.getServerUrl(this)
val url = "$serverUrl/api/external/ptt/user/groups?account=$account"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $accessToken")
.get()
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Timber.tag("TempGroup").w(e, "检查临时群组失败")
}
override fun onResponse(call: Call, response: Response) {
try {
val body = response.body?.string() ?: return
val json = JSONObject(body)
val dataObj = json.optJSONObject("data")
val list = dataObj?.optJSONArray("data") ?: return
for (i in 0 until list.length()) {
val group = list.optJSONObject(i) ?: continue
val gtype = group.optString("gtype", "")
if (gtype == "2") {
val gname = group.optString("gname", "临时群组")
Timber.tag("TempGroup").d("发现临时群组: $gname")
// 打开临时呼叫页面
val appCtx = com.stand.standapp.MyApplication.getAppInstance()
val intent = android.content.Intent(
appCtx,
TempCallActivity::class.java
)
intent.putExtra("group_name", gname)
intent.addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
appCtx.startActivity(intent)
break
}
}
} catch (e: Exception) {
Timber.tag("TempGroup").e(e, "解析临时群组响应失败")
}
}
})
TempCallActivity.checkAndHandleTempGroup(this, account, accessToken)
}
private fun initPttWithAccount(username: String, accessToken: String) {

View File

@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import android.widget.FrameLayout
import com.example.kingway.ptt.CallBackResolution
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
@ -19,6 +20,9 @@ class MainActivity : AppCompatActivity() {
companion object {
private var instance: MainActivity? = null
@Volatile
private var geckoRuntime: GeckoRuntime? = null
// 防止对话框堆叠
@Volatile
private var isConflictDialogShowing = false
@ -31,15 +35,72 @@ class MainActivity : AppCompatActivity() {
@Volatile
private var pendingJsCode: String? = null
private val jsLock = Any()
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(pendingJsCode ?: Any()) {
synchronized(jsLock) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@Volatile
private var hasFirstGroupEntered = false
@Volatile
private var hasPageFinished = false
@Volatile
private var hasAutoJumped = false
@JvmStatic
fun onFirstGroupEntered() {
Timber.tag("PTT").d("Condition met: First group entered")
hasFirstGroupEntered = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun onPageLoadFinished() {
Timber.tag("PTT").d("Condition met: Web page loaded and initialized")
hasPageFinished = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun resetAutoJumpStates() {
hasFirstGroupEntered = false
hasPageFinished = false
hasAutoJumped = false
Timber.tag("PTT").d("Auto jump states reset")
}
@JvmStatic
fun checkAndAutoJumpToLastGroup() {
// synchronized(this) {
// if (hasFirstGroupEntered && hasPageFinished && !hasAutoJumped) {
// hasAutoJumped = true
// instance?.let { ctx ->
// val lastGroupId = AppConfig.getLastGroupId(ctx)
// if (lastGroupId.isNotEmpty()) {
// Timber.tag("PTT").d("Both conditions met. Auto-jumping to last joined group: %s after 2s delay", lastGroupId)
// ctx.runOnUiThread {
// android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
// instance?.pttInterface?.pttJumpGroup(lastGroupId)
// }, 1000)
// }
// } else {
// Timber.tag("PTT").d("Both conditions met but lastGroupId is empty, skip auto-jump")
// }
// }
// } else {
// Timber.tag("PTT").d("Check auto jump: hasFirstGroupEntered=$hasFirstGroupEntered, hasPageFinished=$hasPageFinished, hasAutoJumped=$hasAutoJumped")
// }
// }
}
@JvmStatic
fun onPttLoginSuccess() {
Timber.tag("MainActivity").d("onPttLoginSuccess called from Native")
@ -119,7 +180,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(pendingJsCode ?: Any()) {
synchronized(jsLock) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
@ -128,7 +189,6 @@ class MainActivity : AppCompatActivity() {
}
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private var canGoBack = false
private lateinit var geckoView: GeckoView
@ -156,7 +216,7 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
geckoView = findViewById<GeckoView>(R.id.geckoView)
?: return
?: return
findViewById<android.widget.Button>(R.id.btn_retry)?.setOnClickListener {
hideErrorPage()
@ -217,17 +277,46 @@ class MainActivity : AppCompatActivity() {
}
private fun createGeckoRuntime(): GeckoRuntime {
val runtime = GeckoRuntime.getDefault(this)
runtime.settings.aboutConfigEnabled = true
runtime.settings.consoleOutputEnabled = true
// 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
synchronized(MainActivity::class.java) {
val existing = geckoRuntime
if (existing != null) {
return existing
}
// 1. 将配置写入应用的本地沙盒文件 (YAML 格式)
val configFile = java.io.File(filesDir, "geckoview-config.yaml")
try {
val configContent = """
prefs:
layout.testing.overlay-scrollbars.always-visible: true
""".trimIndent()
configFile.writeText(configContent)
Timber.tag("MainActivity").d("Wrote GeckoView config to: ${configFile.absolutePath}")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to write geckoview-config.yaml")
}
// 2. 通过 GeckoRuntimeSettings.Builder 加载该 YAML 配置文件并创建 GeckoRuntime 实例
val settingsBuilder = org.mozilla.geckoview.GeckoRuntimeSettings.Builder()
.aboutConfigEnabled(true)
.consoleOutput(true)
if (configFile.exists()) {
settingsBuilder.configFilePath(configFile.absolutePath)
}
val runtime = GeckoRuntime.create(this.applicationContext, settingsBuilder.build())
geckoRuntime = runtime
// 3. 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
}
return runtime
}
return runtime
}
private fun createSession(): GeckoSession {
@ -417,45 +506,78 @@ class MainActivity : AppCompatActivity() {
.show()
}
private fun pttLogoutAndDestroy() {
try {
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT cancel failed")
}
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("LoginActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT logout failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "GPIO stop failed")
}
try {
// com.example.kingway.ptt.MyApplication.destroy()
Timber.tag("LoginActivity").i("PTT native destroyed")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT destroy failed")
}
try {
// PrinterManager.shutdown()
Timber.tag("LoginActivity").i("Printer shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "Printer shutdown failed")
}
try {
com.stand.standapp.utils.LogManager.shutdown()
Timber.tag("LoginActivity").i("LogManager shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "LogManager shutdown failed")
fun logout() {
runOnUiThread {
Timber.tag("MainActivity").i("用户注销,清理程序")
// 1. PTT Logout and cancel login
// try {
// com.example.kingway.ptt.SendAtUtil.toCancelLogin()
// } catch (e: Exception) {
// Timber.tag("MainActivity").e(e, "PTT cancel failed")
// }
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("MainActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "PTT logout failed")
}
// 2. Stop GPIO listening
try {
com.stand.standapp.utils.GpioManager.stopListening()
Timber.tag("MainActivity").i("GPIO stop listening")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
resetAutoJumpStates()
CallBackResolution.resetPttLoginStatus();
}catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear public value")
}
// 4. Stop and destroy AudioRecord
try {
com.example.kingway.ptt.AudioRecordManager.destroy()
Timber.tag("MainActivity").i("AudioRecordManager destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "AudioRecordManager destroy failed")
}
// 3. Destroy/release AudioTrack in PTT Native
try {
com.stand.standapp.MyApplication.pNative?.destory()
Timber.tag("MainActivity").i("PTT native AudioTrack destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "PTT native destroy failed")
}
// 5. Close printer connection and reset
try {
com.stand.standapp.printer.PrinterManager.destroy()
Timber.tag("MainActivity").i("Printer destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Printer destroy failed")
}
// 6. Clear Access Token
try {
AppConfig.setAccessToken(this, "")
Timber.tag("MainActivity").i("Cleared access token")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear access token")
}
// 7. Redirect back to LoginActivity and clear task stack
try {
val intent = Intent(this, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to redirect to LoginActivity")
}
}
}
@ -472,15 +594,34 @@ class MainActivity : AppCompatActivity() {
return super.onKeyDown(keyCode, event)
}
override fun onPause() {
super.onPause()
geckoSession?.setActive(false)
}
override fun onResume() {
super.onResume()
geckoSession?.setActive(true)
}
override fun onDestroy() {
cancelTimeoutTimer()
geckoView.releaseSession()
geckoSession?.close()
geckoSession = null
clearCompanionState()
instance = null
super.onDestroy()
}
private fun clearCompanionState() {
synchronized(jsLock) {
pendingJsCode = null
}
pendingDialog = null
isConflictDialogShowing = false
}
private fun startTimeoutTimer() {
cancelTimeoutTimer()
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)

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

View File

@ -26,6 +26,8 @@ class TempCallActivity : AppCompatActivity() {
private val MIN_SPEAK_DURATION = 200L
private val handler = Handler(Looper.getMainLooper())
private var pulseAnimator: ObjectAnimator? = null
private var alertPlayer: android.media.MediaPlayer? = null
private var alertPlayCount = 0
companion object {
@Volatile
@ -34,12 +36,15 @@ class TempCallActivity : AppCompatActivity() {
@Volatile
var isMinimized = false
private set
internal set
// 存储当前临时群组名称,用于恢复时传递
@Volatile
var currentGroupName: String = "临时会话"
@Volatile
var currentTempGroupId: String = ""
@JvmStatic
fun isOpen(): Boolean = instance != null
@ -53,9 +58,11 @@ class TempCallActivity : AppCompatActivity() {
val intent = android.content.Intent(
com.stand.standapp.MyApplication.getAppInstance(),
TempCallActivity::class.java
)
intent.putExtra("group_name", currentGroupName)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
).apply {
putExtra("group_name", currentGroupName)
putExtra("gid", currentTempGroupId)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
com.stand.standapp.MyApplication.getAppInstance().startActivity(intent)
}
}
@ -76,7 +83,137 @@ class TempCallActivity : AppCompatActivity() {
fun clearMinimizedState() {
isMinimized = false
currentGroupName = "临时会话"
currentTempGroupId = ""
MainActivity.executeJs("onTempCallStateChanged('destroyed')")
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
@JvmStatic
@JvmOverloads
fun checkAndHandleTempGroup(
context: android.content.Context,
account: String,
token: String,
groupNameFromCall: String? = null,
targetGid: String? = null
) {
// if (token.isEmpty()) {
// Timber.tag("TempGroupCheck").w("token is empty, skip check")
// return
// }
//
// val serverUrl = AppConfig.getServerUrl(context)
// val url = "$serverUrl/api/vid-pull-record/my-list"
//
// val request = okhttp3.Request.Builder()
// .url(url)
// .addHeader("Authorization", "Bearer $token")
// .get()
// .build()
//
// com.stand.standapp.net.NetworkModule.defaultClient.newCall(request).enqueue(object : okhttp3.Callback {
// override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
// Timber.tag("TempGroupCheck").e(e, "获取拉动记录失败")
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
//
// override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
// try {
// val body = response.body?.string() ?: return
// val json = org.json.JSONObject(body)
// if (json.optInt("code", -1) == 0) {
// val dataArray = json.optJSONArray("data")
// var matchedRecord: org.json.JSONObject? = null
//
// if (dataArray != null && dataArray.length() > 0) {
// // 1. 如果指定了 GID优先按 GID 匹配
// if (targetGid != null && targetGid.isNotEmpty()) {
// for (i in 0 until dataArray.length()) {
// val record = dataArray.optJSONObject(i) ?: continue
// val pocGid = record.optString("pocGid", "")
// if (pocGid == targetGid) {
// matchedRecord = record
// break
// }
// }
// }
//
// // 2. 如果未指定 GID 或未匹配到,且有传入的呼叫群组名称,按名称匹配
// if (matchedRecord == null && groupNameFromCall != null && groupNameFromCall.isNotEmpty() && groupNameFromCall != "临时会话") {
// for (i in 0 until dataArray.length()) {
// val record = dataArray.optJSONObject(i) ?: continue
// val pullTitle = record.optString("pullTitle", "")
// if (pullTitle.contains(groupNameFromCall) || groupNameFromCall.contains(pullTitle)) {
// matchedRecord = record
// break
// }
// }
// }
//
// // 3. 如果仍未匹配到,默认使用最新的一条记录
// if (matchedRecord == null) {
// matchedRecord = dataArray.optJSONObject(0)
// }
// }
//
// if (matchedRecord != null) {
// val pocGid = matchedRecord.optString("pocGid", "")
// val pullTitle = matchedRecord.optString("pullTitle", "临时会话")
// val createBy = matchedRecord.optString("createBy", "")
// val currentUserId = AppConfig.getSavedUserId(context)
//
// val isInitiator = currentUserId.isNotEmpty() && currentUserId == createBy
// Timber.tag("TempGroupCheck").d("匹配拉动记录成功: title=$pullTitle, pocGid=$pocGid, createBy=$createBy, isInitiator=$isInitiator")
//
// if (isInitiator) {
// isMinimized = true
// currentTempGroupId = pocGid
// currentGroupName = pullTitle
// MainActivity.executeJs("onTempCallStateChanged('minimized')")
// if (pocGid.isNotEmpty()) {
// MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$pocGid'); }")
// }
// } else {
// startTempCallActivity(context, pullTitle, pocGid)
// }
// } else {
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// } else {
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// } catch (e: Exception) {
// Timber.tag("TempGroupCheck").e(e, "解析拉动记录响应失败")
// if (groupNameFromCall != null) {
// startTempCallActivity(context, groupNameFromCall, targetGid ?: "")
// }
// }
// }
// })
}
@JvmStatic
private fun startTempCallActivity(context: android.content.Context, gname: String, gid: String) {
try {
val intent = android.content.Intent(context, TempCallActivity::class.java).apply {
putExtra("group_name", gname)
putExtra("gid", gid)
addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
}
context.startActivity(intent)
} catch (e: Exception) {
Timber.tag("TempGroupCheck").e(e, "Failed to start TempCallActivity")
}
}
}
@ -94,14 +231,22 @@ class TempCallActivity : AppCompatActivity() {
instance = this
isMinimized = false
val groupName = intent.getStringExtra("group_name") ?: "临时会话"
val rawGroupName = intent.getStringExtra("group_name")
val groupName = if (rawGroupName.isNullOrEmpty() || rawGroupName == "null") "临时会话" else rawGroupName
val gid = intent.getStringExtra("gid") ?: ""
currentGroupName = groupName
if (gid.isNotEmpty()) {
currentTempGroupId = gid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
findViewById<TextView>(R.id.tv_group_name).text = groupName
setupMinimizeButton()
setupPttButton()
setupHangupButton()
startPulseAnimation()
startVoiceAlert()
fetchPullRecord()
}
override fun onResume() {
@ -114,13 +259,16 @@ class TempCallActivity : AppCompatActivity() {
pulseAnimator?.cancel()
if (instance == this) {
instance = null
// 如果不是最小化状态,说明是真正结束,清除所有状态
if (!isMinimized) {
currentGroupName = "临时会话"
currentTempGroupId = ""
}
// 通知 web 页面状态变化
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
if (!isMinimized) {
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
}
stopVoiceAlert()
}
// 拦截返回键
@ -282,4 +430,104 @@ class TempCallActivity : AppCompatActivity() {
}
}
}
private fun startVoiceAlert() {
try {
findViewById<Button>(R.id.btn_confirm_alert)?.apply {
visibility = View.VISIBLE
setOnClickListener {
stopVoiceAlert()
}
}
alertPlayCount = 0
alertPlayer = android.media.MediaPlayer.create(this, R.raw.pull_alert).apply {
setOnCompletionListener {
alertPlayCount++
if (alertPlayCount < 3) {
try {
it.start()
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error repeating voice play")
stopVoiceAlert()
}
} else {
stopVoiceAlert()
}
}
start()
}
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error initializing MediaPlayer")
findViewById<Button>(R.id.btn_confirm_alert)?.visibility = View.GONE
}
}
private fun stopVoiceAlert() {
try {
findViewById<Button>(R.id.btn_confirm_alert)?.visibility = View.GONE
alertPlayer?.let { player ->
if (player.isPlaying) {
player.stop()
}
player.release()
}
} catch (e: Exception) {
timber.log.Timber.tag("TempCallAlert").e(e, "Error stopping MediaPlayer")
} finally {
alertPlayer = null
}
}
private fun fetchPullRecord() {
val serverUrl = AppConfig.getServerUrl(this)
val token = AppConfig.getAccessToken(this)
if (token.isEmpty()) {
Timber.tag("TempCallAlert").w("AccessToken is empty, cannot query pull record")
return
}
val url = "$serverUrl/api/vid-pull-record/my-list"
val request = okhttp3.Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $token")
.get()
.build()
com.stand.standapp.net.NetworkModule.defaultClient.newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
Timber.tag("TempCallAlert").e(e, "获取拉动记录失败")
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
try {
val body = response.body?.string() ?: return
val json = org.json.JSONObject(body)
if (json.optInt("code", -1) == 0) {
val dataArray = json.optJSONArray("data")
if (dataArray != null && dataArray.length() > 0) {
val record = dataArray.optJSONObject(0) ?: return
val pullTitle = record.optString("pullTitle", "")
val pullTime = record.optString("pullTime", "")
val sourceUnitName = record.optString("sourceUnitName", "")
val pocGid = record.optString("pocGid", "")
if (pocGid.isNotEmpty() && currentTempGroupId.isEmpty()) {
currentTempGroupId = pocGid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
runOnUiThread {
findViewById<View>(R.id.ll_pull_info)?.visibility = View.VISIBLE
findViewById<TextView>(R.id.tv_pull_title)?.text = "标题:$pullTitle"
findViewById<TextView>(R.id.tv_pull_source_unit)?.text = "发送单位:$sourceUnitName"
findViewById<TextView>(R.id.tv_pull_time)?.text = "拉动时间:$pullTime"
findViewById<TextView>(R.id.tv_pull_content)?.text = "内容:请注意!正在对你单位进行视频拉动。"
}
}
}
} catch (e: Exception) {
Timber.tag("TempCallAlert").e(e, "解析拉动记录响应失败")
}
}
})
}
}

View File

@ -15,7 +15,10 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import com.stand.standapp.net.NetworkModule
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
@ -31,21 +34,17 @@ class UpdateManager(private val context: Context) {
private val fileName = "StandApp_Update.apk"
/**
* 获取或创建公共下载目录下的文件 URI
* 获取应用外部专属下载目录下的文件 URI避免申请外部存储权限的同时确保安装包能被系统安装器读取
*/
private fun getPublicDownloadUri(): Uri? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.android.package-archive")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val contentResolver = context.contentResolver
return contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} else {
val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)
return Uri.fromFile(file)
private fun getCacheFileUri(): Uri {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: context.externalCacheDir
?: context.cacheDir
val file = File(dir, fileName)
if (file.exists()) {
file.delete()
}
return Uri.fromFile(file)
}
fun checkUpdate() {
@ -55,11 +54,41 @@ class UpdateManager(private val context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) it.longVersionCode.toInt() else it.versionCode
}
} catch (e: Exception) { 0 }
val currentVersionName = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: Exception) { "" }
val url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
// 获取详细设备和尺寸属性
val deviceId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID) ?: "unknown_android"
val displayMetrics = context.resources.displayMetrics
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
val screenDensity = displayMetrics.density.toString()
val loginAccount = AppConfig.getSavedUsername(context)
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
val jsonParams = JSONObject().apply {
put("versionCode", currentVersionCode)
put("versionName", currentVersionName)
put("deviceId", deviceId)
put("deviceType", "接警终端")
put("deviceBrand", Build.BRAND)
put("deviceModel", Build.MODEL)
put("osVersion", "Android " + Build.VERSION.RELEASE)
put("screenWidth", screenWidth)
put("screenHeight", screenHeight)
put("screenDensity", screenDensity)
put("loginAccount", loginAccount)
}
val url = "$serverUrl/api/app-version/check-update"
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonParams.toString().toRequestBody(mediaType)
val client = NetworkModule.defaultClient
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
@ -82,9 +111,9 @@ class UpdateManager(private val context: Context) {
if (newVersionCode > currentVersionCode) {
val versionName = data.optString("versionName", "New")
val content = data.optString("updateContent", "优化系统体验")
val id = data.optLong("id", -1)
val id = data.optString("id", "")
if (id != -1L) {
if (id != "") {
(context as? AppCompatActivity)?.runOnUiThread {
try {
showUpdateDialog(versionName, content, id)
@ -111,7 +140,7 @@ class UpdateManager(private val context: Context) {
})
}
private fun showUpdateDialog(versionName: String, content: String, id: Long) {
private fun showUpdateDialog(versionName: String, content: String, id: String) {
val view = (context as AppCompatActivity).layoutInflater.inflate(R.layout.dialog_update, null)
val tvTitle = view.findViewById<android.widget.TextView>(R.id.tv_update_title)
val tvContent = view.findViewById<android.widget.TextView>(R.id.tv_update_content)
@ -129,7 +158,7 @@ class UpdateManager(private val context: Context) {
.setCancelable(false)
.setPositiveButton("立即升级") { _, _ ->
val serverUrl = AppConfig.getServerUrl(context)
val downloadUrl = "$serverUrl/app-version/download/$id"
val downloadUrl = "$serverUrl/api/app-version/download/$id"
startManualDownload(downloadUrl)
}
.setNegativeButton("稍后再说", null)
@ -139,7 +168,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)")
@ -183,10 +212,9 @@ class UpdateManager(private val context: Context) {
var targetUri: Uri? = null
try {
targetUri = getPublicDownloadUri()
if (targetUri == null) throw IOException("无法创建文件记录")
outputStream = context.contentResolver.openOutputStream(targetUri)
targetUri = getCacheFileUri()
val file = File(targetUri.path!!)
outputStream = FileOutputStream(file)
inputStream = body.byteStream()
val totalBytes = body.contentLength()
@ -195,7 +223,7 @@ class UpdateManager(private val context: Context) {
var totalRead: Long = 0
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream?.write(buffer, 0, bytesRead)
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (totalBytes > 0) {
@ -206,11 +234,11 @@ class UpdateManager(private val context: Context) {
}
}
}
outputStream?.flush()
outputStream.flush()
handler.post {
progressDialog?.dismiss()
Toast.makeText(context.applicationContext, "下载完成,文件已保存到系统下载目录", Toast.LENGTH_SHORT).show()
Toast.makeText(context.applicationContext, "下载完成,正在安装...", Toast.LENGTH_SHORT).show()
installApk(targetUri)
}
} catch (e: Exception) {

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

@ -8,12 +8,13 @@ import com.dp.dp_serialportlist.Serialport_Factory
import com.stand.standapp.AppConfig
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
object PrinterManager {
private var sf: Serialport_Factory? = null
private var isConnecting = false
private val executor = Executors.newSingleThreadScheduledExecutor()
private var executor: ScheduledExecutorService? = null
private var mContext: Context? = null
private var hasPaper: Boolean = true // 默认为有纸
@ -43,6 +44,9 @@ object PrinterManager {
fun init(context: Context) {
if (sf != null) return
mContext = context.applicationContext
if (executor == null || executor!!.isShutdown) {
executor = Executors.newSingleThreadScheduledExecutor()
}
// Serialport_Factory 构造函数内部会创建 Handler必须在有 Looper 的线程(如主线程)调用
mHandler.post {
@ -51,9 +55,9 @@ object PrinterManager {
sf = Serialport_Factory.getSerialport_Factory(mContext, mHandler)
// 成功创建 factory 后,将后续耗时的连接任务丢给子线程
executor.execute {
executor?.execute {
// 启动自动重连定时任务
executor.scheduleWithFixedDelay({
executor?.scheduleWithFixedDelay({
checkAndConnect()
}, 0, 10, TimeUnit.SECONDS)
}
@ -65,6 +69,26 @@ object PrinterManager {
}
}
fun destroy() {
Timber.tag("Printer").i("正在销毁并重置打印机组件...")
try {
executor?.shutdownNow()
executor = null
} catch (e: Exception) {
Timber.tag("Printer").e(e, "关闭打印机线程池异常")
}
try {
sf?.ClosePort()
sf = null
} catch (e: Exception) {
Timber.tag("Printer").e(e, "关闭打印机串口异常")
}
mContext = null
isConnecting = false
hasPaper = true
Timber.tag("Printer").i("打印机组件销毁完成")
}
@Synchronized
private fun checkAndConnect() {
val factory = sf ?: return

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,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
}
_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

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

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 松开)
@ -47,6 +47,9 @@ object GpioManager {
handleGpioChange(currentValue)
lastGpioValue = currentValue
}
} catch (e: InterruptedException) {
Timber.tag("GPIO").i("GPIO thread interrupted, exiting gracefully")
break
} catch (e: Exception) {
Timber.tag("GPIO").e(e, "Error in GPIO loop")
}
@ -58,6 +61,10 @@ object GpioManager {
mIsRunning = false
mThread?.interrupt()
mThread = null
mZysjSystemManager = null
isSpeaking = false
lastGpioValue = 1
Timber.tag("GPIO").i("GPIO listening stopped and state reset")
}
private var isSpeaking = false

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

View File

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

View File

@ -22,7 +22,7 @@
android:id="@+id/ll_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_marginTop="@dimen/login_header_margin_top"
android:gravity="center"
android:orientation="vertical">
@ -46,8 +46,8 @@
android:background="@drawable/shape_dot_accent" />
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_width="@dimen/login_logo_size"
android:layout_height="@dimen/login_logo_size"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:src="@drawable/ic_fire_logo"
@ -73,7 +73,7 @@
android:layout_marginTop="10dp"
android:text="消防值守台"
android:textColor="#00CFFF"
android:textSize="26sp"
android:textSize="@dimen/login_title_text_size"
android:textStyle="bold"
android:letterSpacing="0.15" />
@ -84,7 +84,7 @@
android:layout_marginTop="4dp"
android:text="FIRE WATCH MONITORING PLATFORM"
android:textColor="#2A6A8A"
android:textSize="10sp"
android:textSize="@dimen/login_subtitle_text_size"
android:letterSpacing="0.1" />
</LinearLayout>
@ -102,7 +102,7 @@
android:elevation="8dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="28dp"
android:padding="@dimen/login_card_padding"
app:layout_constraintWidth_max="450dp"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.88"
@ -117,7 +117,7 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="20dp">
android:layout_marginBottom="@dimen/login_card_title_margin_bottom">
<View
android:layout_width="0dp"
@ -157,19 +157,19 @@
</LinearLayout>
<!-- 测试账号提示 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="测试账号 test010 test011 密码: 123456"
android:textColor="#2A6A8A"
android:textSize="12sp" />
<!-- <TextView-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginBottom="@dimen/login_card_tip_margin_bottom"-->
<!-- android:text="测试账号 test010 test011 密码: 123456"-->
<!-- android:textColor="#2A6A8A"-->
<!-- android:textSize="12sp" />-->
<!-- 用户名输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:layout_height="@dimen/login_input_height"
android:layout_marginBottom="@dimen/login_input_margin_bottom"
android:background="@drawable/shape_edit_text">
<TextView
@ -205,8 +205,8 @@
<!-- 密码输入框 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="14dp"
android:layout_height="@dimen/login_input_height"
android:layout_marginBottom="@dimen/login_input_margin_bottom"
android:background="@drawable/shape_edit_text">
<TextView
@ -259,7 +259,7 @@
<Button
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_height="@dimen/login_btn_height"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_button_login"
android:text="登 录"
@ -268,22 +268,11 @@
android:textStyle="bold"
android:letterSpacing="0.2" />
<!-- 正式界面登录按钮 -->
<Button
android:id="@+id/btn_login_official"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginBottom="8dp"
android:background="@drawable/shape_button_secondary"
android:text="正式界面登录"
android:textColor="#3A7A9A"
android:textSize="14sp" />
<!-- 退出应用 -->
<Button
android:id="@+id/btn_exit"
android:layout_width="match_parent"
android:layout_height="36dp"
android:layout_height="@dimen/login_exit_btn_height"
android:background="@android:color/transparent"
android:text="退出应用"
android:textColor="#1A4A6B"

View File

@ -11,8 +11,8 @@
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:layout_marginTop="40dp"
android:layout_marginRight="16dp"
android:layout_marginTop="@dimen/temp_call_minimize_margin_top"
android:layout_marginRight="@dimen/temp_call_minimize_margin_right"
android:background="@drawable/btn_minimize_bg"
android:gravity="center"
android:orientation="horizontal"
@ -45,13 +45,13 @@
android:layout_alignParentTop="true"
android:gravity="center"
android:orientation="vertical"
android:paddingTop="50dp"
android:paddingBottom="16dp">
android:paddingTop="@dimen/temp_call_header_padding_top"
android:paddingBottom="@dimen/temp_call_header_padding_bottom">
<!-- 临时呼叫图标 -->
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_width="@dimen/temp_call_logo_size"
android:layout_height="@dimen/temp_call_logo_size"
android:src="@android:drawable/ic_menu_call"
android:tint="#FFFFFF" />
@ -63,7 +63,7 @@
android:layout_marginTop="8dp"
android:text="临时呼叫中"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textSize="@dimen/temp_call_status_text_size"
android:textStyle="bold"
android:letterSpacing="0.1" />
@ -86,7 +86,7 @@
android:background="@drawable/shape_login_box"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
android:padding="@dimen/temp_call_card_padding">
<TextView
android:layout_width="wrap_content"
@ -103,9 +103,53 @@
android:layout_marginTop="8dp"
android:text="临时群组"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textSize="@dimen/temp_call_group_name_text_size"
android:textStyle="bold" />
<!-- 视频拉动信息区域 (默认隐藏,只有成功获取到数据才展示) -->
<LinearLayout
android:id="@+id/ll_pull_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_pull_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_pull_source_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="#CCFFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/tv_pull_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_pull_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="#EF4444"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 分隔线 -->
<View
android:layout_width="60dp"
@ -131,6 +175,19 @@
android:textColor="#10B981"
android:textSize="12sp"
android:visibility="gone" />
<!-- 用于确认并静音语音的按钮 -->
<Button
android:id="@+id/btn_confirm_alert"
android:layout_width="@dimen/temp_call_confirm_btn_width"
android:layout_height="@dimen/temp_call_confirm_btn_height"
android:layout_marginTop="16dp"
android:layout_gravity="center_horizontal"
android:background="@drawable/shape_button_login"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
<!-- 底部按钮区域 -->
@ -140,7 +197,7 @@
android:layout_alignParentBottom="true"
android:gravity="center"
android:orientation="vertical"
android:paddingBottom="48dp">
android:paddingBottom="@dimen/temp_call_bottom_padding">
<!-- PTT 大按钮 -->
<FrameLayout
@ -151,21 +208,21 @@
<!-- 外圈光晕 -->
<View
android:id="@+id/view_glow"
android:layout_width="180dp"
android:layout_height="180dp"
android:layout_width="@dimen/temp_call_ptt_glow_size"
android:layout_height="@dimen/temp_call_ptt_glow_size"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_glow" />
<!-- PTT 按钮 -->
<Button
android:id="@+id/btn_ptt"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_width="@dimen/temp_call_ptt_btn_size"
android:layout_height="@dimen/temp_call_ptt_btn_size"
android:layout_gravity="center"
android:background="@drawable/circle_ptt_button"
android:text="按住\n说话"
android:textColor="#FFFFFF"
android:textSize="22sp"
android:textSize="@dimen/temp_call_ptt_btn_text_size"
android:textStyle="bold" />
</FrameLayout>

Binary file not shown.

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 1088x612 或极矮高度屏幕 (h400dp及以下) 适配尺寸 -->
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">8dp</dimen>
<dimen name="login_logo_size">36dp</dimen>
<dimen name="login_title_text_size">18sp</dimen>
<dimen name="login_subtitle_text_size">8sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">12dp</dimen>
<dimen name="login_card_title_margin_bottom">6dp</dimen>
<dimen name="login_card_tip_margin_bottom">6dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">38dp</dimen>
<dimen name="login_input_margin_bottom">6dp</dimen>
<dimen name="login_btn_height">36dp</dimen>
<dimen name="login_exit_btn_height">24dp</dimen>
<!-- 临时呼叫界面适配尺寸 (h400dp) -->
<dimen name="temp_call_minimize_margin_top">12dp</dimen>
<dimen name="temp_call_minimize_margin_right">10dp</dimen>
<dimen name="temp_call_header_padding_top">10dp</dimen>
<dimen name="temp_call_header_padding_bottom">8dp</dimen>
<dimen name="temp_call_logo_size">24dp</dimen>
<dimen name="temp_call_status_text_size">14sp</dimen>
<dimen name="temp_call_card_padding">12dp</dimen>
<dimen name="temp_call_group_name_text_size">16sp</dimen>
<dimen name="temp_call_confirm_btn_width">120dp</dimen>
<dimen name="temp_call_confirm_btn_height">32dp</dimen>
<dimen name="temp_call_bottom_padding">12dp</dimen>
<dimen name="temp_call_ptt_glow_size">110dp</dimen>
<dimen name="temp_call_ptt_btn_size">90dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">14sp</dimen>
</resources>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 1280x720 或中等高度屏幕 (h540dp及以下) 适配尺寸 -->
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">16dp</dimen>
<dimen name="login_logo_size">48dp</dimen>
<dimen name="login_title_text_size">22sp</dimen>
<dimen name="login_subtitle_text_size">9sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">20dp</dimen>
<dimen name="login_card_title_margin_bottom">12dp</dimen>
<dimen name="login_card_tip_margin_bottom">10dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">44dp</dimen>
<dimen name="login_input_margin_bottom">10dp</dimen>
<dimen name="login_btn_height">42dp</dimen>
<dimen name="login_exit_btn_height">30dp</dimen>
<!-- 临时呼叫界面适配尺寸 (h540dp) -->
<dimen name="temp_call_minimize_margin_top">24dp</dimen>
<dimen name="temp_call_minimize_margin_right">14dp</dimen>
<dimen name="temp_call_header_padding_top">24dp</dimen>
<dimen name="temp_call_header_padding_bottom">12dp</dimen>
<dimen name="temp_call_logo_size">30dp</dimen>
<dimen name="temp_call_status_text_size">16sp</dimen>
<dimen name="temp_call_card_padding">16dp</dimen>
<dimen name="temp_call_group_name_text_size">18sp</dimen>
<dimen name="temp_call_confirm_btn_width">140dp</dimen>
<dimen name="temp_call_confirm_btn_height">36dp</dimen>
<dimen name="temp_call_bottom_padding">24dp</dimen>
<dimen name="temp_call_ptt_glow_size">144dp</dimen>
<dimen name="temp_call_ptt_btn_size">70dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">15sp</dimen>
</resources>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 顶部 Header 边距与大小 -->
<dimen name="login_header_margin_top">28dp</dimen>
<dimen name="login_logo_size">56dp</dimen>
<dimen name="login_title_text_size">26sp</dimen>
<dimen name="login_subtitle_text_size">10sp</dimen>
<!-- 登录卡片容器属性 -->
<dimen name="login_card_padding">28dp</dimen>
<dimen name="login_card_title_margin_bottom">20dp</dimen>
<dimen name="login_card_tip_margin_bottom">16dp</dimen>
<!-- 输入框与按钮高度 -->
<dimen name="login_input_height">50dp</dimen>
<dimen name="login_input_margin_bottom">14dp</dimen>
<dimen name="login_btn_height">48dp</dimen>
<dimen name="login_exit_btn_height">36dp</dimen>
<!-- 临时呼叫界面适配尺寸 -->
<dimen name="temp_call_minimize_margin_top">40dp</dimen>
<dimen name="temp_call_minimize_margin_right">16dp</dimen>
<dimen name="temp_call_header_padding_top">48dp</dimen>
<dimen name="temp_call_header_padding_bottom">16dp</dimen>
<dimen name="temp_call_logo_size">36dp</dimen>
<dimen name="temp_call_status_text_size">18sp</dimen>
<dimen name="temp_call_card_padding">24dp</dimen>
<dimen name="temp_call_group_name_text_size">22sp</dimen>
<dimen name="temp_call_confirm_btn_width">160dp</dimen>
<dimen name="temp_call_confirm_btn_height">40dp</dimen>
<dimen name="temp_call_bottom_padding">48dp</dimen>
<dimen name="temp_call_ptt_glow_size">180dp</dimen>
<dimen name="temp_call_ptt_btn_size">150dp</dimen>
<dimen name="temp_call_ptt_btn_text_size">22sp</dimen>
</resources>

View File

@ -0,0 +1,60 @@
# 临时群组界面状态缓存与前端接口回调设计规约 (Design Spec)
## 1. 需求概述
为 StandAPP 临时呼叫功能增加群组锁定状态的缓存与回调机制:
1. **创建界面**:缓存当前的临时群十六进制 GID并通知前端页面调用 JS 接口 `startPullGroupLock(gid)`
2. **销毁界面**:清除缓存的临时群 GID并通知前端页面调用 JS 接口 `endPullGroupLock()`
3. **查询接口**:提供一个新的原生桥接方法 `getTempGroupId` 供前端 JS 主动查询当前在临时群的十六进制 GID。
4. **演示页面**:修改 `print_demo.html` 演示接收回调通知,并添加按钮演示查询当前临时群 GID。
## 2. 方案设计
### 2.1 临时群 GID 缓存逻辑 (`TempCallActivity.kt`)
* **静态成员变量**:在 `TempCallActivity.Companion` 中定义 `@Volatile var currentTempGroupId: String = ""`
* **创建逻辑 (`onCreate`)**
* 获取 `intent.getStringExtra("group_name")`(即临时群组名称,如 "临时群组1" )。
* 将名称使用 `SendAtUtil.str2HexStr(name)` 转为十六进制字符串(如 `"D5E1CAE6..."`),并存入 `currentTempGroupId`
* 如果 `intent` 直接传了 `group_id`,可以直接使用;如果没有,则使用上述十六进制 GID 作为 fallback。
* 调用:
```kotlin
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
```
* **销毁逻辑 (`onDestroy` / `clearMinimizedState`)**
* 销毁时如果 `!isMinimized`,则清除 `currentTempGroupId = ""`
* 发生真实销毁时调用:
```kotlin
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
```
### 2.2 原生桥接接口增加 (`AndroidInterface.kt`)
* 在 `dispatch` 方法的 `when(method)` 分支中增加:
* `"getTempGroupId" -> getTempGroupId()`
* 实现 `getTempGroupId` 接口:
```kotlin
fun getTempGroupId(): String {
return TempCallActivity.currentTempGroupId
}
```
### 2.3 演示页面修改 (`print_demo.html`)
* **注册回调方法**
```javascript
window.startPullGroupLock = function(gid) {
showPttResult("收到临时群锁定回调 startPullGroupLock, GID: " + gid);
};
window.endPullGroupLock = function() {
showPttResult("收到临时群解锁回调 endPullGroupLock");
};
```
* **新增主动查询按钮**
* 在 PTT 功能区域内添加一个按钮 “查询临时群 GID”
```html
<button onclick="queryTempGroupId()" class="ws-btn">查询当前临时群 GID</button>
```
* JS 触发逻辑:
```javascript
function queryTempGroupId() {
var gid = window.android.getTempGroupId();
showPttResult("当前在临时群的 GID: " + (gid || "无"));
}
```

View File

@ -0,0 +1,38 @@
# 临时呼叫界面优化与 pocGid 逻辑对接设计规约 (Design Spec)
## 1. 需求概述
为 StandAPP 临时呼叫功能做进一步改进与视觉优化:
1. **pocGid 解析与覆盖**:异步查询接口 `/api/vid-pull-record/my-list` 后,若返回数据包含 `pocGid` 字段且有效,需要用其覆盖当前的 `currentTempGroupId` 缓存,并通知前端接口 `startPullGroupLock(pocGid)`
2. **第一排“临时群组”显示为 null 的修复**:防范传递的 Intent Extra `"group_name"` 出现为空、`null` 或 `"null"` 字符串的情形,确保兜底展示为 `"临时会话"`
3. **界面字体、颜色与确认按钮样式优化**:美化拉动记录卡片排版,包括增大标题字号、加粗、改变按钮背景与宽度、调整配色提升对比度等。
## 2. 方案设计
### 2.1 pocGid 覆盖机制
`TempCallActivity.kt``fetchPullRecord()` 成功响应体处理中:
```kotlin
val pocGid = record.optString("pocGid", "")
if (pocGid.isNotEmpty()) {
currentTempGroupId = pocGid
MainActivity.executeJs("if(window.startPullGroupLock){ window.startPullGroupLock('$currentTempGroupId'); }")
}
```
### 2.2 null 值防御
`onCreate()` 中,对 intent 传入的群组名称进行过滤判断:
```kotlin
val rawGroupName = intent.getStringExtra("group_name")
val groupName = if (rawGroupName.isNullOrEmpty() || rawGroupName == "null") "临时会话" else rawGroupName
currentGroupName = groupName
```
### 2.3 UI 优化
* **布局文件**`app/src/main/res/layout/activity_temp_call.xml`
* **拉动记录区域样式优化**
* `tv_pull_title` (标题):字体设为 `#FFFFFF``textSize="18sp"`,加粗,`layout_marginTop="12dp"`。
* `tv_pull_source_unit` (发送单位):字体设为 `#CCFFFFFF``textSize="13sp"``layout_marginTop="6dp"`。
* `tv_pull_time` (拉动时间):字体设为 `#99FFFFFF``textSize="12sp"``layout_marginTop="2dp"`。
* `tv_pull_content` (拉动内容):字体设为警示的红色 `#EF4444` 或黄色 `#FBBF24``textSize="14sp"`,加粗,`layout_marginTop="10dp"`。
* **确认按钮样式优化**
* 修改为固定宽度 `layout_width="160dp"`(居中显示),高为 `40dp`
* 背景使用带有圆角的深蓝或半透明按钮背景,字体颜色为纯白,字号为 `14sp`

View File

@ -0,0 +1,85 @@
# 临时呼叫页面语音提示与确认功能设计规约 (Design Spec)
## 1. 需求概述
`TempCallActivity` 弹出页面(临时呼叫页面)时,需要播放一段本地语音提示。
* **语音播报文本**"您有一条拉动,请注意确认"。
* **播报循环规则**:若用户不操作,默认播放并重复 3 次。
* **交互设计**
* 页面中央的信息卡片中新增一个“确认”按钮。
* 用户点击“确认”按钮时,能够立即静音/停止当前正在播放的语音,且该按钮在静音后隐藏。
* 点击“确认”按钮时仅关闭语音播放,暂时不需要关闭页面。
## 2. 方案设计
### 2.1 语音资源准备
* **文件名**`pull_alert.wav`
* **保存路径**`app/src/main/res/raw/pull_alert.wav`(若没有 `raw` 文件夹,将自动创建)
* **生成方式**:使用 Python 脚本调用 Edge TTS 等免费 TTS 接口,在线合成带有“您有一条拉动,请注意确认”文本的高质量 WAV 音频,并自动保存到对应目录下。
### 2.2 布局修改
* **目标文件**`app/src/main/res/layout/activity_temp_call.xml`
* **修改位置**:在包含 `tv_talk_status` 的内部 `LinearLayout` 容器下方,添加“确认”按钮。
* **按钮样式**
* 文字:"确认"
* 默认可见性:`android:visibility="gone"`(开始播放时显示,停止播放/确认后隐藏)
* 背景:复用现有 `@drawable/btn_minimize_bg`
```xml
<!-- 用于确认并静音语音的按钮 -->
<Button
android:id="@+id/btn_confirm_alert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/btn_minimize_bg"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
```
### 2.3 业务逻辑实现
* **目标文件**`app/src/main/java/com/stand/standapp/TempCallActivity.kt`
* **主要逻辑**
1. 定义 `MediaPlayer` 实例 `private var alertPlayer: MediaPlayer? = null` 及播放计数器 `private var alertPlayCount = 0`
2. 实现播放方法 `private fun startVoiceAlert()`
- 如果 `alertPlayer` 尚未初始化,利用 `MediaPlayer.create(this, R.raw.pull_alert)` 初始化。
- 显示“确认”按钮:`btnConfirmAlert.visibility = View.VISIBLE`。
- 监听播放完成事件:
```kotlin
alertPlayer?.setOnCompletionListener {
alertPlayCount++
if (alertPlayCount < 3) {
alertPlayer?.start()
} else {
stopVoiceAlert()
}
}
```
- 开始播放并设置 `alertPlayCount = 0`
3. 实现停止方法 `private fun stopVoiceAlert()`
- 安全释放 `MediaPlayer` 资源:
```kotlin
alertPlayer?.let { player ->
try {
if (player.isPlaying) {
player.stop()
}
} catch (e: Exception) {
// 忽略错误
} finally {
player.release()
}
}
alertPlayer = null
```
- 隐藏“确认”按钮:`btnConfirmAlert.visibility = View.GONE`。
4. 设置“确认”按钮的点击事件:
- 点击时调用 `stopVoiceAlert()`
5. 页面销毁生命周期:
- 在 `onDestroy()` 中,必须调用 `stopVoiceAlert()` 以防止内存泄漏和页面销毁后后台仍在播放的情况。
## 3. 测试与自审规范
* **测试用例 1**:启动临时呼叫页面,验证语音提示是否以正常音量播放,且重复播放 3 次后自动停止,同时“确认”按钮自动隐藏。
* **测试用例 2**:语音播放期间点击“确认”按钮,验证语音是否立刻停止播放,且“确认”按钮是否立即隐藏。
* **测试用例 3**:语音播放期间直接关闭或最小化页面,验证后台音乐播放器是否正确释放,不存在背景音残留。