Compare commits

..

No commits in common. "opti4" and "main" have entirely different histories.
opti4 ... main

18 changed files with 120 additions and 303 deletions

16
.codegraph/.gitignore vendored
View File

@ -1,16 +0,0 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View File

@ -23,8 +23,7 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.StandAPP"
android:hardwareAccelerated="true">
android:theme="@style/Theme.StandAPP">
<activity
android:name=".SplashActivity"

View File

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

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,20 +17,17 @@ 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;
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;
@ -145,7 +139,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 +168,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;
@ -549,16 +543,13 @@ public class CallBackResolution {
if (tempTxt.contains("已登录")) {
String showName = tempTxt.substring(3, tempTxt.length());
CallBackUtil.callBackLogin(true, showName);
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
keepAliveExecutor.shutdownNow();
}
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
Runnable task = () -> {
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
SendAtUtil.sendUDP();
SendAtUtil.sendTCP();
};
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
executor.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,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

@ -31,12 +31,10 @@ class MainActivity : AppCompatActivity() {
@Volatile
private var pendingJsCode: String? = null
private val jsLock = Any()
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(jsLock) {
synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
@ -121,7 +119,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(jsLock) {
synchronized(pendingJsCode ?: Any()) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
@ -474,34 +472,15 @@ 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,37 +7,14 @@ 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)
// 异步检查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()
// 延迟 2 秒进入登录界面
Handler(Looper.getMainLooper()).postDelayed({
startActivity(Intent(this, LoginActivity::class.java))
finish()
}, 2000)
}
}

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 = 150L
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卡顿
@ -139,57 +99,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun saveCacheToLocal() {
// 将数据保存放到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)
}
prefs.edit()
.putString("cache_sessions", sessionsArray.toString())
.putString("cache_messages", messagesArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
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)
}
prefs.edit()
.putString("cache_sessions", sessionsArray.toString())
.putString("cache_messages", messagesArray.toString())
.apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 👈 用户手动中断当前正在生成的 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() {
@ -278,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

@ -94,19 +94,25 @@ 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,18 +40,6 @@ 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()
@ -123,7 +111,6 @@ fun ChatArea(
val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
MarkwonRenderer(
markdown = contentWithCursor,
markwon = markwon,
modifier = Modifier.padding(12.dp)
)
}
@ -174,7 +161,7 @@ fun ChatArea(
}
@Composable
fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) {
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply {
@ -186,14 +173,23 @@ fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifi
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

@ -35,8 +35,8 @@ object GpioManager {
Timber.tag("GPIO").i("Start GPIO listening thread")
while (mIsRunning) {
try {
// 硬件轮询频率优化:150ms 既能保证响应速度,又能降低 CPU 消耗
Thread.sleep(150)
// 硬件轮询频率优化:50ms 既能保证响应速度,又能降低 CPU 消耗
Thread.sleep(50)
val manager = mZysjSystemManager ?: continue
// 获取 GPIO 1 的值 (0 按下, 1 松开)

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) {
@ -99,6 +98,19 @@ 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) {
@ -187,9 +199,6 @@ 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")