11package com.netmonitor.app.util
22
33import android.content.Context
4+ import com.netmonitor.app.Constants
45import com.netmonitor.app.model.ConnectionInfo
56import com.netmonitor.app.model.ExposureRecord
7+ import kotlinx.coroutines.*
68import org.json.JSONArray
79import java.io.File
810import java.text.SimpleDateFormat
911import java.util.Date
1012import java.util.Locale
13+ import java.util.concurrent.ConcurrentHashMap
1114import java.util.concurrent.CopyOnWriteArrayList
1215
1316/* *
1417 * 暴露记录管理器
15- * - JSON 文件持久化存储到 App 内部存储
16- * - 会话级去重,同一连接不会重复记录
17- * - 最多保留 MAX_RECORDS 条记录
18+ *
19+ * 改进点:
20+ * - seenKeys 改用 ConcurrentHashMap.newKeySet(),解决多线程竞争
21+ * - save() 改为延迟批量写入(防抖),避免每条记录都触发全量磁盘 I/O
22+ * - 常量统一引用 Constants
1823 */
1924object ExposureLogManager {
2025
21- private const val FILE_NAME = " exposure_log.json"
22- private const val MAX_RECORDS = 500
26+ private const val TAG = " ExposureLog"
2327
2428 private val records = CopyOnWriteArrayList <ExposureRecord >()
25- private val seenKeys = mutableSetOf<String >()
29+
30+ /* * 线程安全的去重集合 —— 修复原来的 mutableSetOf 并发问题 */
31+ private val seenKeys: MutableSet <String > = ConcurrentHashMap .newKeySet()
32+
2633 private var initialized = false
2734
28- /* *
29- * 初始化:从文件加载历史记录
30- */
35+ // ── 延迟写入防抖 ──
36+ private var saveJob: Job ? = null
37+ private val saveScope = CoroutineScope (Dispatchers .IO + SupervisorJob ())
38+
39+ // ── 初始化 ──
40+
3141 @Synchronized
3242 fun init (context : Context ) {
3343 if (initialized) return
34- val file = File (context.filesDir, FILE_NAME )
44+ val file = File (context.filesDir, Constants . EXPOSURE_LOG_FILE )
3545 if (file.exists()) {
3646 try {
3747 val jsonArray = JSONArray (file.readText())
@@ -41,29 +51,29 @@ object ExposureLogManager {
4151 records.add(record)
4252 }
4353 }
44- AppLogger .i(" ExposureLog " , " \u52a0\u8f7d\u4e86 ${records.size} \u6761\u66b4\u9732\u8bb0\u5f55 " )
54+ AppLogger .i(TAG , " 加载了 ${records.size} 条暴露记录 " )
4555 } catch (e: Exception ) {
46- AppLogger .e(" ExposureLog " , " \u52a0\u8f7d\u66b4\u9732\u8bb0\u5f55\u5931\u8d25 : ${e.message} " )
56+ AppLogger .e(TAG , " 加载暴露记录失败 : ${e.message} " )
4757 }
4858 }
4959 initialized = true
5060 }
5161
52- /* *
53- * 重置会话去重(监控启动时调用)
54- */
62+ // ── 会话管理 ──
63+
5564 fun resetSession () {
5665 seenKeys.clear()
57- AppLogger .i(" ExposureLog " , " \u4f1a\u8bdd\u53bb\u91cd\u5df2\u91cd\u7f6e " )
66+ AppLogger .i(TAG , " 会话去重已重置 " )
5867 }
5968
69+ // ── 扫描与记录 ──
70+
6071 /* *
61- * 扫描连接列表,自动记录新的暴露
72+ * 扫描连接列表,自动记录新暴露
6273 * @return 本次新增的记录数
6374 */
6475 fun scanAndLog (context : Context , connections : List <ConnectionInfo >): Int {
6576 if (! initialized) init (context)
66-
6777 var newCount = 0
6878
6979 for (conn in connections) {
@@ -82,73 +92,96 @@ object ExposureLogManager {
8292 }
8393 }
8494 }
85-
8695 return newCount
8796 }
8897
8998 private fun addRecord (context : Context , record : ExposureRecord ) {
9099 records.add(0 , record)
91100
92101 // 超过上限,删除最旧的
93- while (records.size > MAX_RECORDS ) {
102+ while (records.size > Constants . MAX_EXPOSURE_RECORDS ) {
94103 records.removeAt(records.lastIndex)
95104 }
96105
97- save(context)
98- AppLogger .i(" ExposureLog" ,
99- " \u8bb0\u5f55\u66b4\u9732 : ${record.displayType} | ${record.appName} | ${record.localIp} :${record.localPort} " )
106+ // 延迟批量写入(防抖),避免每条记录都触发全量 I/O
107+ scheduleSave(context)
108+
109+ AppLogger .i(TAG , " 记录暴露: ${record.displayType} | ${record.appName} | " +
110+ " ${record.localIp} :${record.localPort} " )
100111 }
101112
102113 /* *
103- * 获取所有记录
114+ * 防抖写入:在最后一次 addRecord 后延迟 EXPOSURE_DEBOUNCE_SAVE_MS 再执行写入,
115+ * 连续添加多条记录只会触发一次磁盘写入
104116 */
117+ private fun scheduleSave (context : Context ) {
118+ saveJob?.cancel()
119+ saveJob = saveScope.launch {
120+ delay(Constants .EXPOSURE_DEBOUNCE_SAVE_MS )
121+ saveNow(context)
122+ }
123+ }
124+
125+ /* * 立即写入(用于 clear / App 退出等场景) */
126+ fun forceSave (context : Context ) {
127+ saveJob?.cancel()
128+ saveNow(context)
129+ }
130+
131+ private fun saveNow (context : Context ) {
132+ try {
133+ val jsonArray = JSONArray ()
134+ for (record in records) {
135+ jsonArray.put(record.toJson())
136+ }
137+ val file = File (context.filesDir, Constants .EXPOSURE_LOG_FILE )
138+ file.writeText(jsonArray.toString())
139+ } catch (e: Exception ) {
140+ AppLogger .e(TAG , " 保存暴露记录失败: ${e.message} " )
141+ }
142+ }
143+
144+ // ── 查询 ──
145+
105146 fun getRecords (): List <ExposureRecord > = records.toList()
106147
107- /* *
108- * 按类型筛选记录
109- */
110148 fun getRecordsByType (type : String ): List <ExposureRecord > {
111149 return records.filter { it.type == type }
112150 }
113151
114- /* *
115- * 获取记录数量
116- */
117152 fun getCount (): Int = records.size
118153
119- /* *
120- * 清空所有记录
121- */
154+ // ── 清空 ──
155+
122156 fun clear (context : Context ) {
123157 records.clear()
124158 seenKeys.clear()
125- save (context)
126- AppLogger .i(" ExposureLog " , " \u66b4\u9732\u8bb0\u5f55\u5df2\u6e05\u7a7a " )
159+ forceSave (context)
160+ AppLogger .i(TAG , " 暴露记录已清空 " )
127161 }
128162
129- /* *
130- * 导出为文本
131- */
163+ // ── 导出 ──
164+
132165 fun exportText (): String {
133- if (records.isEmpty()) return " \u65e0\u66b4\u9732\u8bb0\u5f55 "
166+ if (records.isEmpty()) return " 无暴露记录 "
134167
135168 val sb = StringBuilder ()
136- sb.appendLine(" ===== NetMonitor \u66b4\u9732\u8bb0\u5f55 =====" )
137- sb.appendLine(" \u5bfc\u51fa\u65f6\u95f4 : " + SimpleDateFormat (
169+ sb.appendLine(" ===== NetMonitor 暴露记录 =====" )
170+ sb.appendLine(" 导出时间 : " + SimpleDateFormat (
138171 " yyyy-MM-dd HH:mm:ss" , Locale .getDefault()
139172 ).format(Date ()))
140- sb.appendLine(" \u603b\u8bb0\u5f55\u6570 : ${records.size} " )
173+ sb.appendLine(" 总记录数 : ${records.size} " )
141174 sb.appendLine()
142175
143176 val ipLeaks = records.filter { it.type == " ip_leak" }
144177 val portExposed = records.filter { it.type == " port_exposed" }
145178
146- sb.appendLine(" --- \u771f\u5b9e IP \u66b4\u9732 : ${ipLeaks.size} \u6761 ---" )
179+ sb.appendLine(" --- 真实IP暴露 : ${ipLeaks.size} 条 ---" )
147180 for (r in ipLeaks) {
148181 sb.appendLine(r.format())
149182 }
150183
151- sb.appendLine(" --- \u7aef\u53e3\u66b4\u9732 : ${portExposed.size} \u6761 ---" )
184+ sb.appendLine(" --- 端口暴露 : ${portExposed.size} 条 ---" )
152185 for (r in portExposed) {
153186 sb.appendLine(r.format())
154187 }
@@ -157,19 +190,9 @@ object ExposureLogManager {
157190 return sb.toString()
158191 }
159192
160- /* *
161- * 保存到文件
162- */
163- private fun save (context : Context ) {
164- try {
165- val jsonArray = JSONArray ()
166- for (record in records) {
167- jsonArray.put(record.toJson())
168- }
169- val file = File (context.filesDir, FILE_NAME )
170- file.writeText(jsonArray.toString())
171- } catch (e: Exception ) {
172- AppLogger .e(" ExposureLog" , " \u4fdd\u5b58\u66b4\u9732\u8bb0\u5f55\u5931\u8d25 : ${e.message} " )
173- }
193+ /* * 释放资源(App 退出时调用) */
194+ fun destroy () {
195+ saveJob?.cancel()
196+ saveScope.cancel()
174197 }
175198}
0 commit comments