Compare commits
21 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
9ed1e31700 | |
|
|
e266a4e690 | |
|
|
89f1df9a9b | |
|
|
96068d56b2 | |
|
|
62cb78167d | |
|
|
d9b65c0e7e | |
|
|
992c18d8dc | |
|
|
4cb6b4ed08 | |
|
|
854e04b28f | |
|
|
eaf169c626 | |
|
|
4465887c96 | |
|
|
4b2bf8f5f4 | |
|
|
7060b99dd9 | |
|
|
58885f69a9 | |
|
|
01dec2de0e | |
|
|
a2c8fd2c1a | |
|
|
414681fa26 | |
|
|
450e04db89 | |
|
|
a982e54eeb | |
|
|
8bddca4996 | |
|
|
397e103596 |
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
' setInterval(function() {',
|
||||
' var code = prompt("bridge:_poll", "");',
|
||||
' if (code) { try { eval(code); } catch(e) {} }',
|
||||
' }, 500);',
|
||||
' }, 2000);',
|
||||
'})();'
|
||||
].join('\n');
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import android.util.Log;
|
|||
import timber.log.Timber;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
|
@ -17,17 +20,20 @@ public class CallBackResolution {
|
|||
private static String TAG = "PTT_RX";
|
||||
private static String indexGroupId = "";//用户所在群组Id
|
||||
private static String indexGroupName = "";//用户所在群组名称
|
||||
private static List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
|
||||
private static final int MAX_GROUP_MEMBERS = 500;
|
||||
private static final int MAX_GROUP_INFOS = 100;
|
||||
private static final Map<String, GroupMemberInfoDto> memberInfoDtos = new LinkedHashMap<>(MAX_GROUP_MEMBERS);
|
||||
private static int speckType = 1;
|
||||
private static String talkId = "";
|
||||
private static String talkName = "";
|
||||
private static ArrayList<GroupInfoDto> groupInfos = new ArrayList<>();
|
||||
private static final Map<String, GroupInfoDto> groupInfos = new LinkedHashMap<>(MAX_GROUP_INFOS);
|
||||
private static String oldLoginSate = "00";
|
||||
private static int logNum = 0;
|
||||
private static long offlineTime = 0;
|
||||
private static boolean loginState = false;
|
||||
private static String pttLoginStatus = "00"; // 00-未登录 01-登录中 02-已登录
|
||||
private static String pttLoginId = "";
|
||||
private static ScheduledExecutorService keepAliveExecutor = null;
|
||||
|
||||
public static String getPttLoginStatus() {
|
||||
return pttLoginStatus;
|
||||
|
|
@ -139,7 +145,7 @@ public class CallBackResolution {
|
|||
Timber.tag(TAG).d( "at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
|
||||
if (1 == groupNo) groupInfos.clear();
|
||||
GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount);
|
||||
groupInfos.add(groupInfo);
|
||||
groupInfos.put(groupId, groupInfo);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -168,7 +174,7 @@ public class CallBackResolution {
|
|||
String tempTxt = unicodeToString(groupMemberName.toString());
|
||||
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
|
||||
if (memberNo == 1) memberInfoDtos.clear();
|
||||
memberInfoDtos.add(infoDto);
|
||||
memberInfoDtos.put(memberId, infoDto);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -543,13 +549,16 @@ public class CallBackResolution {
|
|||
if (tempTxt.contains("已登录")) {
|
||||
String showName = tempTxt.substring(3, tempTxt.length());
|
||||
CallBackUtil.callBackLogin(true, showName);
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
|
||||
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) {
|
||||
keepAliveExecutor.shutdownNow();
|
||||
}
|
||||
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||
Runnable task = () -> {
|
||||
Timber.tag(TAG).d( "callBackLogin send tcp and udp");
|
||||
SendAtUtil.sendUDP();
|
||||
SendAtUtil.sendTCP();
|
||||
};
|
||||
executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
|
||||
} else if (tempTxt.contains("账号已更新")) {
|
||||
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
|
||||
} else if (tempTxt.contains("账号或密码错误")) {
|
||||
|
|
|
|||
|
|
@ -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"); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ class MainActivity : AppCompatActivity() {
|
|||
@Volatile
|
||||
private var pendingJsCode: String? = null
|
||||
|
||||
private val jsLock = Any()
|
||||
|
||||
@JvmStatic
|
||||
fun executeJs(script: String) {
|
||||
Timber.tag("ExecuteJs").d(script)
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
// 合并多次调用(用换行分隔)
|
||||
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
|
||||
}
|
||||
|
|
@ -119,7 +121,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
|
||||
@JvmStatic
|
||||
fun pollPendingJs(): String {
|
||||
synchronized(pendingJsCode ?: Any()) {
|
||||
synchronized(jsLock) {
|
||||
val code = pendingJsCode ?: ""
|
||||
pendingJsCode = null
|
||||
return code
|
||||
|
|
@ -472,15 +474,34 @@ class MainActivity : AppCompatActivity() {
|
|||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
geckoSession?.setActive(false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
geckoSession?.setActive(true)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelTimeoutTimer()
|
||||
geckoView.releaseSession()
|
||||
geckoSession?.close()
|
||||
geckoSession = null
|
||||
clearCompanionState()
|
||||
instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun clearCompanionState() {
|
||||
synchronized(jsLock) {
|
||||
pendingJsCode = null
|
||||
}
|
||||
pendingDialog = null
|
||||
isConflictDialogShowing = false
|
||||
}
|
||||
|
||||
private fun startTimeoutTimer() {
|
||||
cancelTimeoutTimer()
|
||||
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,37 @@ import android.os.Looper
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var checkCount = 0
|
||||
private val maxChecks = 20 // 最多检查20次(2秒)
|
||||
|
||||
private val checkInitRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (MyApplication.pNative != null || checkCount >= maxChecks) {
|
||||
navigateToLogin()
|
||||
} else {
|
||||
checkCount++
|
||||
handler.postDelayed(this, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_splash)
|
||||
|
||||
// 延迟 2 秒进入登录界面
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
// 异步检查PTT JNI初始化状态,避免硬等待2秒
|
||||
handler.post(checkInitRunnable)
|
||||
}
|
||||
|
||||
private fun navigateToLogin() {
|
||||
if (isFinishing || isDestroyed) return
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacks(checkInitRunnable)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,11 +114,9 @@ class TempCallActivity : AppCompatActivity() {
|
|||
pulseAnimator?.cancel()
|
||||
if (instance == this) {
|
||||
instance = null
|
||||
// 如果不是最小化状态,说明是真正结束,清除所有状态
|
||||
if (!isMinimized) {
|
||||
currentGroupName = "临时会话"
|
||||
}
|
||||
// 通知 web 页面状态变化
|
||||
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import android.widget.Toast
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import com.stand.standapp.net.NetworkModule
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
|
@ -58,7 +59,7 @@ class UpdateManager(private val context: Context) {
|
|||
|
||||
val url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
|
||||
|
||||
val client = OkHttpClient()
|
||||
val client = NetworkModule.defaultClient
|
||||
val request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
|
|
@ -139,7 +140,7 @@ class UpdateManager(private val context: Context) {
|
|||
private fun startManualDownload(url: String) {
|
||||
showProgressDialog()
|
||||
|
||||
val client = OkHttpClient()
|
||||
val client = NetworkModule.defaultClient
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", "Mozilla/5.0 (Android)")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
private const val STREAM_FLUSH_INTERVAL_MS = 150L
|
||||
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = AiChatRepository()
|
||||
|
||||
|
|
@ -30,14 +35,49 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
// 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问!
|
||||
private val _streamingContent = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val streamingContent: StateFlow<Map<String, String>> = _streamingContent.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
// 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话!
|
||||
private var activeChatJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE)
|
||||
private val chunkBuffer = java.util.concurrent.ConcurrentHashMap<String, StringBuilder>()
|
||||
private val lastFlushAt = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private val flushScope = kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
|
||||
)
|
||||
private val flushMutex = kotlinx.coroutines.sync.Mutex()
|
||||
|
||||
private fun scheduleFlush(messageId: String) {
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
val last = lastFlushAt[messageId] ?: 0
|
||||
val delta = now - last
|
||||
if (delta >= STREAM_FLUSH_INTERVAL_MS) {
|
||||
flushStreamingMessage(messageId)
|
||||
} else {
|
||||
flushScope.launch {
|
||||
kotlinx.coroutines.delay(STREAM_FLUSH_INTERVAL_MS - delta)
|
||||
flushStreamingMessage(messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushStreamingMessage(messageId: String) {
|
||||
flushScope.launch {
|
||||
flushMutex.withLock {
|
||||
val buffer = chunkBuffer.remove(messageId) ?: return@withLock
|
||||
val pending = buffer.toString()
|
||||
if (pending.isEmpty()) return@withLock
|
||||
_streamingContent.update { current ->
|
||||
current + (messageId to (current[messageId].orEmpty() + pending))
|
||||
}
|
||||
lastFlushAt[messageId] = android.os.SystemClock.uptimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// 👈 将数据加载移到IO线程,避免阻塞主线程导致UI卡顿
|
||||
|
|
@ -99,6 +139,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun saveCacheToLocal() {
|
||||
// 将数据保存放到IO线程中,防止SharedPreferences写大文本阻塞主线程卡顿
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
// 保存会话列表
|
||||
val sessionsArray = JSONArray()
|
||||
|
|
@ -129,21 +171,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
|
||||
fun cancelActiveStreaming() {
|
||||
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
|
||||
activeChatJob?.cancel()
|
||||
activeChatJob = null
|
||||
|
||||
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.isStreaming) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = _streamingContent.value[msg.id] ?: msg.content
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
_streamingContent.value = emptyMap()
|
||||
chunkBuffer.clear()
|
||||
lastFlushAt.clear()
|
||||
|
||||
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
|
||||
saveCacheToLocal() // 强制存盘归档
|
||||
_isLoading.value = false
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
fun createNewSession() {
|
||||
|
|
@ -232,20 +278,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
|
||||
private fun appendAiMessageChunk(messageId: String, chunk: String) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(content = msg.content + chunk)
|
||||
} else msg
|
||||
}
|
||||
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
|
||||
scheduleFlush(messageId)
|
||||
}
|
||||
|
||||
private fun finalizeAiMessage(messageId: String) {
|
||||
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(isStreaming = false)
|
||||
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
|
||||
_streamingContent.update { it - messageId }
|
||||
msg.copy(content = finalContent, isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
|
||||
lastFlushAt.remove(messageId)
|
||||
saveCacheToLocal()
|
||||
}
|
||||
|
||||
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 中动态读取当前服务器基准地址,完美适配真实网关
|
||||
|
|
|
|||
|
|
@ -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 松开)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue