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:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.StandAPP" android:theme="@style/Theme.StandAPP">
android:hardwareAccelerated="true">
<activity <activity
android:name=".SplashActivity" 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 @@
/* (function() {
* StandAPP Bridge - content script var code = [
*
* 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() {', '(function() {',
' var _realAndroid = window.android;', ' var _realAndroid = window.android;',
' window.android = new Proxy({}, {', ' window.android = new Proxy({}, {',
@ -40,110 +17,18 @@
' try { window.android[k] = _realAndroid[k]; } catch(e) {}', ' 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) { var script = document.createElement('script');
try { script.textContent = code;
var script = document.createElement("script"); var parent = document.head || document.documentElement;
script.textContent = code; if (parent) {
var parent = document.head || document.documentElement; parent.appendChild(script);
if (parent) { script.remove();
parent.appendChild(script);
script.parentNode && script.parentNode.removeChild(script);
}
} catch (e) {
console.log("[bridge-cs] inject failed", String(e));
}
} }
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", "version": "1.0",
"type": "extension", "type": "extension",
"description": "JS-Native bridge for StandAPP", "description": "JS-Native bridge for StandAPP",
"permissions": [
"nativeMessaging",
"tabs",
"<all_urls>"
],
"background": {
"scripts": ["background.js"],
"persistent": true
},
"content_scripts": [{ "content_scripts": [{
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["bridge.js"], "js": ["bridge.js"],

View File

@ -5,10 +5,7 @@ import android.util.Log;
import timber.log.Timber; import timber.log.Timber;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -20,20 +17,17 @@ public class CallBackResolution {
private static String TAG = "PTT_RX"; private static String TAG = "PTT_RX";
private static String indexGroupId = "";//用户所在群组Id private static String indexGroupId = "";//用户所在群组Id
private static String indexGroupName = "";//用户所在群组名称 private static String indexGroupName = "";//用户所在群组名称
private static final int MAX_GROUP_MEMBERS = 500; private static List<GroupMemberInfoDto> memberInfoDtos = new ArrayList<>();
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 int speckType = 1;
private static String talkId = ""; private static String talkId = "";
private static String talkName = ""; 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 String oldLoginSate = "00";
private static int logNum = 0; private static int logNum = 0;
private static long offlineTime = 0; private static long offlineTime = 0;
private static boolean loginState = false; private static boolean loginState = false;
private static String pttLoginStatus = "00"; // 00-未登录 01-登录中 02-已登录 private static String pttLoginStatus = "00"; // 00-未登录 01-登录中 02-已登录
private static String pttLoginId = ""; private static String pttLoginId = "";
private static ScheduledExecutorService keepAliveExecutor = null;
public static String getPttLoginStatus() { public static String getPttLoginStatus() {
return pttLoginStatus; 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); Timber.tag(TAG).d( "at_OEM_AT_cb group info groupId: " + groupId + ", groupNo: " + groupNo+", groupCount: " +groupCount);
if (1 == groupNo) groupInfos.clear(); if (1 == groupNo) groupInfos.clear();
GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount); GroupInfoDto groupInfo = new GroupInfoDto(groupId, tempTxt, groupNo, groupCount);
groupInfos.put(groupId, groupInfo); groupInfos.add(groupInfo);
} }
break; break;
@ -174,7 +168,7 @@ public class CallBackResolution {
String tempTxt = unicodeToString(groupMemberName.toString()); String tempTxt = unicodeToString(groupMemberName.toString());
GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo); GroupMemberInfoDto infoDto = new GroupMemberInfoDto(memberId, tempTxt, status, memberNo, haveVideo);
if (memberNo == 1) memberInfoDtos.clear(); if (memberNo == 1) memberInfoDtos.clear();
memberInfoDtos.put(memberId, infoDto); memberInfoDtos.add(infoDto);
} }
break; break;
@ -549,16 +543,13 @@ public class CallBackResolution {
if (tempTxt.contains("已登录")) { if (tempTxt.contains("已登录")) {
String showName = tempTxt.substring(3, tempTxt.length()); String showName = tempTxt.substring(3, tempTxt.length());
CallBackUtil.callBackLogin(true, showName); CallBackUtil.callBackLogin(true, showName);
if (keepAliveExecutor != null && !keepAliveExecutor.isShutdown()) { ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
keepAliveExecutor.shutdownNow();
}
keepAliveExecutor = Executors.newSingleThreadScheduledExecutor();
Runnable task = () -> { Runnable task = () -> {
Timber.tag(TAG).d( "callBackLogin send tcp and udp"); Timber.tag(TAG).d( "callBackLogin send tcp and udp");
SendAtUtil.sendUDP(); SendAtUtil.sendUDP();
SendAtUtil.sendTCP(); SendAtUtil.sendTCP();
}; };
keepAliveExecutor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS); executor.scheduleAtFixedRate(task, 0, 40, TimeUnit.SECONDS);
} else if (tempTxt.contains("账号已更新")) { } else if (tempTxt.contains("账号已更新")) {
Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt); Timber.tag(TAG).w("TTS reports account updated: '%s'", tempTxt);
} else if (tempTxt.contains("账号或密码错误")) { } else if (tempTxt.contains("账号或密码错误")) {

View File

@ -1,14 +1,9 @@
package com.example.kingway.ptt; package com.example.kingway.ptt;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import timber.log.Timber; import timber.log.Timber;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**指令返回类 /**指令返回类
* 说明操作UI请在主线程进行 * 说明操作UI请在主线程进行
@ -16,45 +11,6 @@ import java.util.concurrent.ConcurrentHashMap;
public class CallBackUtil { public class CallBackUtil {
private static String TAG = "TYT_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-失败 * 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 { try {
org.json.JSONArray arr = new org.json.JSONArray(); org.json.JSONArray arr = new org.json.JSONArray();
if (dtos != null && state) { if (dtos != null && state) {
for (GroupMemberInfoDto dto : dtos.values()) { for (GroupMemberInfoDto dto : dtos) {
org.json.JSONObject obj = new org.json.JSONObject(); org.json.JSONObject obj = new org.json.JSONObject();
obj.put("memberId", dto.getMemberId()); obj.put("memberId", dto.getMemberId());
obj.put("memberName", dto.getGmemberName()); obj.put("memberName", dto.getGmemberName());
@ -124,7 +80,7 @@ public class CallBackUtil {
arr.put(obj); 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"); } } catch (Exception e) { Timber.tag(TAG).e(e, "Failed to push PTT event to JS"); }
} }
@ -172,17 +128,17 @@ public class CallBackUtil {
* 返回群组集合信息 * 返回群组集合信息
* GroupInfoDto 群组信息 * GroupInfoDto 群组信息
* */ * */
public static void callBackGroupInfo (java.util.Map<String, GroupInfoDto> groupInfoDtos) { public static void callBackGroupInfo (ArrayList < GroupInfoDto > groupInfoDtos) {
try { try {
org.json.JSONArray arr = new org.json.JSONArray(); 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(); org.json.JSONObject obj = new org.json.JSONObject();
obj.put("groupId", dto.getGroupId()); obj.put("groupId", dto.getGroupId());
obj.put("groupName", dto.getGroupName()); obj.put("groupName", dto.getGroupName());
obj.put("groupNo", dto.getGroupNo()); obj.put("groupNo", dto.getGroupNo());
arr.put(obj); 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"); } } 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("groupName", groupName);
obj.put("talkId", talkId); obj.put("talkId", talkId);
obj.put("talkName", talkName); 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"); } } 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("groupName", groupName);
obj.put("talkId", talkId); obj.put("talkId", talkId);
obj.put("talkName", talkName); 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"); } } 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("latitude", latitude);
obj.put("longitude", longitude); obj.put("longitude", longitude);
obj.put("time", time); 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"); } } 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 androidx.appcompat.app.AppCompatActivity
import com.hjq.permissions.Permission import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions import com.hjq.permissions.XXPermissions
import com.stand.standapp.net.NetworkModule
import com.stand.standapp.printer.PrinterManager import com.stand.standapp.printer.PrinterManager
import okhttp3.* import okhttp3.*
import org.json.JSONObject import org.json.JSONObject
@ -18,7 +17,7 @@ import java.io.IOException
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private val client = NetworkModule.defaultClient private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@ -31,16 +31,11 @@ class MainActivity : AppCompatActivity() {
@Volatile @Volatile
private var pendingJsCode: String? = null private var pendingJsCode: String? = null
private val jsLock = Any()
@JvmStatic @JvmStatic
fun executeJs(script: String) { fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script) Timber.tag("ExecuteJs").d(script)
val pushed = instance?.bridgePortDelegate?.postScript(script) == true synchronized(pendingJsCode ?: Any()) {
if (pushed) { // 合并多次调用(用换行分隔)
return
}
synchronized(jsLock) {
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
} }
} }
@ -124,7 +119,7 @@ class MainActivity : AppCompatActivity() {
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询) // 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic @JvmStatic
fun pollPendingJs(): String { fun pollPendingJs(): String {
synchronized(jsLock) { synchronized(pendingJsCode ?: Any()) {
val code = pendingJsCode ?: "" val code = pendingJsCode ?: ""
pendingJsCode = null pendingJsCode = null
return code return code
@ -135,7 +130,6 @@ class MainActivity : AppCompatActivity() {
private var geckoSession: GeckoSession? = null private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null private var pttInterface: AndroidInterface? = null
private val bridgePortDelegate = BridgePortDelegate()
private var canGoBack = false private var canGoBack = false
private lateinit var geckoView: GeckoView private lateinit var geckoView: GeckoView
@ -189,10 +183,6 @@ class MainActivity : AppCompatActivity() {
)?.accept { extension -> )?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}") Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread { runOnUiThread {
if (extension != null) {
extension.setMessageDelegate(bridgePortDelegate, "standapp")
Timber.tag("MainActivity").d("Bridge MessageDelegate registered")
}
val serverUrl = AppConfig.getServerUrl(this) val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) { val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}" "$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
@ -482,34 +472,15 @@ class MainActivity : AppCompatActivity() {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
override fun onPause() {
super.onPause()
geckoSession?.setActive(false)
}
override fun onResume() {
super.onResume()
geckoSession?.setActive(true)
}
override fun onDestroy() { override fun onDestroy() {
cancelTimeoutTimer() cancelTimeoutTimer()
geckoView.releaseSession() geckoView.releaseSession()
geckoSession?.close() geckoSession?.close()
geckoSession = null geckoSession = null
clearCompanionState()
instance = null instance = null
super.onDestroy() super.onDestroy()
} }
private fun clearCompanionState() {
synchronized(jsLock) {
pendingJsCode = null
}
pendingDialog = null
isConflictDialogShowing = false
}
private fun startTimeoutTimer() { private fun startTimeoutTimer() {
cancelTimeoutTimer() cancelTimeoutTimer()
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS) mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)

View File

@ -7,37 +7,14 @@ import android.os.Looper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
class SplashActivity : 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash) setContentView(R.layout.activity_splash)
// 异步检查PTT JNI初始化状态避免硬等待2秒 // 延迟 2 秒进入登录界面
handler.post(checkInitRunnable) Handler(Looper.getMainLooper()).postDelayed({
} startActivity(Intent(this, LoginActivity::class.java))
finish()
private fun navigateToLogin() { }, 2000)
if (isFinishing || isDestroyed) return
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
override fun onDestroy() {
handler.removeCallbacks(checkInitRunnable)
super.onDestroy()
} }
} }

View File

@ -114,9 +114,11 @@ class TempCallActivity : AppCompatActivity() {
pulseAnimator?.cancel() pulseAnimator?.cancel()
if (instance == this) { if (instance == this) {
instance = null instance = null
// 如果不是最小化状态,说明是真正结束,清除所有状态
if (!isMinimized) { if (!isMinimized) {
currentGroupName = "临时会话" currentGroupName = "临时会话"
} }
// 通知 web 页面状态变化
MainActivity.executeJs("onTempCallStateChanged('" + if (isMinimized) "minimized" else "destroyed" + "')") 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.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.stand.standapp.net.NetworkModule
import okhttp3.* import okhttp3.*
import org.json.JSONObject import org.json.JSONObject
import java.io.File 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 url = "$serverUrl/api/app-version/check-update?versionCode=$currentVersionCode"
val client = NetworkModule.defaultClient val client = OkHttpClient()
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
@ -140,7 +139,7 @@ class UpdateManager(private val context: Context) {
private fun startManualDownload(url: String) { private fun startManualDownload(url: String) {
showProgressDialog() showProgressDialog()
val client = NetworkModule.defaultClient val client = OkHttpClient()
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("User-Agent", "Mozilla/5.0 (Android)") .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.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.UUID import java.util.UUID
private const val STREAM_FLUSH_INTERVAL_MS = 50L
class ChatViewModel(application: Application) : AndroidViewModel(application) { class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val repository = AiChatRepository() private val repository = AiChatRepository()
@ -35,49 +30,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList()) private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow() 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) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话!
private var activeChatJob: kotlinx.coroutines.Job? = null private var activeChatJob: kotlinx.coroutines.Job? = null
private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE) 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 { init {
// 👈 将数据加载移到IO线程避免阻塞主线程导致UI卡顿 // 👈 将数据加载移到IO线程避免阻塞主线程导致UI卡顿
@ -172,21 +132,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 👈 用户手动中断当前正在生成的 AI 回答的方法! // 👈 用户手动中断当前正在生成的 AI 回答的方法!
fun cancelActiveStreaming() { fun cancelActiveStreaming() {
activeChatJob?.cancel() activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流!
activeChatJob = null activeChatJob = null
// 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式
_messages.value = _messages.value.map { msg -> _messages.value = _messages.value.map { msg ->
if (msg.isStreaming) { if (msg.isStreaming) {
val finalContent = _streamingContent.value[msg.id] ?: msg.content msg.copy(isStreaming = false)
msg.copy(content = finalContent, isStreaming = false)
} else msg } else msg
} }
_streamingContent.value = emptyMap()
chunkBuffer.clear()
lastFlushAt.clear()
_isLoading.value = false _isLoading.value = false // 释放锁,允许用户立刻开始下一次提问!
saveCacheToLocal() saveCacheToLocal() // 强制存盘归档
} }
fun createNewSession() { fun createNewSession() {
@ -275,21 +232,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
private fun appendAiMessageChunk(messageId: String, chunk: String) { private fun appendAiMessageChunk(messageId: String, chunk: String) {
chunkBuffer.computeIfAbsent(messageId) { StringBuilder() }.append(chunk) _messages.value = _messages.value.map { msg ->
scheduleFlush(messageId) if (msg.id == messageId) {
msg.copy(content = msg.content + chunk)
} else msg
}
} }
private fun finalizeAiMessage(messageId: String) { private fun finalizeAiMessage(messageId: String) {
val finalResidue = chunkBuffer.remove(messageId)?.toString() ?: ""
_messages.value = _messages.value.map { msg -> _messages.value = _messages.value.map { msg ->
if (msg.id == messageId) { if (msg.id == messageId) {
val finalContent = (_streamingContent.value[messageId] ?: msg.content) + finalResidue msg.copy(isStreaming = false)
_streamingContent.update { it - messageId }
msg.copy(content = finalContent, isStreaming = false)
} else msg } else msg
} }
lastFlushAt.remove(messageId) saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
saveCacheToLocal()
} }
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) { private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {

View File

@ -162,18 +162,6 @@ fun ChatArea(
@Composable @Composable
fun MarkwonRenderer(markdown: String, modifier: Modifier) { 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( androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx -> factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply { val textView = SafeSelectableTextView(ctx).apply {
@ -185,14 +173,23 @@ fun MarkwonRenderer(markdown: String, modifier: Modifier) {
setPadding(0, 0, 0, 0) 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.tag = markwon
textView textView
}, },
update = { view -> update = { view ->
try { try {
val currentMarkwon = view.tag as? io.noties.markwon.Markwon val markwon = view.tag as? io.noties.markwon.Markwon
if (currentMarkwon != null) { if (markwon != null) {
currentMarkwon.setMarkdown(view, markdown) markwon.setMarkdown(view, markdown)
} else { } else {
view.text = markdown view.text = markdown
} }

View File

@ -31,19 +31,9 @@ fun ChatScaffold(
val sessions by viewModel.sessions.collectAsState() val sessions by viewModel.sessions.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState() val currentSessionId by viewModel.currentSessionId.collectAsState()
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val streamingContent by viewModel.streamingContent.collectAsState()
var showClearDialog by remember { mutableStateOf(false) } var showClearDialog by remember { mutableStateOf(false) }
val currentMessages = remember(messages, streamingContent, currentSessionId) { val currentMessages = messages.filter { it.sessionId == 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) { if (showClearDialog) {

View File

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

View File

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

View File

@ -2,7 +2,6 @@ package com.stand.standapp.utils
import android.content.Context import android.content.Context
import com.stand.standapp.AppConfig import com.stand.standapp.AppConfig
import com.stand.standapp.net.NetworkModule
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
@ -18,7 +17,7 @@ import kotlin.system.exitProcess
object LogManager { object LogManager {
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
private const val MAX_DAYS = 15 private const val MAX_DAYS = 15
private val client = NetworkModule.defaultClient private val client = OkHttpClient()
private var logDir: File? = null private var logDir: File? = null
fun init(context: Context) { fun init(context: Context) {
@ -99,6 +98,19 @@ object LogManager {
onCrashDetected(null, thread.name, throwable) onCrashDetected(null, thread.name, throwable)
defaultHandler?.uncaughtException(thread, throwable) ?: exitProcess(1) 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) { private fun cleanOldLogs(dir: File) {