在 startFileRotationTimer() 的定时回调里,轮换到下一分钟前调用:
usb和内部相机
4个文件已修改
270 ■■■■■ 已修改文件
app/src/main/java/com/safeluck/floatwindow/manager/AndroidCameraRecordManager.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/safeluck/floatwindow/manager/UsbCameraRecordManager.java 49 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/safeluck/floatwindow/util/FileUtil.java 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/src/main/java/com/safeluck/floatwindow/util/VideoFileUtils.java 2 ●●● 补丁 | 查看 | 原始文档 | 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
@@ -17,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;
@@ -572,14 +573,17 @@
                        
                        // 释放当前资源
                        releaseResources();
                        // 重命名刚完成的文件
                        if (completedVideoFile != null) {
                            renameCompletedFile(completedVideoFile);
                            completedVideoFile = null;
                        }
                        // 初始化新的编码器和Muxer
                        // 每次写入新文件前,检查并清理存储空间(AnYun_VIDEO 下的 mp4)
                        ensureStorageSpaceForMp4();
                        // 初始化新的编码器和Muxer(创建下一分钟的新文件)
                        if (!initEncoderAndMuxer()) {
                            Timber.e("Failed to create new video file");
                            break;
@@ -647,13 +651,13 @@
                }
                
                releaseResources();
                // 重命名刚完成的文件(停止录像时)
                if (completedVideoFile != null) {
                    renameCompletedFile(completedVideoFile);
                    completedVideoFile = null;
                }
                Timber.d("RecordThread ended");
            }
        }
@@ -811,6 +815,41 @@
        // 如果获取失败,返回默认值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
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, 5, 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,7 +49,7 @@
            }
        }
        
        // 创建年月日目录(例如:260126)
        // 创建年月日目录(例如:2026_01_30)
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy_MM_dd", Locale.getDefault());
        String dateDirName = dateFormat.format(new Date());
        File dateDir = new File(videoDir, dateDirName);