Compare commits

..

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

21 changed files with 142 additions and 596 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

@ -524,80 +524,13 @@
var infoStr = window.android.getAboutInfo();
try {
var info = JSON.parse(infoStr);
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);
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;
document.getElementById('aboutModal').style.display = 'flex';
} catch (e) {
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;

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

@ -378,9 +378,22 @@ class AndroidInterface(private val activity: MainActivity) {
fun getAboutInfo(): String {
return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
val json = com.stand.standapp.utils.DeviceInfoUtils.getDeviceInfoJson(activity)
val json = JSONObject()
json.put("appName", activity.getString(R.string.app_name))
json.put("versionName", packageInfo.versionName ?: "1.0.0")
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.toString()
} catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")

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

@ -1,217 +0,0 @@
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 {
// 硬件轮询频率优化: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")