From 9443522cedc58a1168ab16d4dc25c578e5bf1237 Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 10:04:14 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E6=96=87=E4=BB=B6=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=20GZIP=20=E5=8E=8B=E7=BC=A9=E4=B8=8E=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=90=8D=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **新增** `RecordFileIO` 与 `RecordFileNames` 统一处理 `.txt` 和 `.txt.gz` 文件的读写与命名逻辑。 - **Server 端**:引入后台压缩机制,自动将已完成的历史分段压缩为 `.txt.gz` 以节省空间。 - **App 端**: - 支持透明读取压缩后的记录文件。 - 统一使用“逻辑记录名”(不带 `.gz` 后缀)进行缓存、导出与删除操作。 - 在数据同步(Sync)过程中增加缓存预热逻辑。 - **数据同步**:优化 `PfdFileSender` 支持批量发送指定文件列表,并在同步后正确清理 Server 侧的物理文件及其压缩副本。 - **缓存**:提升 `HISTORY_STATS_CACHE_VERSION` 版本至 14。 --- AGENTS.md | 8 +- .../data/history/DischargeRecordScanner.kt | 8 ++ .../data/history/HistoryCacheNaming.kt | 4 +- .../data/history/HistoryCacheVersions.kt | 2 +- .../data/history/HistoryRepository.kt | 121 +++++++++------- .../batteryrecorder/data/history/SyncUtil.kt | 16 ++- .../batteryrecorder/server/Server.kt | 40 ++++-- .../server/writer/PowerRecordWriter.kt | 107 +++++++++++++- .../shared/data/RecordFileIO.kt | 57 ++++++++ .../shared/data/RecordFileNames.kt | 131 ++++++++++++++++++ .../shared/data/RecordFileParser.kt | 2 +- .../shared/data/RecordsFile.kt | 6 +- .../shared/sync/PfdFileSender.kt | 105 +++++++++----- 13 files changed, 497 insertions(+), 110 deletions(-) create mode 100644 shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt create mode 100644 shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileNames.kt diff --git a/AGENTS.md b/AGENTS.md index 0ea001ea..bc950ade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,16 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV `timestamp,power,packageName,capacity,isDisplayOn,temp,voltage,current` - 其中 `voltage` 列落盘单位为 4 位 `mV`;运行时 `LineRecord.voltage` 仍统一使用 `uV` - `LineRecord.status` 当前只用于运行时采样链路,不参与记录文件落盘;记录文件语义由目录类型(charge/discharge)与 8 列 CSV 本身共同决定 +- 当前活跃分段保持明文 `时间戳.txt`;已关闭历史分段会在后台压缩为 `时间戳.txt.gz` +- 记录的逻辑名称仍固定为 `时间戳.txt`;历史列表、导航、删除、缓存与导出都按逻辑名称工作,物理层再解析到 `.txt` / `.txt.gz` ### 数据同步链路 - Server 以 shell 权限运行时,记录文件落在 `com.android.shell` 数据目录 - App 通过 `sync()` AIDL 拿到 `ParcelFileDescriptor` - 传输协议由 `PfdFileSender` / `PfdFileReceiver` / `SyncConstants` 实现 -- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理 +- `sync()` 当前只同步稳定历史记录文件,不会发送活跃分段或压缩中的临时文件 +- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理;App 侧接收完成后会预热本地 `power_stats` 缓存 ### 首页统计与预测链路 @@ -241,6 +244,7 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV - `AppStatsComputer`、`SceneStatsComputer` 与记录详情 `power_stats` 共用 `HistoryCacheVersions.HISTORY_STATS_CACHE_VERSION` - 缓存命名统一通过 `HistoryCacheNaming.kt` +- `power_stats` 当前按逻辑记录名 `时间戳.txt` 命名,不直接跟随物理 `.txt.gz` 文件名;命中仍继续校验源文件 `lastModified()` - 具体缓存改动流程与检查清单已沉淀到项目 skill:`.agents/skills/history-stats-cache-change/` ## 目录索引 @@ -388,6 +392,8 @@ shared/src/main/ ├── data/ │ ├── BatteryStatus.kt │ ├── LineRecord.kt + │ ├── RecordFileIO.kt + │ ├── RecordFileNames.kt │ ├── RecordFileParser.kt │ ├── RecordsFile.kt │ └── RecordsStats.kt diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt index 2d09cbef..82e06495 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt @@ -5,6 +5,7 @@ import yangfentuozi.batteryrecorder.BuildConfig import yangfentuozi.batteryrecorder.shared.config.SettingsConstants import yangfentuozi.batteryrecorder.shared.config.dataclass.StatisticsSettings import yangfentuozi.batteryrecorder.shared.data.BatteryStatus +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.LineRecord import yangfentuozi.batteryrecorder.shared.data.RecordFileParser import yangfentuozi.batteryrecorder.shared.util.LoggerX @@ -344,6 +345,13 @@ object DischargeRecordScanner { * 从文件尾部反向读取最后一条有效记录时间戳,避免整文件扫描两遍。 */ private fun findFileEndTimestamp(file: File): Long? { + if (RecordFileNames.isCompressedFileName(file.name)) { + var lastTimestamp: Long? = null + RecordFileParser.forEachValidRecord(file) { record -> + lastTimestamp = record.timestamp + } + return lastTimestamp + } val length = file.length() if (length <= 0L) return null diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheNaming.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheNaming.kt index 8ad7382e..ec9381ac 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheNaming.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheNaming.kt @@ -10,5 +10,5 @@ internal fun getAppStatsCacheFile(cacheRoot: File, key: String): File = internal fun getSceneStatsCacheFile(cacheRoot: File, key: String): File = File(File(cacheRoot, "scene_stats/$CACHE_VERSION_DIR"), "$key.cache") -internal fun getPowerStatsCacheFile(cacheRoot: File, sourceFileName: String): File = - File(File(cacheRoot, "power_stats/$CACHE_VERSION_DIR"), "$sourceFileName.cache") +internal fun getPowerStatsCacheFile(cacheRoot: File, logicalRecordName: String): File = + File(File(cacheRoot, "power_stats/$CACHE_VERSION_DIR"), "$logicalRecordName.cache") diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheVersions.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheVersions.kt index 410ccd48..1f5068f2 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheVersions.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheVersions.kt @@ -5,4 +5,4 @@ package yangfentuozi.batteryrecorder.data.history * * 只要 AppStats / SceneStats / powerStats 的缓存格式或 cache key 组成发生变化,就统一提升这个版本。 */ -internal const val HISTORY_STATS_CACHE_VERSION = 13 +internal const val HISTORY_STATS_CACHE_VERSION = 14 diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt index 30fcfe4b..129e4cfd 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt @@ -7,6 +7,8 @@ import yangfentuozi.batteryrecorder.ipc.Service import yangfentuozi.batteryrecorder.shared.Constants import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.LineRecord +import yangfentuozi.batteryrecorder.shared.data.RecordFileIO +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordFileParser import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.data.RecordsStats @@ -84,26 +86,24 @@ object HistoryRepository { private const val NOT_ENOUGH_VALID_SAMPLES_PREFIX = "Not enough valid samples after filtering:" private val CLEANUP_TARGET_TYPES = listOf(BatteryStatus.Charging, BatteryStatus.Discharging) - // 记录文件名由“起始时间戳.txt”组成;无法解析的文件必须显式告警并从记录链路中过滤。 + // 逻辑记录名固定为“起始时间戳.txt”,物理文件允许是 `.txt` 或 `.txt.gz`。 private fun recordFileTimestampOrNull(file: File): Long? = - file.name.substringBeforeLast('.').toLongOrNull() + RecordFileNames.timestampOrNull(file.name) private fun listSortedRecordFiles(dir: File): List = - dir.listFiles() - ?.asSequence() - ?.filter { it.isFile } - ?.mapNotNull { file -> - val timestamp = recordFileTimestampOrNull(file) - if (timestamp == null) { - LoggerX.w(TAG, "[记录] 跳过非法记录文件: ${file.absolutePath}") - return@mapNotNull null + RecordFileNames.listStableFiles(dir).also { + dir.listFiles() + ?.asSequence() + ?.filter { it.isFile } + ?.forEach { file -> + if ( + RecordFileNames.parse(file.name) == null && + !RecordFileNames.isTempFileName(file.name) + ) { + LoggerX.w(TAG, "[记录] 跳过非法记录文件: ${file.absolutePath}") + } } - file to timestamp - } - ?.sortedByDescending { it.second } - ?.map { it.first } - ?.toList() - ?: emptyList() + } fun RecordsFile.toFile(context: Context): File? { val dataDir = dataDir(context, type) @@ -119,11 +119,15 @@ object HistoryRepository { // 验证文件有效性,返回 null 表示无效 private fun validFile(dir: File, name: String): File? = - File(dir, name).takeIf { it.isFile } + RecordFileNames.resolvePhysicalFile(dir, name) + + private fun logicalRecordName(file: File): String = + RecordFileNames.logicalNameOrNull(file.name) + ?: throw IllegalArgumentException("Invalid record file name: ${file.name}") private fun buildHistoryRecord(file: File, stats: RecordsStats): HistoryRecord { return HistoryRecord( - name = file.name, + name = logicalRecordName(file), type = BatteryStatus.fromDataDirName(file.parentFile?.name), stats = stats, lastModified = file.lastModified() @@ -141,9 +145,9 @@ object HistoryRepository { file: File, needCaching: Boolean ): HistoryRecord? { - val cacheFile = getPowerStatsCacheFile(context.cacheDir, file.name) + val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalRecordName(file)) LoggerX.v(TAG, - "[历史] 加载记录统计: file=${file.name} needCaching=$needCaching cache=${cacheFile.name}" + "[历史] 加载记录统计: file=${logicalRecordName(file)} source=${file.name} needCaching=$needCaching cache=${cacheFile.name}" ) val stats = runCatching { RecordsStats.getCachedStats( @@ -182,7 +186,7 @@ object HistoryRepository { fun loadRecord(context: Context, file: File): HistoryRecord { val dataDir = file.parentFile!! val latestFile = listSortedRecordFiles(dataDir).firstOrNull() - val cacheFile = getPowerStatsCacheFile(context.cacheDir, file.name) + val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalRecordName(file)) val stats = RecordsStats.getCachedStats( cacheFile = cacheFile, sourceFile = file, @@ -237,7 +241,7 @@ object HistoryRepository { ?: return CurrentRecordLoadResult.Missing(recordsFile) val latestFile = listSortedRecordFiles(sourceFile.parentFile ?: return CurrentRecordLoadResult.Missing(recordsFile)) .firstOrNull() - val cacheFile = getPowerStatsCacheFile(context.cacheDir, sourceFile.name) + val cacheFile = getPowerStatsCacheFile(context.cacheDir, recordsFile.name) val stats = runCatching { RecordsStats.getCachedStats( cacheFile = cacheFile, @@ -321,15 +325,10 @@ object HistoryRepository { /** 删除记录及其缓存文件 */ fun deleteRecord(context: Context, recordsFile: RecordsFile): Boolean { - - if (runCatching { recordsFile.toFile(context)!!.delete() }.getOrDefault(false)) { - // 同步删除缓存文件 - runCatching { getPowerStatsCacheFile(context.cacheDir, recordsFile.name).delete() } - LoggerX.i(TAG, "[历史] 删除记录成功: ${recordsFile.name}") - return true + val sourceFile = recordsFile.toFile(context) ?: return false.also { + LoggerX.w(TAG, "[历史] 删除记录失败,文件不存在: ${recordsFile.name}") } - LoggerX.w(TAG, "[历史] 删除记录失败: ${recordsFile.name}") - return false + return deleteRecordFile(context, sourceFile, recordsFile.type) } /** @@ -469,10 +468,8 @@ object HistoryRepository { val outputStream = context.contentResolver.openOutputStream(destinationUri, "w") ?: throw IOException("Failed to open destination: $destinationUri") - sourceFile.inputStream().use { input -> - outputStream.use { output -> - input.copyTo(output) - } + outputStream.use { output -> + RecordFileIO.copyAsPlainText(sourceFile, output) } LoggerX.i(TAG, "[历史] 导出记录成功: source=${recordsFile.name} destination=$destinationUri") } @@ -499,16 +496,14 @@ object HistoryRepository { ?: throw FileNotFoundException("Record file not found: ${recordsFile.name}") LoggerX.d( TAG, - "[历史] 写入导出 ZIP 条目: file=${sourceFile.name} size=${sourceFile.length()} destination=$destinationUri" + "[历史] 写入导出 ZIP 条目: file=${recordsFile.name} source=${sourceFile.name} size=${sourceFile.length()} destination=$destinationUri" ) - zipOutput.putNextEntry(ZipEntry(sourceFile.name)) - sourceFile.inputStream().use { input -> - input.copyTo(zipOutput) - } + zipOutput.putNextEntry(ZipEntry(recordsFile.name)) + RecordFileIO.copyAsPlainText(sourceFile, zipOutput) zipOutput.closeEntry() LoggerX.d( TAG, - "[历史] 导出 ZIP 条目写入完成: file=${sourceFile.name} destination=$destinationUri" + "[历史] 导出 ZIP 条目写入完成: file=${recordsFile.name} destination=$destinationUri" ) } } @@ -564,7 +559,9 @@ object HistoryRepository { if (entryName.contains('/') || entryName.contains('\\')) { throw IOException("ZIP 条目路径非法,不是一键导出格式: ${entry.name}") } - if (recordFileTimestampOrNull(File(entryName)) == null) { + if (!entryName.endsWith(RecordFileNames.PLAIN_SUFFIX) || + recordFileTimestampOrNull(File(entryName)) == null + ) { throw IOException("ZIP 条目文件名非法: ${entry.name}") } if (!seenNames.add(entryName)) { @@ -591,6 +588,7 @@ object HistoryRepository { throw IOException("ZIP 中没有可导入的记录文件") } stagedEntries.forEach { stagedFile -> + deleteRecordVariants(context.cacheDir, targetDir, stagedFile.name) val destinationFile = File(targetDir, stagedFile.name) stagedFile.copyTo(destinationFile, overwrite = true) getPowerStatsCacheFile(context.cacheDir, stagedFile.name).delete() @@ -625,11 +623,7 @@ object HistoryRepository { * @return 返回目录内全部文件,不做文件名与内容合法性过滤。 */ private fun listAllRecordFiles(context: Context, type: BatteryStatus): List = - dataDir(context, type) - .listFiles() - ?.filter { it.isFile } - ?.toList() - ?: emptyList() + listSortedRecordFiles(dataDir(context, type)) /** * 解析单条记录文件,供条件清理判断使用。 @@ -646,7 +640,7 @@ object HistoryRepository { LoggerX.w(TAG, "[记录清理] 文件名非法,按异常记录处理: ${file.absolutePath}") return RecordCleanupInspection.InvalidFileName } - val cacheFile = getPowerStatsCacheFile(context.cacheDir, file.name) + val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalRecordName(file)) return runCatching { RecordsStats.getCachedStats( cacheFile = cacheFile, @@ -688,12 +682,37 @@ object HistoryRepository { file: File, type: BatteryStatus ): Boolean { - if (runCatching { file.delete() }.getOrDefault(false)) { - runCatching { getPowerStatsCacheFile(context.cacheDir, file.name).delete() } - LoggerX.i(TAG, "[记录清理] 删除记录成功: type=${type.dataDirName} file=${file.name}") + val logicalName = logicalRecordName(file) + val parentDir = file.parentFile ?: return false + if (runCatching { + deleteRecordVariants(context.cacheDir, parentDir, logicalName) + }.getOrDefault(false) + ) { + LoggerX.i(TAG, "[记录清理] 删除记录成功: type=${type.dataDirName} file=$logicalName") return true } - LoggerX.w(TAG, "[记录清理] 删除记录失败: type=${type.dataDirName} file=${file.name}") + LoggerX.w(TAG, "[记录清理] 删除记录失败: type=${type.dataDirName} file=$logicalName") return false } + + private fun deleteRecordVariants( + cacheRoot: File, + dataDir: File, + logicalName: String + ): Boolean { + val plainFile = File(dataDir, logicalName) + val gzipFile = File(dataDir, "$logicalName.gz") + val removedData = runCatching { + var removed = false + if (plainFile.exists()) { + removed = plainFile.delete() || removed + } + if (gzipFile.exists()) { + removed = gzipFile.delete() || removed + } + removed + }.getOrDefault(false) + runCatching { getPowerStatsCacheFile(cacheRoot, logicalName).delete() } + return removedData + } } diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt index cffaf57c..750641d8 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt @@ -3,6 +3,8 @@ package yangfentuozi.batteryrecorder.data.history import android.content.Context import yangfentuozi.batteryrecorder.ipc.Service import yangfentuozi.batteryrecorder.shared.Constants +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames +import yangfentuozi.batteryrecorder.shared.data.RecordsStats import yangfentuozi.batteryrecorder.shared.sync.PfdFileReceiver import yangfentuozi.batteryrecorder.shared.util.LoggerX import java.io.File @@ -23,7 +25,19 @@ object SyncUtil { val outDir = File(context.dataDir, Constants.APP_POWER_DATA_PATH) try { - PfdFileReceiver.receiveToDir(readPfd, outDir) + PfdFileReceiver.receiveToDir(readPfd, outDir) { savedFile, _ -> + val logicalName = RecordFileNames.logicalNameOrNull(savedFile.name) ?: return@receiveToDir + val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalName) + runCatching { + RecordsStats.getCachedStats( + cacheFile = cacheFile, + sourceFile = savedFile, + needCaching = true + ) + }.onFailure { error -> + LoggerX.w(TAG, "[SYNC] 预热记录统计缓存失败: file=${savedFile.absolutePath}", tr = error) + } + } LoggerX.i(TAG, "[SYNC] 客户端接收完成: ${outDir.absolutePath}") } catch (e: Exception) { LoggerX.e(TAG, "[SYNC] 客户端接收失败", tr = e) diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index 2d540d0b..1bf4d95b 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -27,6 +27,7 @@ import yangfentuozi.batteryrecorder.shared.config.SettingsConstants import yangfentuozi.batteryrecorder.shared.config.dataclass.ServerSettings import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Charging import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Discharging +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.sync.PfdFileSender import yangfentuozi.batteryrecorder.shared.util.Handlers @@ -37,7 +38,6 @@ import yangfentuozi.hiddenapi.compat.ServiceManagerCompat import java.io.File import java.io.FileDescriptor import java.io.IOException -import java.nio.file.Files import java.util.Scanner import kotlin.system.exitProcess @@ -183,6 +183,7 @@ class Server internal constructor() : IService.Stub() { override fun sync(): ParcelFileDescriptor? { writer.flushBufferBlocking() + writer.awaitCompressionBlocking() if (Os.getuid() == 0) { LoggerX.d(tag, "sync: root 模式不需要同步文件, return null") return null @@ -203,25 +204,38 @@ class Server internal constructor() : IService.Stub() { val currDischargeDataPath = if (writer.dischargeDataWriter.needStartNewSegment(writer.dischargeDataWriter.hasPendingStatusChange)) null else writer.dischargeDataWriter.segmentFile?.toPath() + val protectedPaths = setOfNotNull( + currChargeDataPath?.toAbsolutePath()?.toString(), + currDischargeDataPath?.toAbsolutePath()?.toString() + ) + val filesToSync = writer.listStableHistoryFiles().filter { file -> + file.toPath().toAbsolutePath().toString() !in protectedPaths + } var sentCount = 0 - PfdFileSender.sendFile( + PfdFileSender.sendFiles( writeEnd, - shellPowerDataDir + shellPowerDataDir, + filesToSync ) { file -> sentCount += 1 LoggerX.d(tag, "@sendFileCallback: 文件已发送, file=${file.name}") - if ((currChargeDataPath == null || !Files.isSameFile( - file.toPath(), - currChargeDataPath - )) && - (currDischargeDataPath == null || !Files.isSameFile( - file.toPath(), - currDischargeDataPath - )) - ) file.delete() + val logicalName = RecordFileNames.logicalNameOrNull(file.name) + val candidates = buildList { + add(file) + if (logicalName != null) { + add(File(file.parentFile, logicalName)) + add(File(file.parentFile, "$logicalName.gz")) + } + }.distinctBy { it.absolutePath } + candidates.forEach { candidate -> + val candidatePath = candidate.toPath().toAbsolutePath().toString() + if (candidatePath !in protectedPaths) { + candidate.delete() + } + } } - LoggerX.i(tag, "sync: 同步完成, sentCount=$sentCount") + LoggerX.i(tag, "sync: 同步完成, selected=${filesToSync.size} sentCount=$sentCount") } catch (e: Exception) { LoggerX.e(tag, "sync: 后台同步失败", tr = e) try { diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt index 1b9a2ee4..071bc883 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt @@ -5,14 +5,18 @@ import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Charging import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Discharging import yangfentuozi.batteryrecorder.shared.data.LineRecord +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.util.Handlers import yangfentuozi.batteryrecorder.shared.util.LoggerX import yangfentuozi.batteryrecorder.shared.writer.AdvancedWriter import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream +import java.util.concurrent.CountDownLatch +import java.util.zip.GZIPOutputStream class PowerRecordWriter( powerDir: File, @@ -20,6 +24,7 @@ class PowerRecordWriter( private val fixFileOwner: ((File) -> Unit) ) { private val tag = "PowerRecordWriter" + private val compressionHandler: Handler = Handlers.getHandler("RecordCompressionThread") /** * 单条采样写入结果。 @@ -73,6 +78,7 @@ class PowerRecordWriter( makeSureExists(powerDir) makeSureExists(chargeDir) makeSureExists(dischargeDir) + scheduleHistoricalCompressionBootstrap() } fun write(record: LineRecord): WriteResult { @@ -107,6 +113,7 @@ class PowerRecordWriter( fun close() { chargeDataWriter.closeCurrentSegment() dischargeDataWriter.closeCurrentSegment() + awaitCompressionBlocking() } fun flushBuffer() { @@ -119,6 +126,97 @@ class PowerRecordWriter( dischargeDataWriter.flushBufferBlocking() } + fun awaitCompressionBlocking() { + val latch = CountDownLatch(1) + compressionHandler.post { latch.countDown() } + latch.await() + } + + fun listStableHistoryFiles(): List = + RecordFileNames.listStableFiles(chargeDir) + RecordFileNames.listStableFiles(dischargeDir) + + private fun scheduleCompression(file: File) { + compressionHandler.post { + compressHistoricalSegment(file) + } + } + + /** + * 启动后回填压缩旧的历史明文分段。 + * + * 当前活跃分段保持明文,不参与这轮回填;其余已遗留的 `.txt` 会在后台排队压缩。 + * + * @return 无。 + */ + private fun scheduleHistoricalCompressionBootstrap() { + val activePaths = setOfNotNull( + chargeDataWriter.segmentFile?.absolutePath, + dischargeDataWriter.segmentFile?.absolutePath + ) + listOf(chargeDir, dischargeDir).forEach { dir -> + dir.listFiles() + ?.asSequence() + ?.filter { file -> + file.isFile && + !RecordFileNames.isCompressedFileName(file.name) && + !RecordFileNames.isTempFileName(file.name) && + RecordFileNames.parse(file.name) != null && + file.absolutePath !in activePaths + } + ?.sortedBy { it.name } + ?.forEach { file -> + scheduleCompression(file) + } + } + } + + /** + * 将已关闭分段压缩为 gzip。 + * + * 保持当前活跃文件明文;历史分段压缩时通过 `.tmp` 临时文件收尾, + * 目录枚举侧若短暂同时看到 `.txt` 与 `.txt.gz`,会优先选择 gzip,避免重复记录。 + * + * @param sourceFile 已关闭的历史明文分段。 + * @return 无。 + */ + private fun compressHistoricalSegment(sourceFile: File) { + if (!sourceFile.exists()) return + val descriptor = RecordFileNames.parse(sourceFile.name) ?: return + if (descriptor.isCompressed) return + + val gzipFile = File(sourceFile.parentFile, "${descriptor.logicalName}.gz") + val tempFile = File(sourceFile.parentFile, "${descriptor.logicalName}.gz${RecordFileNames.TEMP_SUFFIX}") + if (gzipFile.exists()) { + if (!sourceFile.delete()) { + LoggerX.w(tag, "compressHistoricalSegment: 旧明文分段删除失败, file=${sourceFile.absolutePath}") + } + return + } + + try { + if (tempFile.exists() && !tempFile.delete()) { + throw IOException("Failed to delete temp file: ${tempFile.absolutePath}") + } + FileInputStream(sourceFile).use { input -> + GZIPOutputStream(FileOutputStream(tempFile)).use { output -> + input.copyTo(output, 64 * 1024) + } + } + fixFileOwner(tempFile) + if (!tempFile.renameTo(gzipFile)) { + throw IOException("Failed to rename temp file: ${tempFile.absolutePath}") + } + fixFileOwner(gzipFile) + if (!sourceFile.delete()) { + LoggerX.w(tag, "compressHistoricalSegment: 明文分段删除失败, file=${sourceFile.absolutePath}") + } + LoggerX.d(tag, "compressHistoricalSegment: 历史分段压缩完成, source=${sourceFile.name} target=${gzipFile.name}") + } catch (e: Exception) { + tempFile.delete() + LoggerX.e(tag, "compressHistoricalSegment: 历史分段压缩失败, file=${sourceFile.absolutePath}", tr = e) + } + } + inner class ChargeDataWriter(dir: File, statusData: ChildWriterStatusData?) : BaseDelayedRecordWriter(dir, statusData) { override fun needStartNewSegment(justChangedStatus: Boolean, nowTime: Long): Boolean { // case1 记录超过最大分段时间(0 表示不按时间分段) @@ -313,15 +411,18 @@ class PowerRecordWriter( fun closeCurrentSegment() { flushBuffer() if (writer != null) { + val closedFile = segmentFile try { writer!!.close() } catch (e: IOException) { LoggerX.e(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 关闭分段文件失败", tr = e) } writer = null - if (needDeleteSegment(System.currentTimeMillis())) { - LoggerX.v(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 删除短分段, file=${segmentFile?.name}") - segmentFile!!.delete() + if (closedFile != null && needDeleteSegment(System.currentTimeMillis())) { + LoggerX.v(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 删除短分段, file=${closedFile.name}") + closedFile.delete() + } else if (closedFile != null) { + scheduleCompression(closedFile) } segmentFile = null } diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt new file mode 100644 index 00000000..92f20b87 --- /dev/null +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt @@ -0,0 +1,57 @@ +package yangfentuozi.batteryrecorder.shared.data + +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.util.zip.GZIPInputStream + +/** + * 统一封装记录文件读取入口,避免上层分散判断 plain / gzip。 + */ +object RecordFileIO { + private const val BUFFER_SIZE = 64 * 1024 + + /** + * 打开记录文件输入流。 + * + * @param file 记录文件,支持 `.txt` 与 `.txt.gz`。 + * @return 返回按文件格式适配后的输入流。 + */ + fun openInputStream(file: File): InputStream { + val rawInput = BufferedInputStream(FileInputStream(file), BUFFER_SIZE) + return if (RecordFileNames.isCompressedFileName(file.name)) { + GZIPInputStream(rawInput, BUFFER_SIZE) + } else { + rawInput + } + } + + /** + * 打开统一的文本读取器。 + * + * @param file 记录文件,支持 `.txt` 与 `.txt.gz`。 + * @return 返回 UTF-8 文本读取器。 + */ + fun openBufferedReader(file: File): BufferedReader = + BufferedReader(InputStreamReader(openInputStream(file), Charsets.UTF_8), BUFFER_SIZE) + + /** + * 以明文文本语义导出记录内容。 + * + * @param file 源记录文件。 + * @param output 导出目标输出流。 + * @return 无。 + */ + fun copyAsPlainText( + file: File, + output: OutputStream + ) { + openInputStream(file).use { input -> + input.copyTo(output, BUFFER_SIZE) + } + } +} diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileNames.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileNames.kt new file mode 100644 index 00000000..09b2a9ca --- /dev/null +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileNames.kt @@ -0,0 +1,131 @@ +package yangfentuozi.batteryrecorder.shared.data + +import java.io.File + +/** + * 记录文件命名与逻辑记录定位规则。 + * + * 当前逻辑记录名统一固定为 `时间戳.txt`; + * 物理文件允许是 `时间戳.txt` 或 `时间戳.txt.gz`。 + */ +object RecordFileNames { + const val PLAIN_SUFFIX = ".txt" + const val GZIP_SUFFIX = ".txt.gz" + const val TEMP_SUFFIX = ".tmp" + + data class Descriptor( + val timestamp: Long, + val logicalName: String, + val isCompressed: Boolean + ) + + /** + * 解析记录文件描述信息。 + * + * @param fileName 物理文件名。 + * @return 仅当文件名符合记录规则时返回描述信息,否则返回 `null`。 + */ + fun parse(fileName: String): Descriptor? { + if (fileName.endsWith(TEMP_SUFFIX)) return null + val timestampText = when { + fileName.endsWith(GZIP_SUFFIX) -> fileName.removeSuffix(GZIP_SUFFIX) + fileName.endsWith(PLAIN_SUFFIX) -> fileName.removeSuffix(PLAIN_SUFFIX) + else -> return null + } + val timestamp = timestampText.toLongOrNull() ?: return null + return Descriptor( + timestamp = timestamp, + logicalName = logicalName(timestamp), + isCompressed = fileName.endsWith(GZIP_SUFFIX) + ) + } + + /** + * 构造逻辑记录名。 + * + * @param timestamp 记录时间戳。 + * @return 返回统一逻辑记录名。 + */ + fun logicalName(timestamp: Long): String = "$timestamp$PLAIN_SUFFIX" + + /** + * 从任意支持的记录文件名提取逻辑记录名。 + * + * @param fileName 物理文件名或逻辑文件名。 + * @return 返回逻辑记录名,不支持则返回 `null`。 + */ + fun logicalNameOrNull(fileName: String): String? = parse(fileName)?.logicalName + + /** + * 从任意支持的记录文件名提取记录时间戳。 + * + * @param fileName 物理文件名或逻辑文件名。 + * @return 返回记录时间戳,不支持则返回 `null`。 + */ + fun timestampOrNull(fileName: String): Long? = parse(fileName)?.timestamp + + /** + * 判断文件名是否是 gzip 记录文件。 + * + * @param fileName 文件名。 + * @return gzip 记录文件返回 `true`。 + */ + fun isCompressedFileName(fileName: String): Boolean = fileName.endsWith(GZIP_SUFFIX) + + /** + * 判断文件名是否为压缩过程中的临时文件。 + * + * @param fileName 文件名。 + * @return 临时文件返回 `true`。 + */ + fun isTempFileName(fileName: String): Boolean = fileName.endsWith(TEMP_SUFFIX) + + /** + * 在目录中定位当前逻辑记录对应的实际物理文件。 + * + * 若 `.txt` 与 `.txt.gz` 同时存在,优先返回 `.txt.gz`,避免压缩收尾瞬间出现双份。 + * + * @param dir 记录目录。 + * @param logicalName 逻辑记录名。 + * @return 返回当前可用物理文件;不存在时返回 `null`。 + */ + fun resolvePhysicalFile( + dir: File, + logicalRecordName: String + ): File? { + val timestamp = timestampOrNull(logicalRecordName) ?: return null + val plainFile = File(dir, logicalName(timestamp)) + val gzipFile = File(dir, "${logicalName(timestamp)}.gz") + return when { + gzipFile.isFile -> gzipFile + plainFile.isFile -> plainFile + else -> null + } + } + + /** + * 列出目录内全部稳定记录文件。 + * + * 同一逻辑记录若同时存在 plain / gzip,优先返回 gzip,避免枚举重复记录。 + * + * @param dir 记录目录。 + * @return 返回按记录时间戳倒序排列的稳定物理文件列表。 + */ + fun listStableFiles(dir: File): List { + val selected = LinkedHashMap>() + dir.listFiles() + ?.asSequence() + ?.filter { it.isFile } + ?.forEach { file -> + val descriptor = parse(file.name) ?: return@forEach + val current = selected[descriptor.timestamp] + if (current == null || (!current.second && descriptor.isCompressed)) { + selected[descriptor.timestamp] = file to descriptor.isCompressed + } + } + return selected + .toList() + .sortedByDescending { it.first } + .map { it.second.first } + } +} diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt index a3fb5380..3a7cbc14 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt @@ -31,7 +31,7 @@ object RecordFileParser { var lineNumber = 0 var previousParsedTimestamp: Long? = null - file.bufferedReader().useLines { lines -> + RecordFileIO.openBufferedReader(file).useLines { lines -> lines.forEach { raw -> lineNumber++ val line = raw.trim() diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordsFile.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordsFile.kt index 771a6e72..4d10f69b 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordsFile.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordsFile.kt @@ -11,10 +11,12 @@ data class RecordsFile( ) : Parcelable { companion object { fun fromFile(file: File): RecordsFile { + val logicalName = RecordFileNames.logicalNameOrNull(file.name) + ?: throw IllegalArgumentException("Invalid record file name: ${file.name}") return RecordsFile( type = BatteryStatus.fromDataDirName(file.parentFile?.name), - name = file.name + name = logicalName ) } } -} \ No newline at end of file +} diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt index a96c524e..b7e8f3fd 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt @@ -14,6 +14,40 @@ import java.nio.file.Path object PfdFileSender { private const val TAG = "PfdFileSender" + /** + * 发送指定文件列表。 + * + * @param writePfd 写端管道。 + * @param baseDir 作为相对路径基准的根目录。 + * @param files 待发送文件列表。 + * @param callback 单个文件发送完成后的回调。 + * @return 无。 + */ + fun sendFiles( + writePfd: ParcelFileDescriptor, + baseDir: File, + files: List, + callback: ((File) -> Unit)? = null + ) { + val basePath = baseDir.toPath() + LoggerX.i(TAG, "sendFiles: 开始发送文件列表, base=${baseDir.absolutePath} count=${files.size}") + var sentCount = 0 + var sentBytes = 0L + ParcelFileDescriptor.AutoCloseOutputStream(writePfd).use { raw -> + BufferedOutputStream(raw, SyncConstants.BUF_SIZE).use { out -> + files.forEach { file -> + sendSingleFile(out, file, basePath, callback) { size -> + sentCount += 1 + sentBytes += size + } + } + out.write(SyncConstants.CODE_FINISHED) + out.flush() + } + } + LoggerX.i(TAG, "sendFiles: 文件发送完成, count=$sentCount bytes=$sentBytes") + } + fun sendFile( writePfd: ParcelFileDescriptor, file: File, @@ -37,6 +71,41 @@ object PfdFileSender { LoggerX.i(TAG, "sendFile: 文件发送完成, count=$sentCount bytes=$sentBytes") } + private fun sendSingleFile( + out: OutputStream, + file: File, + basePath: Path, + callback: ((File) -> Unit)?, + onSent: (Long) -> Unit + ) { + if (!file.exists() || !file.isFile) return + val size = file.length() + if (size < 0) throw IOException("Invalid file size: $size") + + out.write(SyncConstants.CODE_FILE) + out.write(basePath.relativize(file.toPath()).toString().toByteArray(Charsets.UTF_8)) + out.write(SyncConstants.CODE_DELIM) + out.write(size.toString().toByteArray(Charsets.US_ASCII)) + out.write(SyncConstants.CODE_DELIM) + + BufferedInputStream(FileInputStream(file), SyncConstants.BUF_SIZE).use { fis -> + val buf = ByteArray(SyncConstants.BUF_SIZE) + var remaining = size + while (remaining > 0) { + val toRead = minOf(remaining, buf.size.toLong()).toInt() + val n = fis.read(buf, 0, toRead) + if (n < 0) throw EOFException("Unexpected EOF reading file: ${file.absolutePath}") + out.write(buf, 0, n) + remaining -= n.toLong() + } + } + out.flush() + + LoggerX.d(TAG, "sendSingleFile: 发送文件, relative=${basePath.relativize(file.toPath())} size=$size") + onSent(size) + callback?.invoke(file) + } + private fun sendFileInner( out: OutputStream, file: File, @@ -50,41 +119,7 @@ object PfdFileSender { sendFileInner(out, it, basePath, callback, onSent) } } else { - val size = file.length() - if (size < 0) throw IOException("Invalid file size: $size") - - // 文件识别码 - out.write(SyncConstants.CODE_FILE) - - // 基于 basePath 的文件路径 (UTF-8) - out.write(basePath.relativize(file.toPath()).toString().toByteArray(Charsets.UTF_8)) - - // 00位 - out.write(SyncConstants.CODE_DELIM) - - // 文件大小 (ASCII 十进制) - out.write(size.toString().toByteArray(Charsets.US_ASCII)) - - // 00位 - out.write(SyncConstants.CODE_DELIM) - - // 文件内容:size: Long 字节 - BufferedInputStream(FileInputStream(file), SyncConstants.BUF_SIZE).use { fis -> - val buf = ByteArray(SyncConstants.BUF_SIZE) - var remaining = size - while (remaining > 0) { - val toRead = minOf(remaining, buf.size.toLong()).toInt() - val n = fis.read(buf, 0, toRead) - if (n < 0) throw EOFException("Unexpected EOF reading file: ${file.absolutePath}") - out.write(buf, 0, n) - remaining -= n.toLong() - } - } - out.flush() - - LoggerX.d(TAG, "sendFileInner: 发送文件, relative=${basePath.relativize(file.toPath())} size=$size") - onSent(size) - callback?.invoke(file) + sendSingleFile(out, file, basePath, callback, onSent) } } } From 01a5535af8ddd6e8cee93efa96aa365cd3e5da2c Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 10:44:32 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=90=8C=E6=AD=A5=E6=97=B6=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BF=9D=E6=8A=A4=E8=B7=AF=E5=BE=84=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/yangfentuozi/batteryrecorder/server/Server.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index 1bf4d95b..09f09342 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -208,9 +208,7 @@ class Server internal constructor() : IService.Stub() { currChargeDataPath?.toAbsolutePath()?.toString(), currDischargeDataPath?.toAbsolutePath()?.toString() ) - val filesToSync = writer.listStableHistoryFiles().filter { file -> - file.toPath().toAbsolutePath().toString() !in protectedPaths - } + val filesToSync = writer.listStableHistoryFiles() var sentCount = 0 PfdFileSender.sendFiles( From 65aaee54c0414354f444f0796fc0b45234b12a9c Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 12:17:51 +0800 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=E8=B0=83=E6=95=B4=20`Server.kt?= =?UTF-8?q?`=20=E4=B8=AD=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91=E7=9A=84?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在非 root 模式下才执行 `writer.awaitCompressionBlocking()`,优化 root 模式下的同步效率。 --- .../src/main/java/yangfentuozi/batteryrecorder/server/Server.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index 09f09342..cdded08a 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -183,11 +183,11 @@ class Server internal constructor() : IService.Stub() { override fun sync(): ParcelFileDescriptor? { writer.flushBufferBlocking() - writer.awaitCompressionBlocking() if (Os.getuid() == 0) { LoggerX.d(tag, "sync: root 模式不需要同步文件, return null") return null } + writer.awaitCompressionBlocking() val pipe = ParcelFileDescriptor.createPipe() val readEnd = pipe[0] From 42c9f762bef258d9daabc0f17b5f1ab3cb6345ec Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 14:14:46 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8C=E6=AD=A5=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=8C=E6=AD=A5=E5=BD=93=E5=89=8D=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E5=88=86=E6=AE=B5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新文档,明确 `sync()` 现在支持同步活跃分段文件,并说明了 shell 侧的清理规则。 - 移除同步前的压缩等待阻塞逻辑,提升同步响应速度。 --- AGENTS.md | 2 +- .../src/main/java/yangfentuozi/batteryrecorder/server/Server.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc950ade..781423e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV - Server 以 shell 权限运行时,记录文件落在 `com.android.shell` 数据目录 - App 通过 `sync()` AIDL 拿到 `ParcelFileDescriptor` - 传输协议由 `PfdFileSender` / `PfdFileReceiver` / `SyncConstants` 实现 -- `sync()` 当前只同步稳定历史记录文件,不会发送活跃分段或压缩中的临时文件 +- `sync()` 当前会同步记录目录中的可识别记录文件;当前活跃分段也会发送到 App 侧,但 shell 侧删除时会排除活跃文件,压缩中的临时文件不会进入发送列表 - 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理;App 侧接收完成后会预热本地 `power_stats` 缓存 ### 首页统计与预测链路 diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index cdded08a..484aadb9 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -187,7 +187,6 @@ class Server internal constructor() : IService.Stub() { LoggerX.d(tag, "sync: root 模式不需要同步文件, return null") return null } - writer.awaitCompressionBlocking() val pipe = ParcelFileDescriptor.createPipe() val readEnd = pipe[0] From 9364d9cd38b70cde96f9f0b81a9d8a45727fa66b Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 14:44:40 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E9=80=BB=E8=BE=91=E5=90=8D=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E4=BB=A5=E5=85=BC=E5=AE=B9=E5=8E=8B=E7=BC=A9=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **doc**: 简化 `AGENTS.md` 中的目录索引,仅保留根模块结构,并微调关键路径表格格式。 - **refactor**: 在 `HistoryViewModel` 中引入 `logicalRecordName()`,统一使用逻辑记录名进行文件过滤和索引查找,解决因 `.txt` 与 `.txt.gz` 后缀不一致导致的列表状态同步问题。 --- AGENTS.md | 284 ++++-------------- .../ui/viewmodel/HistoryViewModel.kt | 19 +- 2 files changed, 80 insertions(+), 223 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 781423e8..a4a743fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -247,9 +247,7 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV - `power_stats` 当前按逻辑记录名 `时间戳.txt` 命名,不直接跟随物理 `.txt.gz` 文件名;命中仍继续校验源文件 `lastModified()` - 具体缓存改动流程与检查清单已沉淀到项目 skill:`.agents/skills/history-stats-cache-change/` -## 目录索引 - -### 根模块 +## 根模块 ```text app/ @@ -259,223 +257,73 @@ hiddenapi/ docs/ ``` -### App 模块 - -```text -app/src/main/java/yangfentuozi/batteryrecorder/ -├── data/ -│ ├── history/ -│ │ ├── HistoryRepository.kt -│ │ ├── BatteryPredictor.kt -│ │ ├── DischargeRecordScanner.kt -│ │ ├── SceneStatsComputer.kt -│ │ ├── AppStatsComputer.kt -│ │ ├── RecordAppStatsComputer.kt -│ │ ├── RecordDetailPowerStatsComputer.kt -│ │ ├── HistoryCacheNaming.kt -│ │ ├── HistoryCacheVersions.kt -│ │ ├── StatisticsRequest.kt -│ │ └── SyncUtil.kt -│ ├── log/ -│ │ └── LogRepository.kt -│ ├── model/ -│ └── ... -├── ipc/ -├── startup/ -├── ui/ -│ ├── BatteryRecorderApp.kt -│ ├── MainActivity.kt -│ ├── BaseActivity.kt -│ ├── EdgeToEdgeInsets.kt -│ ├── navigation/ -│ ├── screens/ -│ │ ├── home/HomeScreen.kt -│ │ ├── settings/SettingsScreen.kt -│ │ ├── prediction/PredictionDetailScreen.kt -│ │ └── history/ -│ │ ├── HistoryListScreen.kt -│ │ └── RecordDetailScreen.kt -│ ├── components/ -│ │ ├── charts/PowerCapacityChart.kt -│ │ ├── global/ -│ │ │ ├── LazySplicedColumnGroup.kt -│ │ │ ├── MarkdownText.kt -│ │ │ └── ... -│ │ ├── home/ -│ │ │ ├── BatteryRecorderTopAppBar.kt -│ │ │ ├── CurrentRecordCard.kt -│ │ │ ├── PredictionCard.kt (同时包含 SceneStatsCard) -│ │ │ ├── StartServerCard.kt -│ │ │ └── StatsCard.kt -│ │ └── settings/sections/ -│ ├── dialog/ -│ │ ├── history/ChartGuideDialog.kt -│ │ ├── home/ -│ │ │ ├── AboutDialog.kt -│ │ │ ├── AdbGuideDialog.kt -│ │ │ ├── DocsIntroDialog.kt -│ │ │ ├── RecordCleanupDialog.kt -│ │ │ └── UpdateDialog.kt -│ │ └── settings/ -│ │ ├── WeightedAlgorithmDialog.kt -│ │ ├── SceneStatsRecentFileCountDialog.kt -│ │ └── ... -│ ├── model/ -│ ├── theme/ -│ └── viewmodel/ -│ ├── MainViewModel.kt -│ ├── SettingsViewModel.kt -│ ├── HistoryViewModel.kt -│ ├── PredictionDetailViewModel.kt -│ └── PowerDisplayMapper.kt -└── utils/ - ├── AppIconMemoryCache.kt - ├── AppDownloader.kt - ├── FormatUtil.kt - └── UpdateUtil.kt -``` - -### Server 模块 - -```text -server/src/main/ -├── aidl/ -├── java/yangfentuozi/batteryrecorder/server/ -│ ├── AppSourceDirObserver.kt -│ ├── BinderSender.kt -│ ├── Global.kt -│ ├── Main.kt -│ ├── Server.kt -│ ├── fakecontext/ -│ │ ├── ExternalProviderResolver.kt -│ │ └── FakeContext.kt -│ ├── notification/ -│ │ ├── LocalNotificationUtil.kt -│ │ ├── NotificationInfo.kt -│ │ ├── NotificationUtil.kt -│ │ ├── RemoteNotificationUtil.kt -│ │ └── server/ -│ │ ├── ChildServerBridge.kt -│ │ ├── NotificationServer.kt -│ │ └── stream/ -│ ├── recorder/ -│ │ └── Monitor.kt -│ ├── sampler/ -│ │ ├── Sampler.kt -│ │ ├── SysfsSampler.kt -│ │ └── DumpsysSampler.kt -│ ├── stream/ -│ │ ├── StreamProtocol.kt -│ │ ├── StreamReader.kt -│ │ └── StreamWriter.kt -│ └── writer/ -│ └── PowerRecordWriter.kt -└── jni/ - ├── CMakeLists.txt - ├── dump_parser.cpp - ├── power_reader.cpp - └── starter.cpp -``` - -### Shared 模块 - -```text -shared/src/main/ -├── aidl/ -└── java/yangfentuozi/batteryrecorder/shared/ - ├── config/ - │ ├── ConfigItems.kt - │ ├── SettingsConstants.kt - │ ├── ConfigUtil.kt - │ ├── SharedSettings.kt - │ └── dataclass/ - ├── data/ - │ ├── BatteryStatus.kt - │ ├── LineRecord.kt - │ ├── RecordFileIO.kt - │ ├── RecordFileNames.kt - │ ├── RecordFileParser.kt - │ ├── RecordsFile.kt - │ └── RecordsStats.kt - ├── sync/ - │ ├── PfdFileSender.kt - │ ├── PfdFileReceiver.kt - │ └── SyncConstants.kt - ├── util/ - │ ├── Handlers.kt - │ └── LoggerX.kt - ├── writer/ - │ └── AdvancedWriter.kt - └── Constants.kt -``` - ## 关键路径索引 -| 功能 | 路径 | -|---------------------------|----------------------------------------------------------------------------------------------------| -| App 进程入口 | `app/.../App.kt` | -| App 入口 Composable | `app/.../ui/BatteryRecorderApp.kt` | -| Activity Edge-to-Edge 入口 | `app/.../ui/BaseActivity.kt` | -| 页面级 Insets 公共方法 | `app/.../ui/EdgeToEdgeInsets.kt` | -| 导航路由 | `app/.../ui/navigation/NavRoute.kt` | -| 导航宿主与历史页共享 ViewModel | `app/.../ui/navigation/BatteryRecorderNavHost.kt` | -| 首页 | `app/.../ui/screens/home/HomeScreen.kt` | -| 首页 `TopAppBar` | `app/.../ui/components/home/BatteryRecorderTopAppBar.kt` | -| 首页当前记录卡片 | `app/.../ui/components/home/CurrentRecordCard.kt` | -| 首页 Root 启动卡片 | `app/.../ui/components/home/StartServerCard.kt` | -| 首页汇总卡片 | `app/.../ui/components/home/StatsCard.kt` | -| 首页记录清理弹窗 | `app/.../ui/dialog/home/RecordCleanupDialog.kt` | -| 设置页 | `app/.../ui/screens/settings/SettingsScreen.kt` | -| 历史列表 | `app/.../ui/screens/history/HistoryListScreen.kt` | -| 记录详情页 | `app/.../ui/screens/history/RecordDetailScreen.kt` | -| 预测详情页 | `app/.../ui/screens/prediction/PredictionDetailScreen.kt` | -| 预测详情 ViewModel | `app/.../ui/viewmodel/PredictionDetailViewModel.kt` | -| 首页预测/场景卡片 | `app/.../ui/components/home/PredictionCard.kt` | -| 图表说明弹窗 | `app/.../ui/dialog/history/ChartGuideDialog.kt` | -| ViewModel | `app/.../ui/viewmodel/` | -| 记录详情图表状态 | `app/.../ui/viewmodel/HistoryViewModel.kt` | -| 放电显示映射 | `app/.../ui/viewmodel/PowerDisplayMapper.kt` | -| IPC Binder 持有 | `app/.../ipc/Service.kt` | -| Binder 接收 Provider | `app/.../ipc/BinderProvider.kt` | -| 配置 Provider | `app/.../ipc/ConfigProvider.kt` | -| 开机自启动 | `app/.../startup/BootCompletedReceiver.kt`, `RootServerStarter.kt`, `BootAutoStartNotification.kt` | -| 原生启动器 | `server/src/main/jni/starter.cpp` | -| 历史仓库 | `app/.../data/history/HistoryRepository.kt` | -| 单记录应用统计 | `app/.../data/history/RecordAppStatsComputer.kt` | -| 记录详情功耗统计 | `app/.../data/history/RecordDetailPowerStatsComputer.kt` | -| 应用预测统计 | `app/.../data/history/AppStatsComputer.kt` | -| 场景统计 | `app/.../data/history/SceneStatsComputer.kt` | -| 放电扫描 | `app/.../data/history/DischargeRecordScanner.kt` | -| 续航预测 | `app/.../data/history/BatteryPredictor.kt` | -| 缓存命名与版本 | `app/.../data/history/HistoryCacheNaming.kt`, `HistoryCacheVersions.kt` | -| 日志导出 | `app/.../data/log/LogRepository.kt` | -| 图表组件 | `app/.../ui/components/charts/PowerCapacityChart.kt` | -| 图标缓存 | `app/.../utils/AppIconMemoryCache.kt` | -| 更新下载/安装 | `app/.../utils/AppDownloader.kt` | -| 功率换算/格式化 | `app/.../utils/FormatUtil.kt` | -| 更新检查工具(对象名 `UpdateUtils`) | `app/.../utils/UpdateUtil.kt` | -| 更新通道展示映射 | `app/.../ui/model/UpdateChannelDisplayName.kt` | -| Server 进程入口 | `server/.../Main.kt` | -| App 更新监听与自动重启 | `server/.../AppSourceDirObserver.kt`, `server/.../Server.kt` | -| Server Binder 实现 | `server/.../Server.kt` | -| Binder 重推链路 | `server/.../BinderSender.kt` | -| 通知子进程桥接 | `server/.../notification/server/ChildServerBridge.kt` | -| 通知子进程入口 | `server/.../notification/server/NotificationServer.kt` | -| 本地通知下发 | `server/.../notification/LocalNotificationUtil.kt` | -| FakeContext | `server/.../fakecontext/FakeContext.kt` | -| 采样循环 | `server/.../recorder/Monitor.kt` | -| 采样抽象 | `server/.../sampler/Sampler.kt` | -| sysfs/JNI 采样 | `server/.../sampler/SysfsSampler.kt` | -| dumpsys 回退采样 | `server/.../sampler/DumpsysSampler.kt` | -| Server 状态续接协议 | `server/.../stream/StreamProtocol.kt`, `StreamReader.kt`, `StreamWriter.kt` | -| 写文件 | `server/.../writer/PowerRecordWriter.kt` | +| 功能 | 路径 | +|---------------------------|------------------------------------------------------------------------------------------------------------------| +| App 进程入口 | `app/.../App.kt` | +| App 入口 Composable | `app/.../ui/BatteryRecorderApp.kt` | +| Activity Edge-to-Edge 入口 | `app/.../ui/BaseActivity.kt` | +| 页面级 Insets 公共方法 | `app/.../ui/EdgeToEdgeInsets.kt` | +| 导航路由 | `app/.../ui/navigation/NavRoute.kt` | +| 导航宿主与历史页共享 ViewModel | `app/.../ui/navigation/BatteryRecorderNavHost.kt` | +| 首页 | `app/.../ui/screens/home/HomeScreen.kt` | +| 首页 `TopAppBar` | `app/.../ui/components/home/BatteryRecorderTopAppBar.kt` | +| 首页当前记录卡片 | `app/.../ui/components/home/CurrentRecordCard.kt` | +| 首页 Root 启动卡片 | `app/.../ui/components/home/StartServerCard.kt` | +| 首页汇总卡片 | `app/.../ui/components/home/StatsCard.kt` | +| 首页记录清理弹窗 | `app/.../ui/dialog/home/RecordCleanupDialog.kt` | +| 设置页 | `app/.../ui/screens/settings/SettingsScreen.kt` | +| 历史列表 | `app/.../ui/screens/history/HistoryListScreen.kt` | +| 记录详情页 | `app/.../ui/screens/history/RecordDetailScreen.kt` | +| 预测详情页 | `app/.../ui/screens/prediction/PredictionDetailScreen.kt` | +| 预测详情 ViewModel | `app/.../ui/viewmodel/PredictionDetailViewModel.kt` | +| 首页预测/场景卡片 | `app/.../ui/components/home/PredictionCard.kt` | +| 图表说明弹窗 | `app/.../ui/dialog/history/ChartGuideDialog.kt` | +| ViewModel | `app/.../ui/viewmodel/` | +| 记录详情图表状态 | `app/.../ui/viewmodel/HistoryViewModel.kt` | +| 放电显示映射 | `app/.../ui/viewmodel/PowerDisplayMapper.kt` | +| IPC Binder 持有 | `app/.../ipc/Service.kt` | +| Binder 接收 Provider | `app/.../ipc/BinderProvider.kt` | +| 配置 Provider | `app/.../ipc/ConfigProvider.kt` | +| 开机自启动 | `app/.../startup/BootCompletedReceiver.kt`, `RootServerStarter.kt`, `BootAutoStartNotification.kt` | +| 原生启动器 | `server/src/main/jni/starter.cpp` | +| 历史仓库 | `app/.../data/history/HistoryRepository.kt` | +| 单记录应用统计 | `app/.../data/history/RecordAppStatsComputer.kt` | +| 记录详情功耗统计 | `app/.../data/history/RecordDetailPowerStatsComputer.kt` | +| 应用预测统计 | `app/.../data/history/AppStatsComputer.kt` | +| 场景统计 | `app/.../data/history/SceneStatsComputer.kt` | +| 放电扫描 | `app/.../data/history/DischargeRecordScanner.kt` | +| 续航预测 | `app/.../data/history/BatteryPredictor.kt` | +| 缓存命名与版本 | `app/.../data/history/HistoryCacheNaming.kt`, `HistoryCacheVersions.kt` | +| 日志导出 | `app/.../data/log/LogRepository.kt` | +| 图表组件 | `app/.../ui/components/charts/PowerCapacityChart.kt` | +| 图标缓存 | `app/.../utils/AppIconMemoryCache.kt` | +| 更新下载/安装 | `app/.../utils/AppDownloader.kt` | +| 功率换算/格式化 | `app/.../utils/FormatUtil.kt` | +| 更新检查工具(对象名 `UpdateUtils`) | `app/.../utils/UpdateUtil.kt` | +| 更新通道展示映射 | `app/.../ui/model/UpdateChannelDisplayName.kt` | +| Server 进程入口 | `server/.../Main.kt` | +| App 更新监听与自动重启 | `server/.../AppSourceDirObserver.kt`, `server/.../Server.kt` | +| Server Binder 实现 | `server/.../Server.kt` | +| Binder 重推链路 | `server/.../BinderSender.kt` | +| 通知子进程桥接 | `server/.../notification/server/ChildServerBridge.kt` | +| 通知子进程入口 | `server/.../notification/server/NotificationServer.kt` | +| 本地通知下发 | `server/.../notification/LocalNotificationUtil.kt` | +| FakeContext | `server/.../fakecontext/FakeContext.kt` | +| 采样循环 | `server/.../recorder/Monitor.kt` | +| 采样抽象 | `server/.../sampler/Sampler.kt` | +| sysfs/JNI 采样 | `server/.../sampler/SysfsSampler.kt` | +| dumpsys 回退采样 | `server/.../sampler/DumpsysSampler.kt` | +| Server 状态续接协议 | `server/.../stream/StreamProtocol.kt`, `StreamReader.kt`, `StreamWriter.kt` | +| 写文件 | `server/.../writer/PowerRecordWriter.kt` | | JNI 原生代码 | `server/src/main/jni/power_reader.cpp`, `server/src/main/jni/dump_parser.cpp`, `server/src/main/jni/starter.cpp` | -| AIDL 接口 | `server/src/main/aidl/` | -| 共享配置 | `shared/.../config/` | -| 共享数据模型与解析 | `shared/.../data/` | -| 共享配置核心文件 | `shared/.../config/SharedSettings.kt`, `shared/.../config/ConfigUtil.kt` | -| 同步协议 | `shared/.../sync/` | -| 日志工具 | `shared/.../util/LoggerX.kt` | +| AIDL 接口 | `server/src/main/aidl/` | +| 共享配置 | `shared/.../config/` | +| 共享数据模型与解析 | `shared/.../data/` | +| 共享配置核心文件 | `shared/.../config/SharedSettings.kt`, `shared/.../config/ConfigUtil.kt` | +| 同步协议 | `shared/.../sync/` | +| 日志工具 | `shared/.../util/LoggerX.kt` | ## 架构约定 @@ -551,7 +399,7 @@ shared/src/main/ - 涉及“新增设置项”时,优先使用项目私有 skill:`.agents/skills/add-setting-item/` - 涉及历史统计缓存改动时,优先使用项目私有 skill:`.agents/skills/history-stats-cache-change/` - 涉及 Compose 页面沉浸或 inset 改动时,优先使用项目私有 skill:`.agents/skills/compose-edge-to-edge-screen/` -- 优先搜索 `fast-context MCP` +- 优先搜索 `fast-cxt MCP` - 精确关键词搜索再用本地 grep/查找 - 只修改与当前任务直接相关的文件 - 如果发现未预期的未提交修改,立即停止并询问用户 diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt b/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt index 3c0ab14c..9837f09b 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt @@ -26,6 +26,7 @@ import yangfentuozi.batteryrecorder.data.model.normalizeRecordDetailChartPoints import yangfentuozi.batteryrecorder.shared.config.SettingsConstants import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.LineRecord +import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.util.LoggerX import yangfentuozi.batteryrecorder.utils.computeEnergyWh @@ -156,6 +157,11 @@ class HistoryViewModel : ViewModel() { const val TARGET_TREND_BUCKET_COUNT = 240L } + // ViewModel 层只允许按逻辑记录名比较,避免 `.txt` / `.txt.gz` 的物理后缀变化污染列表状态。 + private fun File.logicalRecordName(): String = + RecordFileNames.logicalNameOrNull(name) + ?: throw IllegalArgumentException("Invalid record file name: $name") + /** * 加载历史列表。 * @@ -398,8 +404,8 @@ class HistoryViewModel : ViewModel() { val deletedSourceIndex = findSourceIndexByName(deletedName) _records.value = _records.value.filter { it.asRecordsFile() != recordsFile } if (currentListType == recordsFile.type) { - // 删除后同步修正数据源、筛选缓存与游标,避免翻页跳过未显示项。 - listFiles = listFiles.filter { it.name != deletedName } + // 删除后按逻辑名同步修正数据源、筛选缓存与游标,避免压缩后物理文件名不一致。 + listFiles = listFiles.filter { it.logicalRecordName() != deletedName } latestListFile = listFiles.firstOrNull() allListRecordsCache = allListRecordsCache?.filter { record -> record.name != deletedName @@ -682,13 +688,16 @@ class HistoryViewModel : ViewModel() { if (_chargeCapacityChangeFilter.value != null) return true if (listDischargeDisplayPositive != dischargeDisplayPositive) return true if (latestFiles.size != listFiles.size) return true - if (latestFiles.indices.any { index -> latestFiles[index].name != listFiles[index].name }) { + if (latestFiles.indices.any { index -> + latestFiles[index].logicalRecordName() != listFiles[index].logicalRecordName() + } + ) { return true } val latestFile = latestFiles.firstOrNull() ?: return _records.value.isNotEmpty() val currentFirstRecord = _records.value.firstOrNull() ?: return true - return currentFirstRecord.name != latestFile.name + return currentFirstRecord.name != latestFile.logicalRecordName() } /** @@ -754,7 +763,7 @@ class HistoryViewModel : ViewModel() { if (sourceRecords != null) { return sourceRecords.indexOfFirst { record -> record.name == recordName } } - return listFiles.indexOfFirst { file -> file.name == recordName } + return listFiles.indexOfFirst { file -> file.logicalRecordName() == recordName } } private fun resolveCurrentExportRecords(type: BatteryStatus): List? { From eaf55210cf742c008def23b8adb9582036877856 Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 21:56:36 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=E5=B0=86=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=9A=84=E5=8E=8B=E7=BC=A9=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=8E=20Server=20=E4=BE=A7=E7=A7=BB=E5=8A=A8=E8=87=B3=20App?= =?UTF-8?q?=20=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Server 侧**:移除 `PowerRecordWriter` 中的后台压缩逻辑及相关线程句柄,不再主动压缩已关闭的历史分段。 - **App 侧**:在 `HistoryRepository` 中新增 `compressHistoricalRecords` 方法,支持对本地非活跃历史记录进行 GZIP 压缩。 - **同步逻辑**:优化 `SyncUtil.sync()` 流程。在完成数据拉取后,自动触发本地历史记录的压缩整理,并统一进行统计缓存预热。 - **文档更新**:更新 `AGENTS.md`,明确 Server 侧保持明文存储,由 App 侧在同步入口负责本地历史的压缩编排。 --- AGENTS.md | 6 +- .../data/history/HistoryRepository.kt | 87 +++++++++++++++++ .../batteryrecorder/data/history/SyncUtil.kt | 70 ++++++++++---- .../server/writer/PowerRecordWriter.kt | 96 ------------------- 4 files changed, 142 insertions(+), 117 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4a743fe..94a6aeec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV `timestamp,power,packageName,capacity,isDisplayOn,temp,voltage,current` - 其中 `voltage` 列落盘单位为 4 位 `mV`;运行时 `LineRecord.voltage` 仍统一使用 `uV` - `LineRecord.status` 当前只用于运行时采样链路,不参与记录文件落盘;记录文件语义由目录类型(charge/discharge)与 8 列 CSV 本身共同决定 -- 当前活跃分段保持明文 `时间戳.txt`;已关闭历史分段会在后台压缩为 `时间戳.txt.gz` +- 当前活跃分段保持明文 `时间戳.txt`;Server 侧已关闭历史分段继续保持明文,App 会在 `sync()` 编排入口压缩本地非活跃历史为 `时间戳.txt.gz` - 记录的逻辑名称仍固定为 `时间戳.txt`;历史列表、导航、删除、缓存与导出都按逻辑名称工作,物理层再解析到 `.txt` / `.txt.gz` ### 数据同步链路 @@ -115,8 +115,8 @@ Sampler -> SysfsSampler / DumpsysSampler -> Monitor -> PowerRecordWriter -> CSV - Server 以 shell 权限运行时,记录文件落在 `com.android.shell` 数据目录 - App 通过 `sync()` AIDL 拿到 `ParcelFileDescriptor` - 传输协议由 `PfdFileSender` / `PfdFileReceiver` / `SyncConstants` 实现 -- `sync()` 当前会同步记录目录中的可识别记录文件;当前活跃分段也会发送到 App 侧,但 shell 侧删除时会排除活跃文件,压缩中的临时文件不会进入发送列表 -- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理;App 侧接收完成后会预热本地 `power_stats` 缓存 +- `sync()` 当前会先读取当前活跃记录,再按需从 shell 侧拉取记录文件;不论 root 还是 shell,都会在 App 侧压缩本地非活跃历史并在最后预热本地 `power_stats` 缓存 +- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理;App 侧压缩本地历史时通过 `.tmp` 临时文件收尾,目录枚举继续忽略临时文件 ### 首页统计与预测链路 diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt index 129e4cfd..b9d379e6 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt @@ -18,6 +18,7 @@ import java.io.FileNotFoundException import java.io.IOException import java.util.zip.ZipInputStream import java.util.zip.ZipEntry +import java.util.zip.GZIPOutputStream import java.util.zip.ZipOutputStream private const val TAG = "HistoryRepository" @@ -84,6 +85,7 @@ private data class RecordCleanupTarget( object HistoryRepository { private const val NOT_ENOUGH_VALID_SAMPLES_PREFIX = "Not enough valid samples after filtering:" + private const val RECORD_COMPRESSION_BUFFER_SIZE = 64 * 1024 private val CLEANUP_TARGET_TYPES = listOf(BatteryStatus.Charging, BatteryStatus.Discharging) // 逻辑记录名固定为“起始时间戳.txt”,物理文件允许是 `.txt` 或 `.txt.gz`。 @@ -331,6 +333,42 @@ object HistoryRepository { return deleteRecordFile(context, sourceFile, recordsFile.type) } + /** + * 压缩本地非活跃历史记录。 + * + * @param context 应用上下文。 + * @param activeRecordsFile 当前仍在写入的记录;会按逻辑记录名排除,不参与压缩。 + * @return 返回成功压缩的记录数量。 + */ + fun compressHistoricalRecords( + context: Context, + activeRecordsFile: RecordsFile? = null + ): Int { + val activeLogicalName = activeRecordsFile?.name + var compressedCount = 0 + + CLEANUP_TARGET_TYPES.forEach { type -> + dataDir(context, type).listFiles() + ?.asSequence() + ?.filter { file -> + val descriptor = RecordFileNames.parse(file.name) ?: return@filter false + !descriptor.isCompressed && descriptor.logicalName != activeLogicalName + } + ?.sortedBy { it.name } + ?.forEach { file -> + if (compressRecordFile(file)) { + compressedCount += 1 + } + } + } + + LoggerX.i( + TAG, + "[历史] 本地历史压缩完成: compressed=$compressedCount active=${activeLogicalName ?: "null"}" + ) + return compressedCount + } + /** * 按主页清理规则扫描并删除历史记录。 * @@ -715,4 +753,53 @@ object HistoryRepository { runCatching { getPowerStatsCacheFile(cacheRoot, logicalName).delete() } return removedData } + + private fun compressRecordFile(sourceFile: File): Boolean { + val descriptor = RecordFileNames.parse(sourceFile.name) ?: return false + if (descriptor.isCompressed) return false + + val parentDir = sourceFile.parentFile + ?: throw IOException("记录文件没有父目录: ${sourceFile.absolutePath}") + val gzipFile = File(parentDir, "${descriptor.logicalName}.gz") + val tempFile = File(parentDir, "${descriptor.logicalName}.gz${RecordFileNames.TEMP_SUFFIX}") + + if (gzipFile.exists()) { + if (!sourceFile.delete()) { + throw IOException("删除重复明文记录失败: ${sourceFile.absolutePath}") + } + LoggerX.i( + TAG, + "[历史] 删除重复明文记录: source=${sourceFile.name} target=${gzipFile.name}" + ) + return true + } + + if (tempFile.exists() && !tempFile.delete()) { + throw IOException("删除压缩临时文件失败: ${tempFile.absolutePath}") + } + + try { + sourceFile.inputStream().buffered(RECORD_COMPRESSION_BUFFER_SIZE).use { input -> + GZIPOutputStream( + tempFile.outputStream().buffered(RECORD_COMPRESSION_BUFFER_SIZE) + ).use { output -> + input.copyTo(output, RECORD_COMPRESSION_BUFFER_SIZE) + } + } + if (!tempFile.renameTo(gzipFile)) { + throw IOException("记录压缩临时文件改名失败: ${tempFile.absolutePath}") + } + if (!sourceFile.delete()) { + throw IOException("压缩完成后删除明文记录失败: ${sourceFile.absolutePath}") + } + LoggerX.i( + TAG, + "[历史] 本地历史压缩成功: source=${sourceFile.name} target=${gzipFile.name}" + ) + return true + } catch (error: Throwable) { + tempFile.delete() + throw error + } + } } diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt index 750641d8..042073e2 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt @@ -17,31 +17,65 @@ object SyncUtil { LoggerX.w(TAG, "[SYNC] 服务未连接,跳过同步") return } - LoggerX.i(TAG, "[SYNC] 开始从服务端拉取记录文件") - val readPfd = service.sync() ?: run { - LoggerX.w(TAG, "[SYNC] 服务端未返回同步管道") - return - } + LoggerX.i(TAG, "[SYNC] 开始刷新本地历史仓库") val outDir = File(context.dataDir, Constants.APP_POWER_DATA_PATH) + val syncedTargets = LinkedHashMap>() try { - PfdFileReceiver.receiveToDir(readPfd, outDir) { savedFile, _ -> - val logicalName = RecordFileNames.logicalNameOrNull(savedFile.name) ?: return@receiveToDir - val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalName) - runCatching { - RecordsStats.getCachedStats( - cacheFile = cacheFile, - sourceFile = savedFile, - needCaching = true - ) - }.onFailure { error -> - LoggerX.w(TAG, "[SYNC] 预热记录统计缓存失败: file=${savedFile.absolutePath}", tr = error) + val readPfd = service.sync() + if (readPfd == null) { + LoggerX.i(TAG, "[SYNC] 服务端未返回同步管道,跳过传输,继续整理本地历史") + } else { + PfdFileReceiver.receiveToDir(readPfd, outDir) { savedFile, _ -> + val logicalName = RecordFileNames.logicalNameOrNull(savedFile.name) + ?: return@receiveToDir + val parentDir = savedFile.parentFile ?: return@receiveToDir + syncedTargets["${parentDir.absolutePath}/$logicalName"] = parentDir to logicalName + } + LoggerX.i(TAG, "[SYNC] 客户端接收完成: ${outDir.absolutePath}") + } + + val activeRecordsFileResult = runCatching { service.currRecordsFile } + .onFailure { error -> + LoggerX.e(TAG, "[SYNC] 读取服务端当前记录失败", tr = error) } + val compressedCount = if (activeRecordsFileResult.isSuccess) { + HistoryRepository.compressHistoricalRecords( + context = context, + activeRecordsFile = activeRecordsFileResult.getOrNull() + ) + } else { + LoggerX.w(TAG, "[SYNC] 当前记录未知,跳过本地历史压缩") + 0 } - LoggerX.i(TAG, "[SYNC] 客户端接收完成: ${outDir.absolutePath}") + syncedTargets.values.forEach { (parentDir, logicalName) -> + preheatPowerStatsCache(context, parentDir, logicalName) + } + LoggerX.i( + TAG, + "[SYNC] 本地历史整理完成: received=${syncedTargets.size} compressed=$compressedCount" + ) } catch (e: Exception) { - LoggerX.e(TAG, "[SYNC] 客户端接收失败", tr = e) + LoggerX.e(TAG, "[SYNC] 本地历史整理失败", tr = e) + return } } + private fun preheatPowerStatsCache( + context: Context, + parentDir: File, + logicalName: String + ) { + val sourceFile = RecordFileNames.resolvePhysicalFile(parentDir, logicalName) ?: return + val cacheFile = getPowerStatsCacheFile(context.cacheDir, logicalName) + runCatching { + RecordsStats.getCachedStats( + cacheFile = cacheFile, + sourceFile = sourceFile, + needCaching = true + ) + }.onFailure { error -> + LoggerX.w(TAG, "[SYNC] 预热记录统计缓存失败: file=${sourceFile.absolutePath}", tr = error) + } + } } diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt index 071bc883..59ccc660 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt @@ -11,12 +11,9 @@ import yangfentuozi.batteryrecorder.shared.util.Handlers import yangfentuozi.batteryrecorder.shared.util.LoggerX import yangfentuozi.batteryrecorder.shared.writer.AdvancedWriter import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream -import java.util.concurrent.CountDownLatch -import java.util.zip.GZIPOutputStream class PowerRecordWriter( powerDir: File, @@ -24,7 +21,6 @@ class PowerRecordWriter( private val fixFileOwner: ((File) -> Unit) ) { private val tag = "PowerRecordWriter" - private val compressionHandler: Handler = Handlers.getHandler("RecordCompressionThread") /** * 单条采样写入结果。 @@ -78,7 +74,6 @@ class PowerRecordWriter( makeSureExists(powerDir) makeSureExists(chargeDir) makeSureExists(dischargeDir) - scheduleHistoricalCompressionBootstrap() } fun write(record: LineRecord): WriteResult { @@ -113,7 +108,6 @@ class PowerRecordWriter( fun close() { chargeDataWriter.closeCurrentSegment() dischargeDataWriter.closeCurrentSegment() - awaitCompressionBlocking() } fun flushBuffer() { @@ -126,97 +120,9 @@ class PowerRecordWriter( dischargeDataWriter.flushBufferBlocking() } - fun awaitCompressionBlocking() { - val latch = CountDownLatch(1) - compressionHandler.post { latch.countDown() } - latch.await() - } - fun listStableHistoryFiles(): List = RecordFileNames.listStableFiles(chargeDir) + RecordFileNames.listStableFiles(dischargeDir) - private fun scheduleCompression(file: File) { - compressionHandler.post { - compressHistoricalSegment(file) - } - } - - /** - * 启动后回填压缩旧的历史明文分段。 - * - * 当前活跃分段保持明文,不参与这轮回填;其余已遗留的 `.txt` 会在后台排队压缩。 - * - * @return 无。 - */ - private fun scheduleHistoricalCompressionBootstrap() { - val activePaths = setOfNotNull( - chargeDataWriter.segmentFile?.absolutePath, - dischargeDataWriter.segmentFile?.absolutePath - ) - listOf(chargeDir, dischargeDir).forEach { dir -> - dir.listFiles() - ?.asSequence() - ?.filter { file -> - file.isFile && - !RecordFileNames.isCompressedFileName(file.name) && - !RecordFileNames.isTempFileName(file.name) && - RecordFileNames.parse(file.name) != null && - file.absolutePath !in activePaths - } - ?.sortedBy { it.name } - ?.forEach { file -> - scheduleCompression(file) - } - } - } - - /** - * 将已关闭分段压缩为 gzip。 - * - * 保持当前活跃文件明文;历史分段压缩时通过 `.tmp` 临时文件收尾, - * 目录枚举侧若短暂同时看到 `.txt` 与 `.txt.gz`,会优先选择 gzip,避免重复记录。 - * - * @param sourceFile 已关闭的历史明文分段。 - * @return 无。 - */ - private fun compressHistoricalSegment(sourceFile: File) { - if (!sourceFile.exists()) return - val descriptor = RecordFileNames.parse(sourceFile.name) ?: return - if (descriptor.isCompressed) return - - val gzipFile = File(sourceFile.parentFile, "${descriptor.logicalName}.gz") - val tempFile = File(sourceFile.parentFile, "${descriptor.logicalName}.gz${RecordFileNames.TEMP_SUFFIX}") - if (gzipFile.exists()) { - if (!sourceFile.delete()) { - LoggerX.w(tag, "compressHistoricalSegment: 旧明文分段删除失败, file=${sourceFile.absolutePath}") - } - return - } - - try { - if (tempFile.exists() && !tempFile.delete()) { - throw IOException("Failed to delete temp file: ${tempFile.absolutePath}") - } - FileInputStream(sourceFile).use { input -> - GZIPOutputStream(FileOutputStream(tempFile)).use { output -> - input.copyTo(output, 64 * 1024) - } - } - fixFileOwner(tempFile) - if (!tempFile.renameTo(gzipFile)) { - throw IOException("Failed to rename temp file: ${tempFile.absolutePath}") - } - fixFileOwner(gzipFile) - if (!sourceFile.delete()) { - LoggerX.w(tag, "compressHistoricalSegment: 明文分段删除失败, file=${sourceFile.absolutePath}") - } - LoggerX.d(tag, "compressHistoricalSegment: 历史分段压缩完成, source=${sourceFile.name} target=${gzipFile.name}") - } catch (e: Exception) { - tempFile.delete() - LoggerX.e(tag, "compressHistoricalSegment: 历史分段压缩失败, file=${sourceFile.absolutePath}", tr = e) - } - } - inner class ChargeDataWriter(dir: File, statusData: ChildWriterStatusData?) : BaseDelayedRecordWriter(dir, statusData) { override fun needStartNewSegment(justChangedStatus: Boolean, nowTime: Long): Boolean { // case1 记录超过最大分段时间(0 表示不按时间分段) @@ -421,8 +327,6 @@ class PowerRecordWriter( if (closedFile != null && needDeleteSegment(System.currentTimeMillis())) { LoggerX.v(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 删除短分段, file=${closedFile.name}") closedFile.delete() - } else if (closedFile != null) { - scheduleCompression(closedFile) } segmentFile = null } From b4e922156784ec778101a5c46f8cc2e7d753e038 Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 22:17:38 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=80=BB=E8=BE=91=E5=B9=B6=E7=AE=80=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 `Server.kt` 中的文件同步逻辑,使用 `Files.isSameFile` 准确判断并删除已发送的历史文件,简化了受保护路径的判断逻辑。 - 移除 `Server` 类中未使用的变量 `appDataDir` 和 `shellDataDir`。 - 移除 `PowerRecordWriter` 中不再使用的 `listStableHistoryFiles` 方法。 - 优化 `BaseDelayedRecordWriter` 中关闭分段文件时的删除逻辑。 - 清理冗余的 `RecordFileNames` 引用。 --- .../batteryrecorder/server/Server.kt | 41 ++++++------------- .../server/writer/PowerRecordWriter.kt | 11 ++--- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index 484aadb9..368ed24e 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -27,7 +27,6 @@ import yangfentuozi.batteryrecorder.shared.config.SettingsConstants import yangfentuozi.batteryrecorder.shared.config.dataclass.ServerSettings import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Charging import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Discharging -import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.sync.PfdFileSender import yangfentuozi.batteryrecorder.shared.util.Handlers @@ -38,6 +37,7 @@ import yangfentuozi.hiddenapi.compat.ServiceManagerCompat import java.io.File import java.io.FileDescriptor import java.io.IOException +import java.nio.file.Files import java.util.Scanner import kotlin.system.exitProcess @@ -50,10 +50,8 @@ class Server internal constructor() : IService.Stub() { private var serverSocket: LocalServerSocket? = null private val appSourceDirObserver: AppSourceDirObserver - private var appDataDir: File private var appConfigFile: File private var appPowerDataDir: File - private var shellDataDir: File private var shellPowerDataDir: File override fun stopService() { @@ -203,36 +201,25 @@ class Server internal constructor() : IService.Stub() { val currDischargeDataPath = if (writer.dischargeDataWriter.needStartNewSegment(writer.dischargeDataWriter.hasPendingStatusChange)) null else writer.dischargeDataWriter.segmentFile?.toPath() - val protectedPaths = setOfNotNull( - currChargeDataPath?.toAbsolutePath()?.toString(), - currDischargeDataPath?.toAbsolutePath()?.toString() - ) - val filesToSync = writer.listStableHistoryFiles() var sentCount = 0 - PfdFileSender.sendFiles( + PfdFileSender.sendFile( writeEnd, - shellPowerDataDir, - filesToSync + shellPowerDataDir ) { file -> sentCount += 1 LoggerX.d(tag, "@sendFileCallback: 文件已发送, file=${file.name}") - val logicalName = RecordFileNames.logicalNameOrNull(file.name) - val candidates = buildList { - add(file) - if (logicalName != null) { - add(File(file.parentFile, logicalName)) - add(File(file.parentFile, "$logicalName.gz")) - } - }.distinctBy { it.absolutePath } - candidates.forEach { candidate -> - val candidatePath = candidate.toPath().toAbsolutePath().toString() - if (candidatePath !in protectedPaths) { - candidate.delete() - } - } + if ((currChargeDataPath == null || !Files.isSameFile( + file.toPath(), + currChargeDataPath + )) && + (currDischargeDataPath == null || !Files.isSameFile( + file.toPath(), + currDischargeDataPath + )) + ) file.delete() } - LoggerX.i(tag, "sync: 同步完成, selected=${filesToSync.size} sentCount=$sentCount") + LoggerX.i(tag, "sync: 同步完成, sentCount=$sentCount") } catch (e: Exception) { LoggerX.e(tag, "sync: 后台同步失败", tr = e) try { @@ -407,7 +394,6 @@ class Server internal constructor() : IService.Stub() { val appInfo = getAppInfo(Constants.APP_PACKAGE_NAME) Global.appSourceDir = appInfo.sourceDir Global.appUid = appInfo.uid - appDataDir = File(appInfo.dataDir) appConfigFile = File("${appInfo.dataDir}/shared_prefs/${SettingsConstants.PREFS_NAME}.xml") appPowerDataDir = File("${appInfo.dataDir}/${Constants.APP_POWER_DATA_PATH}") @@ -417,7 +403,6 @@ class Server internal constructor() : IService.Stub() { val sampler = if (SysfsSampler.init(appInfo)) SysfsSampler else DumpsysSampler() LoggerX.i(tag, "init: 采样器选择完成, sampler=${sampler::class.java.simpleName}") - shellDataDir = File(Constants.SHELL_DATA_DIR_PATH) shellPowerDataDir = File("${Constants.SHELL_DATA_DIR_PATH}/${Constants.SHELL_POWER_DATA_PATH}") diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt index 59ccc660..1b9a2ee4 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/writer/PowerRecordWriter.kt @@ -5,7 +5,6 @@ import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Charging import yangfentuozi.batteryrecorder.shared.data.BatteryStatus.Discharging import yangfentuozi.batteryrecorder.shared.data.LineRecord -import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.util.Handlers import yangfentuozi.batteryrecorder.shared.util.LoggerX @@ -120,9 +119,6 @@ class PowerRecordWriter( dischargeDataWriter.flushBufferBlocking() } - fun listStableHistoryFiles(): List = - RecordFileNames.listStableFiles(chargeDir) + RecordFileNames.listStableFiles(dischargeDir) - inner class ChargeDataWriter(dir: File, statusData: ChildWriterStatusData?) : BaseDelayedRecordWriter(dir, statusData) { override fun needStartNewSegment(justChangedStatus: Boolean, nowTime: Long): Boolean { // case1 记录超过最大分段时间(0 表示不按时间分段) @@ -317,16 +313,15 @@ class PowerRecordWriter( fun closeCurrentSegment() { flushBuffer() if (writer != null) { - val closedFile = segmentFile try { writer!!.close() } catch (e: IOException) { LoggerX.e(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 关闭分段文件失败", tr = e) } writer = null - if (closedFile != null && needDeleteSegment(System.currentTimeMillis())) { - LoggerX.v(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 删除短分段, file=${closedFile.name}") - closedFile.delete() + if (needDeleteSegment(System.currentTimeMillis())) { + LoggerX.v(this@BaseDelayedRecordWriter.tag, "closeCurrentSegment: 删除短分段, file=${segmentFile?.name}") + segmentFile!!.delete() } segmentFile = null } From 9eaddf34dedeef2e8b57dd03ad5a3c7ebaedfbf8 Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 22:32:22 +0800 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20`RecordFil?= =?UTF-8?q?eIO`=20=E5=B0=81=E8=A3=85=EF=BC=8C=E5=B0=86=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E4=B8=8E=E5=AF=BC=E5=87=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=86=85=E8=81=9A=E5=88=B0=E7=9B=B8=E5=85=B3=E7=B1=BB=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了 `RecordFileIO.kt` 文件。 - 将文件读取与 `GZIP` 解压逻辑直接实现在 `RecordFileParser` 中。 - 在 `HistoryRepository` 中重构了 `copyRecordAsPlainText` 导出逻辑并统一了缓冲区大小常量。 - 优化了历史记录导出与压缩过程中的流处理。 --- .../data/history/HistoryRepository.kt | 33 ++++++++--- .../shared/data/RecordFileIO.kt | 57 ------------------- .../shared/data/RecordFileParser.kt | 18 +++++- 3 files changed, 42 insertions(+), 66 deletions(-) delete mode 100644 shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt index b9d379e6..5fe6abd1 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt @@ -7,18 +7,20 @@ import yangfentuozi.batteryrecorder.ipc.Service import yangfentuozi.batteryrecorder.shared.Constants import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.LineRecord -import yangfentuozi.batteryrecorder.shared.data.RecordFileIO import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.RecordFileParser import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.data.RecordsStats import yangfentuozi.batteryrecorder.shared.util.LoggerX +import java.io.BufferedInputStream import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.util.zip.ZipInputStream +import java.io.OutputStream import java.util.zip.ZipEntry import java.util.zip.GZIPOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream private const val TAG = "HistoryRepository" @@ -85,7 +87,7 @@ private data class RecordCleanupTarget( object HistoryRepository { private const val NOT_ENOUGH_VALID_SAMPLES_PREFIX = "Not enough valid samples after filtering:" - private const val RECORD_COMPRESSION_BUFFER_SIZE = 64 * 1024 + private const val RECORD_FILE_BUFFER_SIZE = 64 * 1024 private val CLEANUP_TARGET_TYPES = listOf(BatteryStatus.Charging, BatteryStatus.Discharging) // 逻辑记录名固定为“起始时间戳.txt”,物理文件允许是 `.txt` 或 `.txt.gz`。 @@ -507,7 +509,7 @@ object HistoryRepository { ?: throw IOException("Failed to open destination: $destinationUri") outputStream.use { output -> - RecordFileIO.copyAsPlainText(sourceFile, output) + copyRecordAsPlainText(sourceFile, output) } LoggerX.i(TAG, "[历史] 导出记录成功: source=${recordsFile.name} destination=$destinationUri") } @@ -537,7 +539,7 @@ object HistoryRepository { "[历史] 写入导出 ZIP 条目: file=${recordsFile.name} source=${sourceFile.name} size=${sourceFile.length()} destination=$destinationUri" ) zipOutput.putNextEntry(ZipEntry(recordsFile.name)) - RecordFileIO.copyAsPlainText(sourceFile, zipOutput) + copyRecordAsPlainText(sourceFile, zipOutput) zipOutput.closeEntry() LoggerX.d( TAG, @@ -779,11 +781,11 @@ object HistoryRepository { } try { - sourceFile.inputStream().buffered(RECORD_COMPRESSION_BUFFER_SIZE).use { input -> + sourceFile.inputStream().buffered(RECORD_FILE_BUFFER_SIZE).use { input -> GZIPOutputStream( - tempFile.outputStream().buffered(RECORD_COMPRESSION_BUFFER_SIZE) + tempFile.outputStream().buffered(RECORD_FILE_BUFFER_SIZE) ).use { output -> - input.copyTo(output, RECORD_COMPRESSION_BUFFER_SIZE) + input.copyTo(output, RECORD_FILE_BUFFER_SIZE) } } if (!tempFile.renameTo(gzipFile)) { @@ -802,4 +804,19 @@ object HistoryRepository { throw error } } + + private fun copyRecordAsPlainText( + file: File, + output: OutputStream + ) { + val rawInput = BufferedInputStream(file.inputStream(), RECORD_FILE_BUFFER_SIZE) + val input = if (RecordFileNames.isCompressedFileName(file.name)) { + GZIPInputStream(rawInput, RECORD_FILE_BUFFER_SIZE) + } else { + rawInput + } + input.use { stream -> + stream.copyTo(output, RECORD_FILE_BUFFER_SIZE) + } + } } diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt deleted file mode 100644 index 92f20b87..00000000 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileIO.kt +++ /dev/null @@ -1,57 +0,0 @@ -package yangfentuozi.batteryrecorder.shared.data - -import java.io.BufferedInputStream -import java.io.BufferedReader -import java.io.File -import java.io.FileInputStream -import java.io.InputStream -import java.io.InputStreamReader -import java.io.OutputStream -import java.util.zip.GZIPInputStream - -/** - * 统一封装记录文件读取入口,避免上层分散判断 plain / gzip。 - */ -object RecordFileIO { - private const val BUFFER_SIZE = 64 * 1024 - - /** - * 打开记录文件输入流。 - * - * @param file 记录文件,支持 `.txt` 与 `.txt.gz`。 - * @return 返回按文件格式适配后的输入流。 - */ - fun openInputStream(file: File): InputStream { - val rawInput = BufferedInputStream(FileInputStream(file), BUFFER_SIZE) - return if (RecordFileNames.isCompressedFileName(file.name)) { - GZIPInputStream(rawInput, BUFFER_SIZE) - } else { - rawInput - } - } - - /** - * 打开统一的文本读取器。 - * - * @param file 记录文件,支持 `.txt` 与 `.txt.gz`。 - * @return 返回 UTF-8 文本读取器。 - */ - fun openBufferedReader(file: File): BufferedReader = - BufferedReader(InputStreamReader(openInputStream(file), Charsets.UTF_8), BUFFER_SIZE) - - /** - * 以明文文本语义导出记录内容。 - * - * @param file 源记录文件。 - * @param output 导出目标输出流。 - * @return 无。 - */ - fun copyAsPlainText( - file: File, - output: OutputStream - ) { - openInputStream(file).use { input -> - input.copyTo(output, BUFFER_SIZE) - } - } -} diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt index 3a7cbc14..dd47c325 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/data/RecordFileParser.kt @@ -1,10 +1,16 @@ package yangfentuozi.batteryrecorder.shared.data import yangfentuozi.batteryrecorder.shared.util.LoggerX +import java.io.BufferedInputStream +import java.io.BufferedReader import java.io.File +import java.io.FileInputStream +import java.io.InputStreamReader +import java.util.zip.GZIPInputStream object RecordFileParser { private const val TAG = "RecordFileParser" + private const val BUFFER_SIZE = 64 * 1024 fun parseToList(file: File): List { val records = mutableListOf() @@ -31,7 +37,7 @@ object RecordFileParser { var lineNumber = 0 var previousParsedTimestamp: Long? = null - RecordFileIO.openBufferedReader(file).useLines { lines -> + openBufferedReader(file).useLines { lines -> lines.forEach { raw -> lineNumber++ val line = raw.trim() @@ -92,4 +98,14 @@ object RecordFileParser { "logInvalidLine: 跳过损坏记录, file=${file.absolutePath} line=$lineNumber reason=$reason" ) } + + private fun openBufferedReader(file: File): BufferedReader { + val rawInput = BufferedInputStream(FileInputStream(file), BUFFER_SIZE) + val input = if (RecordFileNames.isCompressedFileName(file.name)) { + GZIPInputStream(rawInput, BUFFER_SIZE) + } else { + rawInput + } + return BufferedReader(InputStreamReader(input, Charsets.UTF_8), BUFFER_SIZE) + } } From d153e98c7b7bb3a6e8431bc535af25d1e1c2965c Mon Sep 17 00:00:00 2001 From: ItosEO Date: Fri, 17 Apr 2026 22:47:48 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E6=89=AB=E6=8F=8F=E4=B8=8E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 `DischargeRecordScanner` 中对压缩文件不必要的反向读取逻辑。 - 在 `SyncUtil` 同步过程中增加本地历史功耗统计缓存的预热机制,优化加载性能。 - 重构 `SyncUtil` 中的文件状态处理逻辑,增强健壮性。 - 移除 `PfdFileSender` 中未使用的 `sendFiles` 方法。 --- .../data/history/DischargeRecordScanner.kt | 8 ----- .../batteryrecorder/data/history/SyncUtil.kt | 27 ++++++++++++++- .../shared/sync/PfdFileSender.kt | 34 ------------------- 3 files changed, 26 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt index 82e06495..2d09cbef 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/DischargeRecordScanner.kt @@ -5,7 +5,6 @@ import yangfentuozi.batteryrecorder.BuildConfig import yangfentuozi.batteryrecorder.shared.config.SettingsConstants import yangfentuozi.batteryrecorder.shared.config.dataclass.StatisticsSettings import yangfentuozi.batteryrecorder.shared.data.BatteryStatus -import yangfentuozi.batteryrecorder.shared.data.RecordFileNames import yangfentuozi.batteryrecorder.shared.data.LineRecord import yangfentuozi.batteryrecorder.shared.data.RecordFileParser import yangfentuozi.batteryrecorder.shared.util.LoggerX @@ -345,13 +344,6 @@ object DischargeRecordScanner { * 从文件尾部反向读取最后一条有效记录时间戳,避免整文件扫描两遍。 */ private fun findFileEndTimestamp(file: File): Long? { - if (RecordFileNames.isCompressedFileName(file.name)) { - var lastTimestamp: Long? = null - RecordFileParser.forEachValidRecord(file) { record -> - lastTimestamp = record.timestamp - } - return lastTimestamp - } val length = file.length() if (length <= 0L) return null diff --git a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt index 042073e2..507016c7 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt @@ -3,7 +3,9 @@ package yangfentuozi.batteryrecorder.data.history import android.content.Context import yangfentuozi.batteryrecorder.ipc.Service import yangfentuozi.batteryrecorder.shared.Constants +import yangfentuozi.batteryrecorder.shared.data.BatteryStatus import yangfentuozi.batteryrecorder.shared.data.RecordFileNames +import yangfentuozi.batteryrecorder.shared.data.RecordsFile import yangfentuozi.batteryrecorder.shared.data.RecordsStats import yangfentuozi.batteryrecorder.shared.sync.PfdFileReceiver import yangfentuozi.batteryrecorder.shared.util.LoggerX @@ -39,15 +41,23 @@ object SyncUtil { .onFailure { error -> LoggerX.e(TAG, "[SYNC] 读取服务端当前记录失败", tr = error) } + val activeRecordsFile = if (activeRecordsFileResult.isSuccess) { + activeRecordsFileResult.getOrNull() + } else { + null + } val compressedCount = if (activeRecordsFileResult.isSuccess) { HistoryRepository.compressHistoricalRecords( context = context, - activeRecordsFile = activeRecordsFileResult.getOrNull() + activeRecordsFile = activeRecordsFile ) } else { LoggerX.w(TAG, "[SYNC] 当前记录未知,跳过本地历史压缩") 0 } + if (readPfd == null && compressedCount > 0) { + preheatLocalHistoricalPowerStatsCaches(context, activeRecordsFile) + } syncedTargets.values.forEach { (parentDir, logicalName) -> preheatPowerStatsCache(context, parentDir, logicalName) } @@ -61,6 +71,21 @@ object SyncUtil { } } + private fun preheatLocalHistoricalPowerStatsCaches( + context: Context, + activeRecordsFile: RecordsFile? + ) { + val activeLogicalName = activeRecordsFile?.name + listOf(BatteryStatus.Charging, BatteryStatus.Discharging).forEach { type -> + HistoryRepository.listRecordFiles(context, type).forEach { file -> + val logicalName = RecordFileNames.logicalNameOrNull(file.name) ?: return@forEach + if (logicalName == activeLogicalName) return@forEach + val parentDir = file.parentFile ?: return@forEach + preheatPowerStatsCache(context, parentDir, logicalName) + } + } + } + private fun preheatPowerStatsCache( context: Context, parentDir: File, diff --git a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt index b7e8f3fd..9c8e1ddb 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt @@ -14,40 +14,6 @@ import java.nio.file.Path object PfdFileSender { private const val TAG = "PfdFileSender" - /** - * 发送指定文件列表。 - * - * @param writePfd 写端管道。 - * @param baseDir 作为相对路径基准的根目录。 - * @param files 待发送文件列表。 - * @param callback 单个文件发送完成后的回调。 - * @return 无。 - */ - fun sendFiles( - writePfd: ParcelFileDescriptor, - baseDir: File, - files: List, - callback: ((File) -> Unit)? = null - ) { - val basePath = baseDir.toPath() - LoggerX.i(TAG, "sendFiles: 开始发送文件列表, base=${baseDir.absolutePath} count=${files.size}") - var sentCount = 0 - var sentBytes = 0L - ParcelFileDescriptor.AutoCloseOutputStream(writePfd).use { raw -> - BufferedOutputStream(raw, SyncConstants.BUF_SIZE).use { out -> - files.forEach { file -> - sendSingleFile(out, file, basePath, callback) { size -> - sentCount += 1 - sentBytes += size - } - } - out.write(SyncConstants.CODE_FINISHED) - out.flush() - } - } - LoggerX.i(TAG, "sendFiles: 文件发送完成, count=$sentCount bytes=$sentBytes") - } - fun sendFile( writePfd: ParcelFileDescriptor, file: File,