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: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

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

View File

@ -524,80 +524,13 @@
var infoStr = window.android.getAboutInfo(); var infoStr = window.android.getAboutInfo();
try { try {
var info = JSON.parse(infoStr); var info = JSON.parse(infoStr);
var html = '<strong>应用名称:</strong> ' + info.appName + '<br>' +
var friendlyNames = { '<strong>版本号:</strong> <span style="color: #3b82f6;">' + info.versionName + '</span><br>' +
appName: '应用名称', '<strong>设备型号:</strong> ' + info.model + '<br>' +
versionName: '版本号', '<strong>系统版本:</strong> Android ' + info.osVersion + '<br>' +
model: '设备型号', '<strong>CPU 架构:</strong> ' + info.cpuAbi + '<br>' +
osVersion: '系统版本', '<strong>运行模式:</strong> ' + (info.is64Bit ? "64位" : "32位兼容模式");
cpuAbi: 'CPU 架构', document.getElementById('aboutBody').innerHTML = html;
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);
document.getElementById('aboutModal').style.display = 'flex'; document.getElementById('aboutModal').style.display = 'flex';
} catch (e) { } catch (e) {
document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr; document.getElementById('aboutBody').innerText = "获取信息解析失败: " + infoStr;

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

@ -378,9 +378,22 @@ class AndroidInterface(private val activity: MainActivity) {
fun getAboutInfo(): String { fun getAboutInfo(): String {
return try { return try {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0) 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("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() json.toString()
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("AndroidInterface").e(e, "getAboutInfo failed") Timber.tag("AndroidInterface").e(e, "getAboutInfo failed")

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,12 +31,10 @@ 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)
synchronized(jsLock) { synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔) // 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
} }
@ -121,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
@ -474,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({
}
private fun navigateToLogin() {
if (isFinishing || isDestroyed) return
startActivity(Intent(this, LoginActivity::class.java)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
} }, 2000)
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 = 150L
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卡顿
@ -139,8 +99,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
private fun saveCacheToLocal() { private fun saveCacheToLocal() {
// 将数据保存放到IO线程中防止SharedPreferences写大文本阻塞主线程卡顿
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try { try {
// 保存会话列表 // 保存会话列表
val sessionsArray = JSONArray() val sessionsArray = JSONArray()
@ -171,25 +129,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
e.printStackTrace() e.printStackTrace()
} }
} }
}
// 👈 用户手动中断当前正在生成的 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() {
@ -278,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

@ -94,19 +94,25 @@ fun FloatingChatWidget() {
visible = true 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( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)), enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)), exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)),
modifier = Modifier modifier = Modifier
.offset { .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( IntOffset(
(clampedX + cardDragX).roundToInt() (clampedX + cardDragX).roundToInt()
.coerceIn(0, (screenWidthPx - cardWidthPx).toInt()), .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 } } val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } }
if (isAnyStreaming) { if (isAnyStreaming) {
val totalLength = messages.map { it.content.length }.sum() val totalLength = messages.map { it.content.length }.sum()
@ -123,7 +111,6 @@ fun ChatArea(
val contentWithCursor = msg.content + if (msg.isStreaming) "" else "" val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
MarkwonRenderer( MarkwonRenderer(
markdown = contentWithCursor, markdown = contentWithCursor,
markwon = markwon,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
) )
} }
@ -174,7 +161,7 @@ fun ChatArea(
} }
@Composable @Composable
fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) { fun MarkwonRenderer(markdown: String, modifier: Modifier) {
androidx.compose.ui.viewinterop.AndroidView( androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx -> factory = { ctx ->
val textView = SafeSelectableTextView(ctx).apply { val textView = SafeSelectableTextView(ctx).apply {
@ -186,14 +173,23 @@ fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifi
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

@ -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") 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) {
@ -187,9 +199,6 @@ object LogManager {
private val fileNameFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) private val fileNameFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// 过滤掉高频的 VERBOSE 级别日志写入(例如录音帧),保留在控制台输出
if (priority == android.util.Log.VERBOSE) return
executor.execute { executor.execute {
try { try {
val logFile = File(logDir, "${fileNameFormat.format(Date())}.log") val logFile = File(logDir, "${fileNameFormat.format(Date())}.log")