Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 71 additions & 217 deletions AGENTS.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, Pair<File, String>>()

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

/**
* 加载历史列表。
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

/**
Expand Down Expand Up @@ -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<RecordsFile>? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}")

Expand All @@ -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}")

Expand Down
Original file line number Diff line number Diff line change
@@ -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<File> {
val selected = LinkedHashMap<Long, Pair<File, Boolean>>()
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 }
}
}
Original file line number Diff line number Diff line change
@@ -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<LineRecord> {
val records = mutableListOf<LineRecord>()
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
}
Loading
Loading