diff --git a/AGENTS.md b/AGENTS.md index 0ea001e..94a6aee 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`;Server 侧已关闭历史分段继续保持明文,App 会在 `sync()` 编排入口压缩本地非活跃历史为 `时间戳.txt.gz` +- 记录的逻辑名称仍固定为 `时间戳.txt`;历史列表、导航、删除、缓存与导出都按逻辑名称工作,物理层再解析到 `.txt` / `.txt.gz` ### 数据同步链路 - Server 以 shell 权限运行时,记录文件落在 `com.android.shell` 数据目录 - App 通过 `sync()` AIDL 拿到 `ParcelFileDescriptor` - 传输协议由 `PfdFileSender` / `PfdFileReceiver` / `SyncConstants` 实现 -- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理 +- `sync()` 当前会先读取当前活跃记录,再按需从 shell 侧拉取记录文件;不论 root 还是 shell,都会在 App 侧压缩本地非活跃历史并在最后预热本地 `power_stats` 缓存 +- 同步结束后,已传输的 shell 侧旧文件会按当前文件排除规则清理;App 侧压缩本地历史时通过 `.tmp` 临时文件收尾,目录枚举继续忽略临时文件 ### 首页统计与预测链路 @@ -241,11 +244,10 @@ 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/` -## 目录索引 - -### 根模块 +## 根模块 ```text app/ @@ -255,221 +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 - │ ├── 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` | ## 架构约定 @@ -545,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/data/history/HistoryCacheNaming.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryCacheNaming.kt index 8ad7382..ec9381a 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 410ccd4..1f5068f 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 30fcfe4..5fe6abd 100644 --- a/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt +++ b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/HistoryRepository.kt @@ -7,15 +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.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" @@ -82,28 +87,27 @@ private data class RecordCleanupTarget( object HistoryRepository { private const val NOT_ENOUGH_VALID_SAMPLES_PREFIX = "Not enough valid samples after filtering:" + private const val RECORD_FILE_BUFFER_SIZE = 64 * 1024 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 +123,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 +149,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 +190,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 +245,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 +329,46 @@ object HistoryRepository { /** 删除记录及其缓存文件 */ fun deleteRecord(context: Context, recordsFile: RecordsFile): Boolean { + val sourceFile = recordsFile.toFile(context) ?: return false.also { + LoggerX.w(TAG, "[历史] 删除记录失败,文件不存在: ${recordsFile.name}") + } + return deleteRecordFile(context, sourceFile, recordsFile.type) + } - if (runCatching { recordsFile.toFile(context)!!.delete() }.getOrDefault(false)) { - // 同步删除缓存文件 - runCatching { getPowerStatsCacheFile(context.cacheDir, recordsFile.name).delete() } - LoggerX.i(TAG, "[历史] 删除记录成功: ${recordsFile.name}") - return true + /** + * 压缩本地非活跃历史记录。 + * + * @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.w(TAG, "[历史] 删除记录失败: ${recordsFile.name}") - return false + + LoggerX.i( + TAG, + "[历史] 本地历史压缩完成: compressed=$compressedCount active=${activeLogicalName ?: "null"}" + ) + return compressedCount } /** @@ -469,10 +508,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 -> + copyRecordAsPlainText(sourceFile, output) } LoggerX.i(TAG, "[历史] 导出记录成功: source=${recordsFile.name} destination=$destinationUri") } @@ -499,16 +536,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)) + copyRecordAsPlainText(sourceFile, zipOutput) zipOutput.closeEntry() LoggerX.d( TAG, - "[历史] 导出 ZIP 条目写入完成: file=${sourceFile.name} destination=$destinationUri" + "[历史] 导出 ZIP 条目写入完成: file=${recordsFile.name} destination=$destinationUri" ) } } @@ -564,7 +599,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 +628,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 +663,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 +680,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 +722,101 @@ 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 + } + + 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_FILE_BUFFER_SIZE).use { input -> + GZIPOutputStream( + tempFile.outputStream().buffered(RECORD_FILE_BUFFER_SIZE) + ).use { output -> + input.copyTo(output, RECORD_FILE_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 + } + } + + 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/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt b/app/src/main/java/yangfentuozi/batteryrecorder/data/history/SyncUtil.kt index cffaf57..507016c 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,10 @@ 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 import java.io.File @@ -15,19 +19,88 @@ 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) - LoggerX.i(TAG, "[SYNC] 客户端接收完成: ${outDir.absolutePath}") + 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 activeRecordsFile = if (activeRecordsFileResult.isSuccess) { + activeRecordsFileResult.getOrNull() + } else { + null + } + val compressedCount = if (activeRecordsFileResult.isSuccess) { + HistoryRepository.compressHistoricalRecords( + context = context, + 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) + } + 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 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, + 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/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt b/app/src/main/java/yangfentuozi/batteryrecorder/ui/viewmodel/HistoryViewModel.kt index 3c0ab14..9837f09 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? { diff --git a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt index 2d540d0..368ed24 100644 --- a/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt +++ b/server/src/main/java/yangfentuozi/batteryrecorder/server/Server.kt @@ -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() { @@ -396,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}") @@ -406,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/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 0000000..09b2a9c --- /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 a3fb538..dd47c32 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 - file.bufferedReader().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) + } } 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 771a6e7..4d10f69 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 a96c524..9c8e1dd 100644 --- a/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt +++ b/shared/src/main/java/yangfentuozi/batteryrecorder/shared/sync/PfdFileSender.kt @@ -37,6 +37,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 +85,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) } } }