| app/src/main/java/com/safeluck/floatwindow/manager/AndroidCameraRecordManager.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| app/src/main/java/com/safeluck/floatwindow/manager/UsbCameraRecordManager.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| app/src/main/java/com/safeluck/floatwindow/util/FileUtil.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| app/src/main/java/com/safeluck/floatwindow/util/VideoFileUtils.java | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| readMe.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
app/src/main/java/com/safeluck/floatwindow/manager/AndroidCameraRecordManager.java
@@ -22,6 +22,7 @@ import com.safeluck.floatwindow.MediaArgu; import com.safeluck.floatwindow.ResponseVO; import com.safeluck.floatwindow.util.VideoFileUtils; import com.safeluck.floatwindow.util.FileUtil; import timber.log.Timber; @@ -266,12 +267,15 @@ // 停止当前录像 stopCurrentRecording(); // 通知文件创建 // 通知文件创建(当前这段 1 分钟文件) if (currentVideoFile != null) { notifyCallback(2, 0, currentVideoFile.getName()); } // 开始新的录像 // 每次写入新文件前,检查并清理存储空间(AnYun_VIDEO 下的 mp4) ensureStorageSpaceForMp4(); // 开始新的录像(创建下一分钟的新文件) startRecording(); // 继续定时 @@ -280,6 +284,41 @@ } }, RECORD_INTERVAL_MS); } /** * 确保存储空间足够(针对 AnYun_VIDEO 下的 mp4) * TF 卡:使用 FileUtil.cleanupH264Files(内部已改为清理 mp4)按日期目录删除最早的视频 * 内部 Flash:使用 FileUtil.ensureInternalFlashSpaceForH264(内部已改为清理 mp4) */ private void ensureStorageSpaceForMp4() { if (context == null || mediaArgu == null) { return; } try { int tfFlag = mediaArgu.getTfCardFlag(); // 0-内部存储,1-TF 卡 // 先定位当前使用的日期目录,再取其父目录 AnYun_VIDEO 作为根目录 File dateDir = VideoFileUtils.getVideoDirectory(context, tfFlag); if (dateDir == null) { return; } File rootDir = dateDir.getParentFile(); // .../AnYun_VIDEO if (rootDir == null) { return; } String rootPath = rootDir.getAbsolutePath(); if (tfFlag == 1) { // TF 卡:限制总大小 + 剩余空间 FileUtil.cleanupH264Files(context, rootPath); } else { // 内部 Flash:确保剩余空间 ≥ 800MB FileUtil.ensureInternalFlashSpaceForH264(context); } } catch (Exception e) { Timber.e(e, "ensureStorageSpaceForMp4 error"); } } /** * 停止当前录像 app/src/main/java/com/safeluck/floatwindow/manager/UsbCameraRecordManager.java
@@ -6,6 +6,7 @@ import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; import android.media.MediaMetadataRetriever; import android.media.MediaMuxer; import android.media.MediaRecorder; import android.text.TextUtils; @@ -16,6 +17,7 @@ import com.safeluck.floatwindow.MediaArgu; import com.safeluck.floatwindow.ResponseVO; import com.safeluck.floatwindow.util.GlobalData; import com.safeluck.floatwindow.util.FileUtil; import com.safeluck.floatwindow.util.VideoFileUtils; import timber.log.Timber; @@ -83,6 +85,9 @@ // 录制开始时间(纳秒),用于时间戳同步 private volatile long recordingStartTimeNs = 0; // 刚完成的文件(用于重命名) private File completedVideoFile; /** * 录像回调接口 @@ -274,6 +279,13 @@ stopRecordThread(); releaseResources(); // 重命名刚完成的文件(停止录像时) if (completedVideoFile != null) { renameCompletedFile(completedVideoFile); completedVideoFile = null; } if (usbCamera != null) { usbCamera.stopCamera(); } @@ -336,7 +348,7 @@ // 创建视频编码器 MediaFormat videoFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height); videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible); videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 3); // 码率 videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 5); // 码率 videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE); videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL); @@ -457,6 +469,11 @@ mediaMuxer = null; } // 保存刚完成的文件路径,用于后续重命名 if (currentVideoFile != null && currentVideoFile.exists()) { completedVideoFile = currentVideoFile; } muxerStarted = false; videoTrackIndex = -1; audioTrackIndex = -1; @@ -556,8 +573,17 @@ // 释放当前资源 releaseResources(); // 初始化新的编码器和Muxer // 重命名刚完成的文件 if (completedVideoFile != null) { renameCompletedFile(completedVideoFile); completedVideoFile = null; } // 每次写入新文件前,检查并清理存储空间(AnYun_VIDEO 下的 mp4) ensureStorageSpaceForMp4(); // 初始化新的编码器和Muxer(创建下一分钟的新文件) if (!initEncoderAndMuxer()) { Timber.e("Failed to create new video file"); break; @@ -625,6 +651,13 @@ } releaseResources(); // 重命名刚完成的文件(停止录像时) if (completedVideoFile != null) { renameCompletedFile(completedVideoFile); completedVideoFile = null; } Timber.d("RecordThread ended"); } } @@ -699,6 +732,126 @@ } /** * 重命名完成的视频文件 * 格式:HHmmss_学员名_1或2_时长秒.mp4 * 例如:132541_学员C_1_58.mp4 */ private void renameCompletedFile(File originalFile) { if (originalFile == null || !originalFile.exists()) { Timber.w("原始文件不存在,无法重命名: %s", originalFile); return; } try { // 1. 从原文件名提取时分秒(例如:132541_P1.mp4 -> 132541) String originalName = originalFile.getName(); String timePart = originalName; // 移除 .mp4 扩展名 if (originalName.endsWith(".mp4")) { timePart = originalName.substring(0, originalName.length() - 4); } // 移除 _P1 或 _P2 后缀 if (timePart.endsWith("_P1") || timePart.endsWith("_P2")) { timePart = timePart.substring(0, timePart.length() - 3); } // 2. 获取学员名字 String studentName = GlobalData.getInstance().parseWaterMaskInfo("student", "无", GlobalData.ShareType.STRING); if (TextUtils.isEmpty(studentName) || "无".equals(studentName)) { studentName = "学员"; } // 3. 获取 P1/P2(1 对应 P1,2 对应 P2) int cameraId = (mediaArgu != null) ? mediaArgu.getUsbCameraId() : 1; String cameraIdStr = String.valueOf(cameraId); // 4. 获取视频时长(秒) int durationSeconds = getVideoDuration(originalFile); // 5. 构建新文件名:HHmmss_学员名_1或2_时长秒.mp4 String newFileName = String.format("%s_%s_%s_%d.mp4", timePart, studentName, cameraIdStr, durationSeconds); File newFile = new File(originalFile.getParent(), newFileName); // 6. 重命名文件 if (originalFile.renameTo(newFile)) { Timber.d("文件重命名成功: %s -> %s", originalFile.getName(), newFileName); // 更新回调中的文件名 notifyCallback(2,0,newFileName); } else { Timber.e("文件重命名失败: %s -> %s", originalFile.getName(), newFileName); } } catch (Exception e) { Timber.e(e, "重命名文件时发生异常: %s", originalFile.getName()); } } /** * 获取视频文件时长(秒) */ private int getVideoDuration(File videoFile) { MediaMetadataRetriever retriever = null; try { retriever = new MediaMetadataRetriever(); retriever.setDataSource(videoFile.getAbsolutePath()); String durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); if (durationStr != null && !durationStr.isEmpty()) { long durationMs = Long.parseLong(durationStr); int durationSeconds = (int) (durationMs / 1000); Timber.d("视频时长: %d 秒", durationSeconds); return durationSeconds; } } catch (Exception e) { Timber.e(e, "获取视频时长失败,使用默认值60秒"); } finally { if (retriever != null) { try { retriever.release(); } catch (Exception e) { Timber.e(e, "释放 MediaMetadataRetriever 失败"); } } } // 如果获取失败,返回默认值60秒 return 60; } /** * 确保存储空间足够(针对 AnYun_VIDEO 下的 mp4) * TF 卡:使用 FileUtil.cleanupH264Files(内部已改为清理 mp4)按日期目录删除最早的视频 * 内部 Flash:使用 FileUtil.ensureInternalFlashSpaceForH264(内部已改为清理 mp4) */ private void ensureStorageSpaceForMp4() { if (context == null || mediaArgu == null) { return; } try { int tfFlag = mediaArgu.getTfCardFlag(); // 0-内部存储,1-TF 卡 // 先定位当前使用的日期目录,再取其父目录 AnYun_VIDEO 作为根目录 File dateDir = VideoFileUtils.getVideoDirectory(context, tfFlag); if (dateDir == null) { return; } File rootDir = dateDir.getParentFile(); // .../AnYun_VIDEO if (rootDir == null) { return; } String rootPath = rootDir.getAbsolutePath(); if (tfFlag == 1) { // TF 卡:限制总大小 + 剩余空间 FileUtil.cleanupH264Files(context, rootPath); } else { // 内部 Flash:确保剩余空间 ≥ 800MB FileUtil.ensureInternalFlashSpaceForH264(context); } } catch (Exception e) { Timber.e(e, "ensureStorageSpaceForMp4 error"); } } /** * 检查并启动Muxer * P1模式:当视频和音频轨道都准备好时启动 * P2模式:当视频轨道准备好时即可启动(无音频轨道) app/src/main/java/com/safeluck/floatwindow/util/FileUtil.java
@@ -58,23 +58,24 @@ } /** * 清理 TF 卡上的 h264 文件 * 当 h264 文件总大小超过指定阈值或 TF 卡剩余空间小于指定值时,删除日期最早的文件夹 * * @param context Context 对象,用于获取 TF 卡路径和剩余空间 * @param h264RootDir h264 根目录路径,例如 "/sdcard/h264" * 清理 TF 卡上的 MP4 文件(例如 AnYun_VIDEO 目录) * 当 MP4 文件总大小超过指定阈值或 TF 卡剩余空间小于指定值时,删除日期最早的文件夹 * * @param context Context 对象,用于获取 TF 卡路径和剩余空间 * @param mp4RootDir MP4 根目录路径,例如 "/storage/XXXX-XXXX/AnYun_VIDEO" * @param maxTotalSizeGB 最大总大小(GB),默认 5GB * @param minFreeSpaceGB 最小剩余空间(GB),默认 1GB */ public static void cleanupH264Files(Context context, String h264RootDir, long maxTotalSizeGB, long minFreeSpaceGB) { if (context == null || h264RootDir == null || h264RootDir.trim().isEmpty()) { Timber.w("Context or h264 root directory is null, skip cleanup"); public static void cleanupH264Files(Context context, String mp4RootDir, long maxTotalSizeGB, long minFreeSpaceGB) { // 注意:为了兼容旧代码,方法名仍然叫 cleanupH264Files,但已经改为清理 MP4 文件 if (context == null || mp4RootDir == null || mp4RootDir.trim().isEmpty()) { Timber.w("Context or mp4 root directory is null, skip cleanup"); return; } File h264Root = new File(h264RootDir); if (!h264Root.exists() || !h264Root.isDirectory()) { Timber.d("H264 root directory does not exist: %s, skip cleanup", h264RootDir); File mp4Root = new File(mp4RootDir); if (!mp4Root.exists() || !mp4Root.isDirectory()) { Timber.d("MP4 root directory does not exist: %s, skip cleanup", mp4RootDir); return; } @@ -91,23 +92,24 @@ Timber.d("TF card free space: %d GB", freeSpaceGB); // 扫描所有日期文件夹 File[] dateDirs = h264Root.listFiles(File::isDirectory); File[] dateDirs = mp4Root.listFiles(File::isDirectory); if (dateDirs == null || dateDirs.length == 0) { Timber.d("No date directories found in h264 root: %s", h264RootDir); Timber.d("No date directories found in mp4 root: %s", mp4RootDir); return; } // 按日期排序(最早的在前) List<DateDirInfo> dateDirList = new ArrayList<>(); SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd", Locale.CHINA); // AnYun_VIDEO 使用的是 yyyy_MM_dd 目录名,例如 2026_01_30 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd", Locale.CHINA); for (File dateDir : dateDirs) { String dirName = dateDir.getName(); // 只处理符合日期格式的文件夹(yyyyMMdd) if (dirName.length() == 8 && dirName.matches("\\d{8}")) { // 只处理符合日期格式的文件夹(yyyy_MM_dd,例如 2026_01_30) if (dirName.matches("\\d{4}_\\d{2}_\\d{2}")) { try { Date date = dateFormat.parse(dirName); long totalSize = calculateH264FilesSize(dateDir); long totalSize = calculateMp4FilesSize(dateDir); dateDirList.add(new DateDirInfo(dateDir, date, totalSize)); } catch (ParseException e) { Timber.w("Invalid date directory name: %s", dirName); @@ -128,7 +130,7 @@ for (DateDirInfo info : dateDirList) { totalSizeGB += info.totalSize / (1024L * 1024L * 1024L); } Timber.d("Total h264 files size: %d GB, Max allowed: %d GB", totalSizeGB, maxTotalSizeGB); Timber.d("Total mp4 files size: %d GB, Max allowed: %d GB", totalSizeGB, maxTotalSizeGB); Timber.d("TF card free space: %d GB, Min required: %d GB", freeSpaceGB, minFreeSpaceGB); // 检查是否需要清理 @@ -172,19 +174,20 @@ } /** * 清理 TF 卡上的 h264 文件(使用默认参数:最大5GB,最小剩余空间1GB) * 清理 TF 卡上的 MP4 文件(使用默认参数:最大5GB,最小剩余空间1GB) */ public static void cleanupH264Files(Context context, String h264RootDir) { cleanupH264Files(context, h264RootDir, 5, 1); public static void cleanupH264Files(Context context, String mp4RootDir) { // 为兼容旧调用保留方法名,内部已改为处理 MP4 cleanupH264Files(context, mp4RootDir, 35, 1); } /** * 检查内部 Flash(非 TF 卡)剩余空间,如果小于 800MB,则按时间顺序删除 h264_*.h264 文件 * 目录:context.getExternalFilesDir(null).getAbsolutePath() * 文件名格式示例:h264_1735023032000.h264、h264_camera2_1735023032000.h264 * 检查内部 Flash(非 TF 卡)剩余空间,如果小于 800MB,则按时间顺序删除 AnYun_VIDEO 下最早的 MP4 文件 * 目录结构示例:/sdcard/AnYun_VIDEO/yyMMdd/HHmmss_xxx.mp4 * * 删除规则: * - 按文件名中的时间戳从小到大(越早越先删)依次删除 * - 递归遍历 AnYun_VIDEO 目录,收集所有 .mp4 文件 * - 按 lastModified 时间从早到晚排序(越早越先删) * - 每删除一次后重新计算剩余空间,直到 ≥ 800MB 或文件删完 * * 返回值: @@ -197,13 +200,21 @@ return 0; } File externalDir = context.getExternalFilesDir(null); if (externalDir == null) { Timber.w("ensureInternalFlashSpaceForH264: external files dir is null"); // 内部存储根目录(与 VideoFileUtils 中保持一致) File externalRoot = android.os.Environment.getExternalStorageDirectory(); if (externalRoot == null) { Timber.w("ensureInternalFlashSpaceForH264: external storage dir is null"); return 0; } String basePath = externalDir.getAbsolutePath(); // AnYun_VIDEO 根目录 File anyunRoot = new File(externalRoot, "AnYun_VIDEO"); if (!anyunRoot.exists() || !anyunRoot.isDirectory()) { Timber.w("ensureInternalFlashSpaceForH264: AnYun_VIDEO dir not found -> %s", anyunRoot.getAbsolutePath()); return 0; } String basePath = anyunRoot.getAbsolutePath(); long minFreeBytes = 800L * 1024L * 1024L; // 800MB long freeBytes = getFreeSpaceBytes(basePath); @@ -214,85 +225,32 @@ return 0; } // 收集符合命名规则的 h264 文件 File[] files = externalDir.listFiles(); if (files == null || files.length == 0) { Timber.w("ensureInternalFlashSpaceForH264: no files in dir -> %s", basePath); // 收集所有 .mp4 文件 List<File> mp4Files = new ArrayList<>(); collectMp4Files(anyunRoot, mp4Files); if (mp4Files.isEmpty()) { Timber.w("ensureInternalFlashSpaceForH264: no mp4 files in dir -> %s", basePath); // 已经没有可删的文件,如果仍小于 800MB,则直接返回 -1 return freeBytes >= minFreeBytes ? 0 : -1; } class H264FileInfo { File file; long timestamp; H264FileInfo(File file, long timestamp) { this.file = file; this.timestamp = timestamp; } } List<H264FileInfo> h264Files = new ArrayList<>(); for (File file : files) { if (!file.isFile()) { continue; } String name = file.getName(); // 只处理 .h264 结尾,且以 h264_ 开头的文件 if (!name.toLowerCase(Locale.CHINA).endsWith(".h264")) { continue; } if (!name.startsWith("h264_") && !name.startsWith("h264_camera2_")) { continue; } // 提取时间戳部分 String timePart = null; if (name.startsWith("h264_camera2_")) { // 前缀长度 13:"h264_camera2_" timePart = name.substring("h264_camera2_".length(), name.length() - ".h264".length()); } else if (name.startsWith("h264_")) { // 前缀长度 5:"h264_" timePart = name.substring("h264_".length(), name.length() - ".h264".length()); } if (timePart == null || timePart.isEmpty()) { continue; } try { long ts = Long.parseLong(timePart); h264Files.add(new H264FileInfo(file, ts)); } catch (NumberFormatException e) { // 文件名不符合时间戳格式,跳过 Timber.w("ensureInternalFlashSpaceForH264: invalid timestamp in file name -> %s", name); } } if (h264Files.isEmpty()) { Timber.w("ensureInternalFlashSpaceForH264: no matched h264 files in -> %s", basePath); return freeBytes >= minFreeBytes ? 0 : -1; } // 按时间戳升序排列(越早的越先删) Collections.sort(h264Files, new Comparator<H264FileInfo>() { // 按 lastModified 升序排列(越早的越先删) Collections.sort(mp4Files, new Comparator<File>() { @Override public int compare(H264FileInfo o1, H264FileInfo o2) { return Long.compare(o1.timestamp, o2.timestamp); public int compare(File o1, File o2) { return Long.compare(o1.lastModified(), o2.lastModified()); } }); int deletedCount = 0; for (H264FileInfo info : h264Files) { for (File f : mp4Files) { if (freeBytes >= minFreeBytes) { break; } File f = info.file; long size = f.length(); Timber.d("ensureInternalFlashSpaceForH264: deleting file -> %s, size=%d, ts=%d", f.getAbsolutePath(), size, info.timestamp); Timber.d("ensureInternalFlashSpaceForH264: deleting mp4 file -> %s, size=%d", f.getAbsolutePath(), size); if (f.delete()) { deletedCount++; @@ -310,14 +268,14 @@ } /** * 计算目录下所有 .h264 文件的总大小(字节) * 计算目录下所有 .mp4 文件的总大小(字节) */ private static long calculateH264FilesSize(File dir) { private static long calculateMp4FilesSize(File dir) { long totalSize = 0; File[] files = dir.listFiles(); if (files != null) { for (File file : files) { if (file.isFile() && file.getName().toLowerCase().endsWith(".h264")) { if (file.isFile() && file.getName().toLowerCase().endsWith(".mp4")) { totalSize += file.length(); } } @@ -342,6 +300,28 @@ } /** * 递归收集目录下所有 .mp4 文件 */ private static void collectMp4Files(File dir, List<File> outList) { if (dir == null || !dir.exists()) { return; } File[] files = dir.listFiles(); if (files == null) { return; } for (File f : files) { if (f.isDirectory()) { collectMp4Files(f, outList); } else if (f.isFile() && f.getName().toLowerCase(Locale.CHINA).endsWith(".mp4")) { outList.add(f); } } } /** * 获取指定路径的剩余空间(字节) */ private static long getFreeSpaceBytes(String path) { app/src/main/java/com/safeluck/floatwindow/util/VideoFileUtils.java
@@ -49,8 +49,8 @@ } } // 创建年月日目录(例如:260126) SimpleDateFormat dateFormat = new SimpleDateFormat("yyMMdd", Locale.getDefault()); // 创建年月日目录(例如:2026_01_30) SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd", Locale.getDefault()); String dateDirName = dateFormat.format(new Date()); File dateDir = new File(videoDir, dateDirName); if (!dateDir.exists()) { readMe.md
@@ -372,4 +372,20 @@ isServiceBoundState.value = false mediaAidlInterface = null } } } TF 卡录制(tfCardFlag == 1) 目录:<TF 根>/AnYun_VIDEO/yyMMdd/HHmmss_... .mp4 每次新建 1 分钟 MP4 前: 基于 cleanupH264Files(现为 MP4 版本): 按日期目录统计所有 MP4 总大小。 超过 5GB 或 TF 卡剩余空间 < 1GB 时,从最早日期目录开始整目录删除。 内部 Flash 录制(tfCardFlag == 0) 目录:/sdcard/AnYun_VIDEO/yyMMdd/HHmmss_... .mp4 每次新建 1 分钟 MP4 前: 基于 ensureInternalFlashSpaceForH264(现为 MP4 版本): 如果该分区剩余空间 < 800MB: 递归收集 AnYun_VIDEO 下所有 MP4。 按 lastModified 从早到晚依次删,直到 ≥ 800MB 或没有文件。