测试
This commit is contained in:
134
app/src/main/java/com/hikoncont/crash/CrashHandler.kt
Normal file
134
app/src/main/java/com/hikoncont/crash/CrashHandler.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.hikoncont.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 全局未捕获异常处理器
|
||||
* 捕获崩溃信息写入本地文件,重启后由 CrashLogUploader 上传服务器
|
||||
*/
|
||||
class CrashHandler private constructor() : Thread.UncaughtExceptionHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CrashHandler"
|
||||
private const val CRASH_DIR = "crash_logs"
|
||||
private const val MAX_LOG_FILES = 20 // 最多保留20个崩溃日志
|
||||
|
||||
@Volatile
|
||||
private var instance: CrashHandler? = null
|
||||
|
||||
fun getInstance(): CrashHandler {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: CrashHandler().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultHandler: Thread.UncaughtExceptionHandler? = null
|
||||
private lateinit var appContext: Context
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
Log.i(TAG, "✅ 崩溃处理器已初始化")
|
||||
}
|
||||
|
||||
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||
try {
|
||||
saveCrashLog(thread, throwable)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 保存崩溃日志失败", e)
|
||||
}
|
||||
// 交给系统默认处理器(终止进程)
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
|
||||
private fun saveCrashLog(thread: Thread, throwable: Throwable) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
|
||||
val fileName = "crash_${dateFormat.format(Date(timestamp))}_$timestamp.log"
|
||||
|
||||
val sb = StringBuilder()
|
||||
// 设备信息
|
||||
sb.appendLine("=== 崩溃报告 ===")
|
||||
sb.appendLine("时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date(timestamp))}")
|
||||
sb.appendLine("时间戳: $timestamp")
|
||||
sb.appendLine("线程: ${thread.name} (id=${thread.id})")
|
||||
sb.appendLine()
|
||||
sb.appendLine("=== 设备信息 ===")
|
||||
sb.appendLine("品牌: ${Build.BRAND}")
|
||||
sb.appendLine("型号: ${Build.MODEL}")
|
||||
sb.appendLine("制造商: ${Build.MANUFACTURER}")
|
||||
sb.appendLine("Android版本: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
sb.appendLine("指纹: ${Build.FINGERPRINT}")
|
||||
try {
|
||||
val pm = appContext.packageManager
|
||||
val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(appContext.packageName, android.content.pm.PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(appContext.packageName, 0)
|
||||
}
|
||||
sb.appendLine("应用版本: ${pi.versionName} (${pi.longVersionCode})")
|
||||
} catch (_: Exception) {
|
||||
sb.appendLine("应用版本: unknown")
|
||||
}
|
||||
sb.appendLine("包名: ${appContext.packageName}")
|
||||
sb.appendLine()
|
||||
|
||||
// 堆栈信息
|
||||
sb.appendLine("=== 异常堆栈 ===")
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
sb.append(sw.toString())
|
||||
|
||||
// 写入文件
|
||||
val crashDir = File(appContext.filesDir, CRASH_DIR)
|
||||
if (!crashDir.exists()) crashDir.mkdirs()
|
||||
|
||||
val crashFile = File(crashDir, fileName)
|
||||
crashFile.writeText(sb.toString())
|
||||
Log.e(TAG, "💾 崩溃日志已保存: ${crashFile.absolutePath}")
|
||||
|
||||
// 清理旧日志
|
||||
cleanOldLogs(crashDir)
|
||||
}
|
||||
|
||||
private fun cleanOldLogs(dir: File) {
|
||||
val files = dir.listFiles { f -> f.name.startsWith("crash_") && f.name.endsWith(".log") }
|
||||
?: return
|
||||
if (files.size > MAX_LOG_FILES) {
|
||||
files.sortBy { it.lastModified() }
|
||||
for (i in 0 until files.size - MAX_LOG_FILES) {
|
||||
files[i].delete()
|
||||
Log.d(TAG, "🗑️ 清理旧崩溃日志: ${files[i].name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取崩溃日志目录
|
||||
*/
|
||||
fun getCrashLogDir(): File {
|
||||
return File(appContext.filesDir, CRASH_DIR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有待上传的崩溃日志文件
|
||||
*/
|
||||
fun getPendingCrashLogs(): List<File> {
|
||||
val dir = getCrashLogDir()
|
||||
if (!dir.exists()) return emptyList()
|
||||
return dir.listFiles { f -> f.name.startsWith("crash_") && f.name.endsWith(".log") }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/hikoncont/crash/CrashLogUploader.kt
Normal file
102
app/src/main/java/com/hikoncont/crash/CrashLogUploader.kt
Normal file
@@ -0,0 +1,102 @@
|
||||
package com.hikoncont.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.socket.client.Socket
|
||||
import kotlinx.coroutines.*
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 崩溃日志上传器
|
||||
* 在应用重启后检查本地崩溃日志,通过 Socket.IO 上传到服务端
|
||||
*/
|
||||
class CrashLogUploader(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CrashLogUploader"
|
||||
private const val MAX_LOG_SIZE = 512 * 1024 // 单个日志最大512KB
|
||||
private const val UPLOAD_DELAY_MS = 5000L // 启动后延迟5秒再上传,等待Socket连接稳定
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/**
|
||||
* 在Socket连接成功后调用,上传待处理的崩溃日志
|
||||
*/
|
||||
fun uploadPendingLogs(socket: Socket?, deviceId: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
delay(UPLOAD_DELAY_MS)
|
||||
|
||||
val crashHandler = CrashHandler.getInstance()
|
||||
val pendingLogs = crashHandler.getPendingCrashLogs()
|
||||
|
||||
if (pendingLogs.isEmpty()) {
|
||||
Log.d(TAG, "📋 无待上传的崩溃日志")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.i(TAG, "📤 发现 ${pendingLogs.size} 个崩溃日志待上传")
|
||||
|
||||
for (logFile in pendingLogs) {
|
||||
if (socket?.connected() != true) {
|
||||
Log.w(TAG, "⚠️ Socket未连接,停止上传")
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
val content = logFile.readText()
|
||||
if (content.length > MAX_LOG_SIZE) {
|
||||
Log.w(TAG, "⚠️ 崩溃日志过大,截断: ${logFile.name} (${content.length}B)")
|
||||
}
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("deviceId", deviceId)
|
||||
put("fileName", logFile.name)
|
||||
put("content", content.take(MAX_LOG_SIZE))
|
||||
put("fileSize", logFile.length())
|
||||
put("crashTime", extractCrashTimestamp(logFile.name))
|
||||
put("uploadTime", System.currentTimeMillis())
|
||||
put("deviceModel", Build.MODEL)
|
||||
put("osVersion", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
}
|
||||
|
||||
socket.emit("crash_log", payload)
|
||||
Log.i(TAG, "✅ 已上传崩溃日志: ${logFile.name}")
|
||||
|
||||
// 上传成功后删除本地文件
|
||||
logFile.delete()
|
||||
Log.d(TAG, "🗑️ 已删除本地崩溃日志: ${logFile.name}")
|
||||
|
||||
// 间隔发送,避免拥塞
|
||||
delay(500)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 上传崩溃日志失败: ${logFile.name}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "📤 崩溃日志上传完成")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 崩溃日志上传流程异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名提取崩溃时间戳
|
||||
* 文件名格式: crash_2025-01-01_12-00-00_1234567890.log
|
||||
*/
|
||||
private fun extractCrashTimestamp(fileName: String): Long {
|
||||
return try {
|
||||
val parts = fileName.removeSuffix(".log").split("_")
|
||||
parts.lastOrNull()?.toLongOrNull() ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user