Compare commits

..

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

20 changed files with 92 additions and 581 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

@ -1,94 +0,0 @@
/*
* StandAPP Bridge - background script
*
* Responsibilities:
* 1. Connect to native via browser.runtime.connectNative("standapp").
* 2. Forward executeJs messages from native to every connected content port.
* 3. Accept content-script connections, and signal "ready" when both the
* native link and the content port are up. Content script uses this
* ready flag to decide whether to enable the WebExtension push channel
* or fall back to the legacy prompt() polling bridge.
*/
"use strict";
const NATIVE_APP = "standapp";
const CONTENT_PORT_NAME = "bridge";
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
let nativePort = null;
let reconnectAttempt = 0;
let contentPorts = new Set();
function nextReconnectDelay() {
const idx = Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
const delay = RECONNECT_DELAYS_MS[idx];
reconnectAttempt++;
return delay;
}
function broadcastReadyToContent() {
const ready = nativePort !== null;
const msg = { type: "bridgeStatus", ready: ready };
contentPorts.forEach(function (p) {
try { p.postMessage(msg); } catch (e) {}
});
}
function handleNativeMessage(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type !== "executeJs") return;
if (typeof msg.script !== "string" || msg.script.length === 0) return;
contentPorts.forEach(function (p) {
try { p.postMessage({ type: "executeJs", script: msg.script }); }
catch (e) {}
});
}
function connectNative() {
try {
nativePort = browser.runtime.connectNative(NATIVE_APP);
} catch (e) {
console.log("[bridge-bg] connectNative threw", String(e));
nativePort = null;
scheduleReconnect();
broadcastReadyToContent();
return;
}
reconnectAttempt = 0;
console.log("[bridge-bg] native port connected");
nativePort.onMessage.addListener(handleNativeMessage);
nativePort.onDisconnect.addListener(function () {
console.log("[bridge-bg] native port disconnected");
nativePort = null;
broadcastReadyToContent();
scheduleReconnect();
});
broadcastReadyToContent();
}
function scheduleReconnect() {
const delay = nextReconnectDelay();
setTimeout(function () {
if (nativePort === null) connectNative();
}, delay);
}
browser.runtime.onConnect.addListener(function (port) {
if (port.name !== CONTENT_PORT_NAME) return;
contentPorts.add(port);
console.log("[bridge-bg] content port added, total =", contentPorts.size);
port.onDisconnect.addListener(function () {
contentPorts.delete(port);
console.log("[bridge-bg] content port removed, total =", contentPorts.size);
});
port.postMessage({ type: "bridgeStatus", ready: nativePort !== null });
});
connectNative();

View File

@ -1,28 +1,5 @@
/*
* StandAPP Bridge - content script
*
* Runs in the content-script isolated world, with manifest content_scripts
* settings { matches: "<all_urls>", run_at: "document_start" }.
*
* Two responsibilities:
* 1. Inject a page-context script that builds the window.android Proxy.
* The Proxy still routes every method call through prompt("bridge:...")
* so the page's synchronous calls (e.g. JSON.parse(window.android.xxx()))
* keep working without any front-end change.
* 2. Subscribe to a Port from background.js for native->page pushes.
* The push channel is the WebExtension Port; received scripts are
* injected into the page DOM (same <script> trick) so the script runs
* in the page context and can call page globals like updatePttTalkStatus.
*
* Fallback: if no bridgeStatus{ready:true} arrives within 1.5s, the legacy
* prompt("bridge:_poll") polling is enabled. As soon as the Port reports
* ready, the polling is cleared.
*/
(function () {
"use strict";
var PAGE_SCRIPT = [
(function() {
var code = [
'(function() {',
' var _realAndroid = window.android;',
' window.android = new Proxy({}, {',
@ -40,110 +17,18 @@
' try { window.android[k] = _realAndroid[k]; } catch(e) {}',
' });',
' }',
' setInterval(function() {',
' var code = prompt("bridge:_poll", "");',
' if (code) { try { eval(code); } catch(e) {} }',
' }, 500);',
'})();'
].join("\n");
].join('\n');
function injectPageScript(code) {
try {
var script = document.createElement("script");
script.textContent = code;
var parent = document.head || document.documentElement;
if (parent) {
parent.appendChild(script);
script.parentNode && script.parentNode.removeChild(script);
}
} catch (e) {
console.log("[bridge-cs] inject failed", String(e));
}
var script = document.createElement('script');
script.textContent = code;
var parent = document.head || document.documentElement;
if (parent) {
parent.appendChild(script);
script.remove();
}
injectPageScript(PAGE_SCRIPT);
var nativeChannelReady = false;
var pollTimer = null;
var bgPort = null;
var readyTimeout = null;
var lastPollScript = "";
function startFallbackPoll() {
if (pollTimer !== null) return;
console.log("[bridge-cs] fallback poll enabled");
pollTimer = setInterval(function () {
try {
var code = prompt("bridge:_poll", "");
if (code && code.length > 0 && code !== lastPollScript) {
lastPollScript = code;
injectPageScript(code);
}
} catch (e) {}
}, 2000);
}
function stopFallbackPoll() {
if (pollTimer === null) return;
clearInterval(pollTimer);
pollTimer = null;
console.log("[bridge-cs] fallback poll stopped");
}
function clearReadyTimeout() {
if (readyTimeout !== null) {
clearTimeout(readyTimeout);
readyTimeout = null;
}
}
function connectBackground() {
try {
if (!browser || !browser.runtime || !browser.runtime.connect) {
throw new Error("runtime.connect unavailable");
}
bgPort = browser.runtime.connect({ name: "bridge" });
} catch (e) {
console.log("[bridge-cs] connect failed", String(e));
bgPort = null;
startFallbackPoll();
return;
}
bgPort.onMessage.addListener(function (msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "bridgeStatus") {
if (msg.ready) {
nativeChannelReady = true;
clearReadyTimeout();
stopFallbackPoll();
console.log("[bridge-cs] native channel ready");
} else {
nativeChannelReady = false;
startFallbackPoll();
console.log("[bridge-cs] native channel down");
}
} else if (msg.type === "executeJs" && typeof msg.script === "string") {
injectPageScript(msg.script);
}
});
bgPort.onDisconnect.addListener(function () {
console.log("[bridge-cs] bg port disconnected");
bgPort = null;
nativeChannelReady = false;
clearReadyTimeout();
startFallbackPoll();
setTimeout(connectBackground, 1000);
});
}
function armReadyTimeout() {
clearReadyTimeout();
readyTimeout = setTimeout(function () {
if (!nativeChannelReady) {
console.log("[bridge-cs] no ready in 1500ms, starting fallback");
startFallbackPoll();
}
}, 1500);
}
armReadyTimeout();
connectBackground();
})();

View File

@ -9,15 +9,6 @@
"version": "1.0",
"type": "extension",
"description": "JS-Native bridge for StandAPP",
"permissions": [
"nativeMessaging",
"tabs",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["bridge.js"],

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

@ -1,81 +0,0 @@
package com.stand.standapp
import org.json.JSONObject
import org.mozilla.geckoview.WebExtension
import timber.log.Timber
/**
* GeckoView WebExtension MessageDelegate for the StandAPP bridge.
*
* Acts as the native side of the browser.runtime.connectNative("standapp")
* port opened by the extension's background script. The extension forwards
* {type:"executeJs", script:"..."} messages emitted from background to every
* connected content script.
*
* Usage from MainActivity:
* runtime.webExtensionController // not used directly; setMessageDelegate is
* invoked on the installed WebExtension instance.
*
* extension.setMessageDelegate(BridgePortDelegate(), "standapp")
*
* BridgePortDelegate.postScript("updatePttTalkStatus(true)")
*
* On the very first connect (background also connects to native for the
* first time), a short startup window exists during which the port is not
* yet attached. Callers can check isReady() or rely on the Boolean return
* value of postScript() to fall back to the legacy prompt() buffer.
*/
class BridgePortDelegate : WebExtension.MessageDelegate {
@Volatile
private var port: WebExtension.Port? = null
@Volatile
private var ready: Boolean = false
override fun onConnect(port: WebExtension.Port) {
Timber.tag(TAG).i("Native port connected: name=%s", port.name)
this.port = port
this.ready = true
port.setDelegate(object : WebExtension.PortDelegate {
override fun onPortMessage(message: Any, p: WebExtension.Port) {
Timber.tag(TAG).d("Received from extension: %s", message)
}
override fun onDisconnect(p: WebExtension.Port) {
Timber.tag(TAG).w("Native port disconnected: name=%s", p.name)
if (this@BridgePortDelegate.port === p) {
this@BridgePortDelegate.ready = false
this@BridgePortDelegate.port = null
}
}
})
}
fun isReady(): Boolean = ready && port != null
/**
* Push a script to every connected content script via the WebExtension
* Port. Returns true on success, false when the channel is not yet
* attached (callers should then fall back to the prompt() buffer).
*/
fun postScript(script: String): Boolean {
val p = port
if (p == null || !ready) return false
return try {
val payload = JSONObject().apply {
put("type", "executeJs")
put("script", script)
}
p.postMessage(payload)
true
} catch (e: Throwable) {
Timber.tag(TAG).e(e, "postScript failed")
false
}
}
companion object {
private const val TAG = "BridgePort"
}
}

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,16 +31,11 @@ class MainActivity : AppCompatActivity() {
@Volatile
private var pendingJsCode: String? = null
private val jsLock = Any()
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
val pushed = instance?.bridgePortDelegate?.postScript(script) == true
if (pushed) {
return
}
synchronized(jsLock) {
synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@ -124,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
@ -135,7 +130,6 @@ class MainActivity : AppCompatActivity() {
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private val bridgePortDelegate = BridgePortDelegate()
private var canGoBack = false
private lateinit var geckoView: GeckoView
@ -189,10 +183,6 @@ class MainActivity : AppCompatActivity() {
)?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread {
if (extension != null) {
extension.setMessageDelegate(bridgePortDelegate, "standapp")
Timber.tag("MainActivity").d("Bridge MessageDelegate registered")
}
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
@ -482,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 = 50L
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val repository = AiChatRepository()
@ -35,49 +30,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _streamingContent = MutableStateFlow<Map<String, String>>(emptyMap())
val streamingContent: StateFlow<Map<String, String>> = _streamingContent.asStateFlow()
// 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问!
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话!
private var activeChatJob: kotlinx.coroutines.Job? = null
private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE)
private val chunkBuffer = java.util.concurrent.ConcurrentHashMap<String, StringBuilder>()
private val lastFlushAt = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val flushScope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
)
private val flushMutex = kotlinx.coroutines.sync.Mutex()
private fun scheduleFlush(messageId: String) {
val now = android.os.SystemClock.uptimeMillis()
val last = lastFlushAt[messageId] ?: 0
val delta = now - last
if (delta >= STREAM_FLUSH_INTERVAL_MS) {
flushStreamingMessage(messageId)
} else {
flushScope.launch {
kotlinx.coroutines.delay(STREAM_FLUSH_INTERVAL_MS - delta)
flushStreamingMessage(messageId)
}
}
}
private fun flushStreamingMessage(messageId: String) {
flushScope.launch {
flushMutex.withLock {
val buffer = chunkBuffer.remove(messageId) ?: return@withLock
val pending = buffer.toString()
if (pending.isEmpty()) return@withLock
_streamingContent.update { current ->
current + (messageId to (current[messageId].orEmpty() + pending))
}
lastFlushAt[messageId] = android.os.SystemClock.uptimeMillis()
}
}
}
init {
// 👈 将数据加载移到IO线程避免阻塞主线程导致UI卡顿
@ -172,21 +132,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 👈 用户手动中断当前正在生成的 AI 回答的方法!
fun cancelActiveStreaming() {
activeChatJob?.cancel()
activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
activeChatJob = null
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
_messages.value = _messages.value.map { msg ->
if (msg.isStreaming) {
val finalContent = _streamingContent.value[msg.id] ?: msg.content
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false
saveCacheToLocal()
_isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() // 强制存盘归档
}
fun createNewSession() {
@ -275,21 +232,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun appendAiMessageChunk(messageId: String, chunk: String) {
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk)
scheduleFlush(messageId)
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
msg.copy(content = msg.content + chunk)
} else msg
}
}
private fun finalizeAiMessage(messageId: String) {
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
_messages.value = _messages.value.map { msg ->
if (msg.id == messageId) {
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue
_streamingContent.update { it - messageId }
msg.copy(content = finalContent, isStreaming = false)
msg.copy(isStreaming = false)
} else msg
}
lastFlushAt.remove(messageId)
saveCacheToLocal()
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
}
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {

View File

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

View File

@ -31,19 +31,9 @@ fun ChatScaffold(
val sessions by viewModel.sessions.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val messages by viewModel.messages.collectAsState()
val streamingContent by viewModel.streamingContent.collectAsState()
var showClearDialog by remember { mutableStateOf(false) }
val currentMessages = remember(messages, streamingContent, currentSessionId) {
val streaming = streamingContent
messages.asSequence()
.filter { it.sessionId == currentSessionId }
.map { msg ->
val live = streaming[msg.id]
if (msg.isStreaming && live != null) msg.copy(content = live) else msg
}
.toList()
}
val currentMessages = messages.filter { it.sessionId == currentSessionId }
// 清除确认对话框
if (showClearDialog) {

View File

@ -2,7 +2,6 @@ package com.stand.standapp.ui.chat.repo
import com.stand.standapp.AppConfig
import android.content.Context
import com.stand.standapp.net.NetworkModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
@ -16,9 +15,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
class AiChatRepository {
private val client: OkHttpClient = NetworkModule.streamingClient()
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
.build()
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
// 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关

View File

@ -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) {