Compare commits

..

No commits in common. "62cb78167d84a75567634ecf95cd1be7d404f169" and "4b2bf8f5f48decc9c6404af7ca90f52ea741b445" have entirely different histories.

12 changed files with 53 additions and 192 deletions

View File

@ -5,10 +5,7 @@ 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;
@ -20,13 +17,11 @@ public class CallBackResolution {
private static String TAG = "PTT_RX";
private static String indexGroupId = "";//用户所在群组Id
private static String indexGroupName = "";//用户所在群组名称
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 List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
private static int speckType = 1;
private static String talkId = "";
private static String talkName = "";
private static final Map<String, GroupInfoDto> groupInfos = new LinkedHashMap<>(MAX_GROUP_INFOS);
private static ArrayList<GroupInfoDto> groupInfos = new ArrayList<>();
private static String oldLoginSate = "00";
private static int logNum = 0;
private static long offlineTime = 0;
@ -145,7 +140,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.put(groupId, groupInfo);
groupInfos.add(groupInfo);
}
break;
@ -174,7 +169,7 @@ public class CallBackResolution {
String tempTxt = unicodeToString(groupMemberName.toString());
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
if (memberNo == 1) memberInfoDtos.clear();
memberInfoDtos.put(memberId, infoDto);
memberInfoDtos.add(infoDto);
}
break;

View File

@ -1,14 +1,9 @@
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请在主线程进行
@ -16,45 +11,6 @@ import java.util.concurrent.ConcurrentHashMap;
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);
}
/**
* 返回登录
@ -112,11 +68,11 @@ public class CallBackUtil {
* 查询群组成员返回
* state true-成功 false-失败
*/
public static void callBackOnMemberSuccess(boolean state, java.util.Map<String, GroupMemberInfoDto> dtos) {
public static void callBackOnMemberSuccess(boolean state, List<GroupMemberInfoDto> dtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
if (dtos != null && state) {
for (GroupMemberInfoDto dto : dtos.values()) {
for (GroupMemberInfoDto dto : dtos) {
org.json.JSONObject obj = new org.json.JSONObject();
obj.put("memberId", dto.getMemberId());
obj.put("memberName", dto.getGmemberName());
@ -124,7 +80,7 @@ public class CallBackUtil {
arr.put(obj);
}
}
pushJsThrottled("MemberList", "if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('MemberList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -172,17 +128,17 @@ public class CallBackUtil {
* 返回群组集合信息
* GroupInfoDto 群组信息
* */
public static void callBackGroupInfo (java.util.Map<String, GroupInfoDto> groupInfoDtos) {
public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
try {
org.json.JSONArray arr = new org.json.JSONArray();
for (GroupInfoDto dto : groupInfoDtos.values()) {
for (GroupInfoDto dto : groupInfoDtos) {
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);
}
pushJsThrottled("GroupList", "if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('GroupList', " + arr.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -201,7 +157,7 @@ public class CallBackUtil {
obj.put("groupName", groupName);
obj.put("talkId", talkId);
obj.put("talkName", talkName);
pushJsThrottled("SpeakerUpdate", "if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('SpeakerUpdate', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -215,7 +171,7 @@ public class CallBackUtil {
obj.put("groupName", groupName);
obj.put("talkId", talkId);
obj.put("talkName", talkName);
pushJsThrottled("PlayStatus", "if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("if(window.onPttEvent){window.onPttEvent('PlayStatus', " + obj.toString() + ");}");
} catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
}
@ -255,7 +211,7 @@ public class CallBackUtil {
obj.put("latitude", latitude);
obj.put("longitude", longitude);
obj.put("time", time);
pushJsThrottled("LocationStatus", "if(window.onPttEvent){window.onPttEvent('LocationStatus', " + obj.toString() + ");}");
com.stand.standapp.MainActivity.executeJs("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

@ -9,7 +9,6 @@ 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
@ -18,7 +17,7 @@ import java.io.IOException
class LoginActivity : AppCompatActivity() {
private val client = NetworkModule.defaultClient
private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -489,19 +489,10 @@ class MainActivity : AppCompatActivity() {
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

@ -114,9 +114,11 @@ class TempCallActivity : AppCompatActivity() {
pulseAnimator?.cancel()
if (instance == this) {
instance = null
// 如果不是最小化状态,说明是真正结束,清除所有状态
if (!isMinimized) {
currentGroupName = "临时会话"
}
// 通知 web 页面状态变化
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
}
}

View File

@ -15,7 +15,6 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import com.stand.standapp.net.NetworkModule
import okhttp3.*
import org.json.JSONObject
import java.io.File
@ -59,7 +58,7 @@ class UpdateManager(private val context: Context) {
val url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
val client = NetworkModule.defaultClient
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
@ -140,7 +139,7 @@ class UpdateManager(private val context: Context) {
private fun startManualDownload(url: String) {
showProgressDialog()
val client = NetworkModule.defaultClient
val client = OkHttpClient()
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", "Mozilla/5.0 (Android)")

View File

@ -1,25 +0,0 @@
package com.stand.standapp.net
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object NetworkModule {
private const val DEFAULT_CONNECT_TIMEOUT_SEC = 15L
private const val DEFAULT_READ_TIMEOUT_SEC = 30L
private const val DEFAULT_WRITE_TIMEOUT_SEC = 30L
val defaultClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(DEFAULT_CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS)
.readTimeout(DEFAULT_READ_TIMEOUT_SEC, TimeUnit.SECONDS)
.writeTimeout(DEFAULT_WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
fun streamingClient(): OkHttpClient {
return defaultClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
}
}

View File

@ -13,16 +13,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
private const val STREAM_FLUSH_INTERVAL_MS = 50L
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val repository = AiChatRepository()
@ -35,49 +30,14 @@ 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卡顿
@ -172,21 +132,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
fun cancelActiveStreaming() {
activeChatJob?.cancel()
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
activeChatJob = null
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
_messages.value = _messages.value.map { msg ->
if (msg.isStreaming) {
val finalContent = _streamingContent.value[msg.id] ?: msg.content
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false
saveCacheToLocal()
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
}
fun createNewSession() {
@ -275,21 +232,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun appendAiMessageChunk(messageId: String, chunk: String) {
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
scheduleFlush(messageId)
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
msg.copy(content = msg.content + chunk)
} else msg
}
}
private fun finalizeAiMessage(messageId: String) {
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
_streamingContent.update { it - messageId }
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
lastFlushAt.remove(messageId)
saveCacheToLocal()
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
}
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {

View File

@ -162,18 +162,6 @@ fun ChatArea(
@Composable
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
val markwon = remember(context) {
io.noties.markwon.Markwon.builder(context)
.usePlugin(io.noties.markwon.core.CorePlugin.create())
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context))
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(context))
.build()
}
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply {
@ -185,14 +173,23 @@ 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 currentMarkwon = view.tag as? io.noties.markwon.Markwon
if (currentMarkwon != null) {
currentMarkwon.setMarkdown(view, markdown)
val markwon = view.tag as? io.noties.markwon.Markwon
if (markwon != null) {
markwon.setMarkdown(view, markdown)
} else {
view.text = markdown
}

View File

@ -31,19 +31,9 @@ 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 = 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()
}
val currentMessages = messages.filter { it.sessionId == currentSessionId }
// 清除确认对话框
if (showClearDialog) {

View File

@ -2,7 +2,6 @@ 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
@ -16,9 +15,12 @@ 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 = NetworkModule.streamingClient()
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
.build()
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关

View File

@ -2,7 +2,6 @@ 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
@ -18,7 +17,7 @@ import kotlin.system.exitProcess
object LogManager {
private val executor = Executors.newSingleThreadExecutor()
private const val MAX_DAYS = 15
private val client = NetworkModule.defaultClient
private val client = OkHttpClient()
private var logDir: File? = null
fun init(context: Context) {