|
|
@@ -0,0 +1,295 @@
|
|
|
+package io.dcloud.uniplugin;
|
|
|
+
|
|
|
+import android.content.Context;
|
|
|
+import android.content.SharedPreferences;
|
|
|
+import android.text.TextUtils;
|
|
|
+import android.util.Log;
|
|
|
+
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+
|
|
|
+import java.io.File;
|
|
|
+import java.io.FileInputStream;
|
|
|
+import java.io.FileOutputStream;
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.Locale;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 原生日志落地与 JS 桥接帮助类。
|
|
|
+ * 目标:
|
|
|
+ * 1. 原生异常/关键日志本地保留 2~3 天;
|
|
|
+ * 2. JS 侧可主动拉取反馈所需的 native 日志摘要;
|
|
|
+ * 3. 当 JS 已就绪时,关键日志可通过 NativeLogBridge 实时透传给前端日志链路。
|
|
|
+ */
|
|
|
+public final class NativeLogBridgeHelper {
|
|
|
+
|
|
|
+ private static final String TAG = "NativeLogBridgeHelper";
|
|
|
+ private static final String PREF_NAME = "native_log_bridge";
|
|
|
+ private static final String KEY_LAST_CRASH_HINT = "last_crash_hint";
|
|
|
+ private static final String KEY_LAST_CRASH_AT = "last_crash_at";
|
|
|
+ private static final String KEY_LAST_CRASH_STACK = "last_crash_stack";
|
|
|
+ private static final long RETENTION_MS = 3L * 24 * 60 * 60 * 1000;
|
|
|
+ private static final int MAX_EXPORT_CHARS = 12000;
|
|
|
+ private static final int MAX_STACK_CHARS = 4000;
|
|
|
+ private static final String NATIVE_LOG_EVENT = "NativeLogBridge";
|
|
|
+
|
|
|
+ private static volatile Context appContext;
|
|
|
+ private static volatile Thread.UncaughtExceptionHandler previousHandler;
|
|
|
+ private static volatile boolean initialized = false;
|
|
|
+
|
|
|
+ private NativeLogBridgeHelper() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public static synchronized void init(Context context) {
|
|
|
+ if (context == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ appContext = context.getApplicationContext();
|
|
|
+ cleanupExpiredFiles();
|
|
|
+ if (initialized) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ previousHandler = Thread.getDefaultUncaughtExceptionHandler();
|
|
|
+ Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
|
|
+ try {
|
|
|
+ recordCrash("NATIVE_CRASH", throwable);
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e(TAG, "recordCrash failed", e);
|
|
|
+ }
|
|
|
+ if (previousHandler != null) {
|
|
|
+ previousHandler.uncaughtException(thread, throwable);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ initialized = true;
|
|
|
+ log("INFO", "NATIVE_BOOT", "native log bridge initialized", null, null, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void logInfo(String tag, String msg) {
|
|
|
+ log("INFO", tag, msg, null, null, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void logWarn(String tag, String msg) {
|
|
|
+ log("WARN", tag, msg, null, null, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void logError(String tag, String msg, Throwable throwable) {
|
|
|
+ log("ERROR", tag, msg, throwable, null, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void log(String level, String tag, String msg, Throwable throwable, JSONObject extra, boolean emitToJs) {
|
|
|
+ Context context = appContext;
|
|
|
+ if (context == null) {
|
|
|
+ Log.w(TAG, "log ignored because appContext is null");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ cleanupExpiredFiles();
|
|
|
+ JSONObject line = new JSONObject();
|
|
|
+ long ts = System.currentTimeMillis();
|
|
|
+ line.put("ts", ts);
|
|
|
+ line.put("level", safeLevel(level));
|
|
|
+ line.put("tag", safeTag(tag));
|
|
|
+ line.put("msg", safeText(msg, 800));
|
|
|
+ if (throwable != null) {
|
|
|
+ line.put("stack", safeText(Log.getStackTraceString(throwable), MAX_STACK_CHARS));
|
|
|
+ }
|
|
|
+ if (extra != null && !extra.isEmpty()) {
|
|
|
+ line.put("extra", extra);
|
|
|
+ }
|
|
|
+ appendLine(context, line);
|
|
|
+ if (emitToJs) {
|
|
|
+ HeartRateModule.emitNativeLog(line);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void recordCrash(String tag, Throwable throwable) {
|
|
|
+ Context context = appContext;
|
|
|
+ if (context == null || throwable == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ long ts = System.currentTimeMillis();
|
|
|
+ String stack = safeText(Log.getStackTraceString(throwable), MAX_STACK_CHARS);
|
|
|
+ String hint = safeText(throwable.getClass().getSimpleName() + ": " + throwable.getMessage(), 300);
|
|
|
+ SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
|
|
|
+ sp.edit()
|
|
|
+ .putString(KEY_LAST_CRASH_HINT, hint)
|
|
|
+ .putLong(KEY_LAST_CRASH_AT, ts)
|
|
|
+ .putString(KEY_LAST_CRASH_STACK, stack)
|
|
|
+ .apply();
|
|
|
+ log("ERROR", tag, hint, throwable, null, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static JSONObject getFeedbackBundle() {
|
|
|
+ Context context = appContext;
|
|
|
+ JSONObject res = new JSONObject();
|
|
|
+ if (context == null) {
|
|
|
+ res.put("clientLog", "");
|
|
|
+ res.put("clientCrashHint", "");
|
|
|
+ res.put("clientCrashAt", 0L);
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+ SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
|
|
|
+ res.put("clientLog", getRecentLogText(MAX_EXPORT_CHARS));
|
|
|
+ res.put("clientCrashHint", sp.getString(KEY_LAST_CRASH_HINT, ""));
|
|
|
+ res.put("clientCrashAt", sp.getLong(KEY_LAST_CRASH_AT, 0L));
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static JSONObject getCrashInfo() {
|
|
|
+ Context context = appContext;
|
|
|
+ JSONObject res = new JSONObject();
|
|
|
+ if (context == null) {
|
|
|
+ res.put("hint", "");
|
|
|
+ res.put("crashAt", 0L);
|
|
|
+ res.put("stack", "");
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+ SharedPreferences sp = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
|
|
|
+ res.put("hint", sp.getString(KEY_LAST_CRASH_HINT, ""));
|
|
|
+ res.put("crashAt", sp.getLong(KEY_LAST_CRASH_AT, 0L));
|
|
|
+ res.put("stack", sp.getString(KEY_LAST_CRASH_STACK, ""));
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static void clearCrashInfo() {
|
|
|
+ Context context = appContext;
|
|
|
+ if (context == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
|
|
+ .edit()
|
|
|
+ .remove(KEY_LAST_CRASH_HINT)
|
|
|
+ .remove(KEY_LAST_CRASH_AT)
|
|
|
+ .remove(KEY_LAST_CRASH_STACK)
|
|
|
+ .apply();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static String getRecentLogText(int maxChars) {
|
|
|
+ Context context = appContext;
|
|
|
+ if (context == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ File dir = getLogDir(context);
|
|
|
+ File[] files = dir.listFiles((file) -> file.isFile() && file.getName().startsWith("native-log-"));
|
|
|
+ if (files == null || files.length == 0) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ Arrays.sort(files, Comparator.comparing(File::getName));
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = files.length - 1; i >= 0; i--) {
|
|
|
+ try {
|
|
|
+ String text = readFileText(files[i]);
|
|
|
+ if (!TextUtils.isEmpty(text)) {
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ sb.insert(0, '\n');
|
|
|
+ }
|
|
|
+ sb.insert(0, text.trim());
|
|
|
+ }
|
|
|
+ if (sb.length() >= maxChars) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e(TAG, "read log file failed: " + files[i].getAbsolutePath(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (sb.length() > maxChars) {
|
|
|
+ return sb.substring(sb.length() - maxChars);
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void appendLine(Context context, JSONObject line) {
|
|
|
+ FileOutputStream fos = null;
|
|
|
+ try {
|
|
|
+ File file = new File(getLogDir(context), buildTodayFileName());
|
|
|
+ fos = new FileOutputStream(file, true);
|
|
|
+ fos.write((line.toJSONString() + "\n").getBytes(StandardCharsets.UTF_8));
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e(TAG, "appendLine failed", e);
|
|
|
+ } finally {
|
|
|
+ if (fos != null) {
|
|
|
+ try {
|
|
|
+ fos.close();
|
|
|
+ } catch (IOException ignore) {
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String readFileText(File file) {
|
|
|
+ FileInputStream fis = null;
|
|
|
+ try {
|
|
|
+ fis = new FileInputStream(file);
|
|
|
+ byte[] buf = new byte[(int) file.length()];
|
|
|
+ int len = fis.read(buf);
|
|
|
+ if (len <= 0) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return new String(buf, 0, len, StandardCharsets.UTF_8);
|
|
|
+ } catch (Exception e) {
|
|
|
+ Log.e(TAG, "readFileText failed: " + file.getAbsolutePath(), e);
|
|
|
+ return "";
|
|
|
+ } finally {
|
|
|
+ if (fis != null) {
|
|
|
+ try {
|
|
|
+ fis.close();
|
|
|
+ } catch (IOException ignore) {
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static File getLogDir(Context context) {
|
|
|
+ File dir = new File(context.getFilesDir(), "native-client-logs");
|
|
|
+ if (!dir.exists() && !dir.mkdirs()) {
|
|
|
+ Log.w(TAG, "mkdir failed: " + dir.getAbsolutePath());
|
|
|
+ }
|
|
|
+ return dir;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String buildTodayFileName() {
|
|
|
+ return "native-log-" + new SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(new Date()) + ".jsonl";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void cleanupExpiredFiles() {
|
|
|
+ Context context = appContext;
|
|
|
+ if (context == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ File[] files = getLogDir(context).listFiles((file) -> file.isFile() && file.getName().startsWith("native-log-"));
|
|
|
+ if (files == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ long cutoff = System.currentTimeMillis() - RETENTION_MS;
|
|
|
+ for (File file : files) {
|
|
|
+ if (file.lastModified() < cutoff && !file.delete()) {
|
|
|
+ Log.w(TAG, "delete expired log failed: " + file.getAbsolutePath());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String safeLevel(String level) {
|
|
|
+ if ("ERROR".equalsIgnoreCase(level) || "WARN".equalsIgnoreCase(level) || "INFO".equalsIgnoreCase(level)) {
|
|
|
+ return level.toUpperCase(Locale.ROOT);
|
|
|
+ }
|
|
|
+ return "INFO";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String safeTag(String tag) {
|
|
|
+ return safeText(TextUtils.isEmpty(tag) ? "NATIVE" : tag, 80);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String safeText(String text, int maxLen) {
|
|
|
+ if (text == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String normalized = text.replace('\r', ' ').replace('\n', ' ').trim();
|
|
|
+ if (normalized.length() <= maxLen) {
|
|
|
+ return normalized;
|
|
|
+ }
|
|
|
+ return normalized.substring(0, maxLen);
|
|
|
+ }
|
|
|
+}
|