Compare commits

...

8 Commits

7 changed files with 336 additions and 0 deletions

View File

@ -238,6 +238,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;">
@ -1143,6 +1144,24 @@
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 不可用");
}
}
</script>
</body>
</html>

View File

@ -86,6 +86,7 @@ class AndroidInterface(private val activity: MainActivity) {
"showJsonDialog" -> { showJsonDialog(args.optString(0, "")); "" }
"getTempCallStatus" -> getTempCallStatus()
"maximizeTempCall" -> { maximizeTempCall(); "" }
"getTempGroupId" -> getTempGroupId()
"logout" -> { logout(); "" }
else -> {
Timber.tag("Bridge").w("Unknown method: $method")
@ -500,6 +501,10 @@ class AndroidInterface(private val activity: MainActivity) {
}
}
fun getTempGroupId(): String {
return TempCallActivity.currentTempGroupId
}
fun maximizeTempCall() {
Handler(Looper.getMainLooper()).post {
try {

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
@ -40,6 +42,9 @@ class TempCallActivity : AppCompatActivity() {
@Volatile
var currentGroupName: String = "临时会话"
@Volatile
var currentTempGroupId: String = ""
@JvmStatic
fun isOpen(): Boolean = instance != null
@ -76,7 +81,9 @@ class TempCallActivity : AppCompatActivity() {
fun clearMinimizedState() {
isMinimized = false
currentGroupName = "临时会话"
currentTempGroupId = ""
MainActivity.executeJs("onTempCallStateChanged('destroyed')")
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
}
@ -96,12 +103,16 @@ class TempCallActivity : AppCompatActivity() {
val groupName = intent.getStringExtra("group_name") ?: "临时会话"
currentGroupName = groupName
currentTempGroupId = com.example.kingway.ptt.SendAtUtil.str2HexStr(groupName)
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() {
@ -116,9 +127,14 @@ class TempCallActivity : AppCompatActivity() {
instance = null
if (!isMinimized) {
currentGroupName = "临时会话"
currentTempGroupId = ""
}
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
if (!isMinimized) {
MainActivity.executeJs("if(window.endPullGroupLock){ window.endPullGroupLock(); }")
}
}
stopVoiceAlert()
}
// 拦截返回键
@ -280,4 +296,99 @@ 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", "")
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

@ -106,6 +106,50 @@
android:textSize="22sp"
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="8dp"
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="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_pull_source_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#CCFFFFFF"
android:textSize="12sp" />
<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="6dp"
android:textColor="#FFD700"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
<!-- 分隔线 -->
<View
android:layout_width="60dp"
@ -131,6 +175,18 @@
android:textColor="#10B981"
android:textSize="12sp"
android:visibility="gone" />
<!-- 用于确认并静音语音的按钮 -->
<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" />
</LinearLayout>
<!-- 底部按钮区域 -->

Binary file not shown.

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,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**:语音播放期间直接关闭或最小化页面,验证后台音乐播放器是否正确释放,不存在背景音残留。