本文偏工程、不偏宣传;没有”百万级用户””业界领先”这类话术,只有写这个项目时踩过的坑、做过的取舍,以及还留着没填的坑。希望它对你做”本地优先 + 加密存储”的 Android 应用有帮助。
市面上”相册加密 / 文件保险箱”类 App 不少,但绝大多数要么把文件传云端(你根本不知道它传没传),要么只是给文件改个名、挪个位置,名为”加密”实则一眼看穿。我想要的是一把真正只落在本机的保险箱:不联网、不申请网络权限、不上传任何字节,把图片、视频、文档藏进应用私有目录,用密码 / 指纹解锁,随时能取出来。
于是有了 FileSafe。它是一个 Kotlin + Jetpack Compose 的 Android 应用,核心就三件事:藏进去、加密起来、解锁后能原样拿出来。本文会从”一次解锁的完整链路”讲起,重点拆解加密体系、缩略图、密码存储这几块容易做错的实现细节,最后照例来一段”坦白局”。
开源地址:GitHub · Gitee(国内镜像)。当前状态:versionName 1.0 / versionCode 1(首个发布候选版本),minSdk 24(Android 7.0)→ targetSdk 36(Android 16)。
一、整体流程
技术栈非常朴素,没什么花哨的东西:语言 / UI Kotlin 2.2.10 · Jetpack Compose + Material3(BOM 2024.12.01);架构 MVVM(ViewModel + StateFlow)+ Repository 模式 · 单 Activity + Navigation Compose 2.8.5;存储 DataStore Preferences 1.1.1(运行时实际数据源)· Room 2.7.0(预留,见第八节);加密 AES-256-GCM(Android Keystore)· XChaCha20-Poly1305(Bouncy Castle 1.78);媒体 Media3 ExoPlayer 1.5.1(视频播放)· Coil 2.7.0(图片加载);安全 Biometric API 1.2.0-alpha05。
打开应用后的链路(MainActivity → AuthViewModel 驱动):首启设置密码 → 设置密保问题(可选);启动时读 DataStore 判断「是否设过密码 / 是否已解锁」——未设密码 → SetupPasswordScreen;设了且未解锁 → LoginScreen(密码 + 可选指纹);已解锁 → AppNavigation(主界面)。
这段判定逻辑全在 MainActivity 里用 collectAsState 订阅,纯声明式,没有 Fragment 堆叠,也没有密码明文在内存里长时间停留。解锁成功后,用户看到的是按路由分区的主界面:login · setup_password · setup_security_questions · forgot_password · home · folder/{folderId} · trash · settings · security_settings · backup · about · video_player。
实时加密 / 进度更新的核心是 EncryptionService(前台服务)通过一个 ProgressManager 单例广播进度给 UI。这个”服务 + 单例监听器”的组合后面单独讲。
二、加密体系
这是整个项目最核心、也最容易踩坑的部分。我给了两个可切换的算法,大文件走分块 + 并行加密。所有的加密 / 解密入口收敛在 CryptoManager 里。
为什么是”双算法”
AES-256-GCM:走 AndroidKeystore,密钥 alias 是 FileSafeKey_AES,密钥不可导出、由硬件 / 系统级 KeyStore 保管。这是”钥匙在保险柜里、密文在外面”的标准做法。
XChaCha20-Poly1305:Bouncy Castle 的软件实现,每个文件独立随机生成一个 32 字节密钥 + 24 字节 nonce。之所以加它,是想给”一次一密”留一条路——AES 走 Keystore 是一把主钥加密所有文件,而 XChaCha 路径可以做每文件独立密钥。
这里有个值得说明的取舍:Bouncy Castle 的 ChaCha20Poly1305 只接受 12 字节 internal nonce,而 XChaCha20 的非对称设计要用 24 字节。所以我在代码里手写了一遍 hChaCha20 子钥派生(hChaCha20Subkey + quarterRound),用 nonce 前 16 字节派生子钥,后 8 字节拼成内部 12 字节 nonce。这段纯算法代码不依赖任何库,十几行搞定。
大文件:超过 10MB 就分块并行
CHUNK_SIZE = 8MB,LARGE_FILE_THRESHOLD = 10MB。超过阈值走 encryptFileParallel:把文件整读进内存、按 8MB 切片;每一片用 async(Dispatchers.Default) 并行加密;再按原始顺序 sortedBy { it.index } 写回,保证解密时顺序正确。
val encryptedChunks = coroutineScope {\n chunks.mapIndexed { index, chunkData ->\n async(Dispatchers.Default) {\n val cipher = Cipher.getInstance(AES_TRANSFORMATION)\n cipher.init(Cipher.ENCRYPT_MODE, getOrCreateAESKey())\n val iv = cipher.iv\n EncryptedChunk(iv, cipher.doFinal(chunkData), index)\n }\n }.awaitAll()\n}
这样做的收益在于:单线程加密大文件会长时间卡住主流程,分块并行后能充分利用多核,进度也能按字节精确上报。代价是需要把整份文件读进内存再切片,对大文件有内存峰值,这也是为什么把分块阈值定在 10MB、块大小定在 8MB——在”内存占用”和”并行度”之间折中。
文件头:一个字段决定兼容性
密文文件不是裸字节,开头有一段自定义头。第一个字节是算法标记,第二个字节是”是否分块”,解密时全靠它来判断怎么读:
byte[0] = ALGORITHM_MARKER // 0x01 = AES-256-GCM, 0x02 = XChaCha20-Poly1305\nbyte[1] = 0 | 1 // 是否分块加密\n\n# 不分块(一整个块)\n[12B IV] [密文] (AES)\n[24B nonce][32B key][密文] (XChaCha)\n\n# 分块\n[8B 原始文件长度]\n然后对每块重复:\n [4B IV/nonce 长度][IV/nonce][4B 密文长度][密文]
分块里每个块都生成独立 IV / nonce,这也是 GCM(以及 Poly1305)常用的安全实践——避免用同一 nonce 加密多个块。解密时逆向解析:decryptFile 先读两个头字节,再按是否分块走 decryptFileInChunks / decryptFileInOneGo;读不到合法标记就退到 decryptLegacyFile,兼容早期版本的旧格式。这个”读取时兼容旧格式”的兜底很重要,否则升级一次 App 用户文件可能全废了。
一个实现上的小细节:XChaCha 的 32 字节文件密钥,在顺序分块路径里写在整个头部(只一次),而在并行分块路径里是跟每个加密块一起写的(每块后各写一次)。解密代码只认”有没有读到完整密钥”,两种写法都能解出来,但如果你要自己实现同样的格式,得注意这个差异。
三、两种模式
我用一个 EncryptionMode(HIDE / ENCRYPT)来控制导入行为,在安全设置里随时可切。这里要诚实地说:
ENCRYPT 模式:文件先进缓存,再加密成 {UUID}.enc 存到 encrypted_files/,是真正的密文。
HIDE 模式:只是把文件复制进私有目录 encrypted_files/,文件名取一个 . 开头的随机串(.${UUID})。
也就是说——“隐藏”不等于”加密”。把文件挪进 filesDir 后,普通相册和别的 App 确实看不到了(它不申请存储权限,也不在媒体库里登记),但拿到 root 的人能直接读出原文件。我在设置里把这两种模式并列,就是想让用户明确知道自己在用哪个档位,别把”隐藏”当”加密”用。
算法的选择(AES_256_GCM / XCHACHA20_POLY1305)也同理,都在 SecuritySettingsManager 里存进 DataStore,导入时由 EncryptionService 读取。
四、缩略图
传统做法是拿到文件直接给图片加载库生成缩略图,但加密文件的字节是密文,Coil 读不出来。所以在 ThumbnailManager 里,我针对加密文件走了一条特殊路径:isEncrypted 为真 → decryptFile(密文, 临时文件) → 图片用 BitmapFactory 采样解码 → 视频用 MediaMetadataRetriever.frameAtTime 取一帧 → 压缩成 {fileId}.jpg 存 thumbnails/ → 删除临时文件。
几个细节值得记录:100MB 上限(MAX_THUMBNAIL_FILE_SIZE):解密一个大文件只为生成一张 256px 的缩略图太不划算,超大文件直接放弃缩略图,列表走占位图。这是我在”缩略图体验”和”性能”之间做的取舍。图片用 BitmapFactory 的 inSampleSize 采样,视频用 MediaMetadataRetriever,统一输出 256px / JPEG 85。缩略图文件按 fileId 命名({fileId}.jpg),删文件时同步删缩略图。
这里有个隐含的安全收益:缩略图是解密后再压缩的,即使有人拿到 thumbnails/,看到的也只是 256px 的模糊小图,不是原始内容——而且它确实是 JPEG,不是明文。
五、密码存储
密码存储是我最谨慎对待的地方。PasswordManager 的做法是两段式:先对密码做 SHA-256 摘要(转 hex 字符串),再用 Keystore 里的 AES-256-GCM 密钥加密这个摘要。最终落到 DataStore settings 里的是 encryptBytes(SHA-256(密码)) 的 Base64。验证时反向解出来、再和输入密码的摘要比对。
校验:输入密码 --SHA-256--> 摘要A\n 存储值 --AES-GCM 解密--> 摘要B\n 摘要A == 摘要B ?
这里我要坦白一个点:密码本身用的是 SHA-256,不是 PBKDF2。SHA-256 很快,抗暴力破解并不理想;但它的输出又加密了一层,密文只有用 Keystore 里那把不可导出的 FileSafeKey_AES 才能解开,而 Keystore 密钥不随备份迁移(见第九节)。所以它的安全性其实建立在”Keystore 密钥不可导出”这条上,而不是 SHA-256 本身。
此外代码里另有一套 deriveKeyFromPassword(PBKDF2WithHmacSHA256,10000 次迭代),是给”带口令的文件加密”用的,目前只服务于备份功能(备份当前处于禁用态)。两套口令相关实现并存,读代码时别混淆。
六、应用安全
“解锁后忘了锁”是保险箱最常见的破口。MainActivity 里做了三件事:
自动锁定(checkAutoLock):在 onResume 里检查距上次活动时间是否超过设定值。设定值来自 AUTO_LOCK_OPTIONS(立即 / 1 分钟 / 5 分钟…/ 永不)。特别地,我设了一个 EXTERNAL_ACTIVITY_THRESHOLD = 30s——用户跳出去调起系统文件选择器、从相册选文件、拍照返回,这些”短暂离开”会被识别出来并跳过自动锁定,避免选个文件回来就得重新输密码的尴尬。
// 短暂离开(如系统文件选择器)不算失焦\nval timeSincePause = System.currentTimeMillis() - lastPauseTime\nif (lastPauseTime > 0 && timeSincePause < EXTERNAL_ACTIVITY_THRESHOLD) {\n return\n}
防截屏 / 录屏(FLAG_SECURE):updateScreenshotFlag 根据 screenshotEnabled 动态 addFlags(FLAG_SECURE) 或 clearFlags。开启后系统截图会变黑,录屏也只录到黑幕。这是成本最低、见效最快的一道防线。
息屏锁定:注册 ACTION_SCREEN_OFF 广播接收器,一屏灭就 lockApp()。设置页可以控制是否开启。
七、前台服务与进度
一个几百 MB 的视频导入,加密可能要几十秒。如果 UI 卡着不动,用户第一反应是”App 是不是死了”。所以导入 / 导出都走 EncryptionService(前台服务):动作 ACTION_IMPORT / ACTION_BATCH_IMPORT / ACTION_EXPORT / ACTION_CANCEL;通知基于 NotificationChannel 的进度条通知,带”取消”操作;进度通过 ProgressManager 单例的 addListener 广播给 UI,FileOperationProgress 携带文件名、进度、当前第几个 / 总数、结果或错误信息;节流有 PROGRESS_UPDATE_INTERVAL = 150ms 和 128KB 缓冲,避免高频通知刷屏;批量导入用 BatchImportHolder 存一次要处理的 List<Uri>,逐文件循环,统计成功 / 失败数。
批量导入的细节是:每个文件临时放 cache/temp/,加密成功后删除;整个过程 ensureActive() 检查协程取消,用户点”取消”即时终止。
这里也埋了一个已知问题(见第九节):Manifest 声明了 POST_NOTIFICATIONS,但没在 Android 13+ 做运行时请求,所以通知可能不显示。这是我没填的坑之一。
八、DataStore 还是 Room
这是我个人最喜欢的一个”反直觉”决定。按常识,文件列表这种结构化数据就该用 Room。但我在 FileSafe 里运行时数据源用的是 DataStore(file_data),Room(schema v2)反而预留。
原因很直接:这个应用的元数据(文件名、加密路径、类型、所属文件夹、是否加密)访问频率不高、量也不大,而且删除 / 移动文件时要求”元数据和物理文件强一致”,DataStore 用起来更轻、写入更单纯,不必引入 Room 那套关系映射和生命周期管理。Room 仍保留了三张实体表(FileItemEntity / FolderEntity / TrashItemEntity)和 FileSafeDatabase,一是为将来”回收站 / 文件夹成体系后”铺路,二是备份流程里会用到(当前备份被禁用)。
| 存储位置 | 名称 | 内容 |
|---|---|---|
| DataStore | file_data | 文件/文件夹/回收站元数据 —— 运行时实际数据源 |
| DataStore | settings | 密码摘要密文、首次启动标记、上次备份时间、生物识别开关 |
| DataStore | security_settings | 自动锁定时长、防截屏、生物识别、主题、加密模式/算法 |
| DataStore | security_questions | 密保问题与答案(私有目录序列化存储) |
| 文件目录 | encrypted_files/ | 加密(或隐藏)后的文件 |
| 文件目录 | thumbnails/ | 缩略图缓存 {fileId}.jpg |
也正因为元数据走 DataStore、明文不落外部存储,整个 App 无需申请存储权限:导入走系统文件选择器(SAF)拿 content:// URI,用 ContentResolver 读流;导出也走 SAF 写回。这既符合沙箱规范,也让”无网络权限”这一条成为可能。
九、未填的坑
诚实地说,下面这些我都没做完,写出来是提醒自己也提醒你:
- 备份与恢复(禁用态):
BackupScreen的按钮目前弹”功能开发中”。底层实现其实已在(ZIP 打包 DataStore / Room / 加密文件 / 缩略图,可选 AES-256-GCM 口令加密),但没在真机验证过全流程,不敢放开。README 和”关于”页文案也还没和禁用态对齐。 - Release 签名未配置:
app/build.gradle.kts没有signingConfig,assembleRelease产物未签名,正式发布需要外部注入 keystore。 android:allowBackup="true":默认模板没改。对保险箱类应用这是隐患——Keystore 密钥不会随云备份迁移,用户换机 / 恢复备份后,密文可能解不开。建议改为false。- 通知权限运行时请求缺失:Android 13+ 前台服务进度通知可能不显示。
- 无自动化测试:
CryptoManager/ 密码校验这层最该有单元测试,目前只有示例。加密这种东西,改动一个小细节就可能让老文件解不开,值得用测试兜底。 - 未开启混淆:
isMinifyEnabled = false,发布前需要处理 R8 规则。
写在最后
硬要总结的话,做”本地保险箱”这类 App,难点从来不是”会不会加密”,而是在一堆琐碎的边界里不犯错:文件头要不要兼容旧版、分块顺序能不能保证、缩略图要不要为加密文件解密、密码是该用快哈希还是慢哈希、离开页面多久要重新锁、Keystore 密钥会不会跟着备份一起丢……每一项单独看都不难,串起来才是一个能被每天用、且用着安心的工具。
FileSafe 目前是首个发布候选版本,功能基本完整、能编译出 Debug APK。如果你也在做类似的”本地优先 + 加密存储”应用,或者对加密 / Compose 的实现细节有想聊的,欢迎评论区交流。
相关链接:
GitHub:https://github.com/LogicLiu1024/FileSafe
Gitee(国内镜像):https://gitee.com/logicliu/file-safe
许可证:本项目基于 MIT License 开源
版权声明:本文遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
