三栏法是一个 Android 笔记本,33 个 Kotlin 文件,约 3300 行,用来练 David D. Burns 的三栏技术:写下自动思维,标出其中的认知歪曲,写下回应。思维记录是私密的,所以整个设计只定了一条规则:数据不会自己离开手机;只在你导出或分享时离开。
这条规则可以逐项验证:把数据可能离开手机的途径全部列出来——网络、文件系统和其他应用、屏幕、系统备份——然后一条一条检查,每条给出证据。源码是公开的;不想读源码,最后一节给出直接在发布 APK 上核对的命令。
一、网络
仓库里的 manifest 一个 uses-permission 元素都没有。打包出的 APK 里能看到的权限,全部是 AndroidX 在合并 manifest 时带进来的:两个 android.permission. 开头的权限出自生物识别库,只为可选的应用锁服务;另外两行是同一个应用私有权限的声明和使用,由 androidx.core 自动加入。这个权限是签名级的,只有用同一密钥签名的应用才能持有,作用是限制应用内部的广播接收器,不授予任何系统能力。
$ aapt dump permissions The-Three-Column-Method-v1.7.2.apk
package: com.threecolumn.cbt
uses-permission: name='android.permission.USE_BIOMETRIC'
uses-permission: name='android.permission.USE_FINGERPRINT'
permission: com.threecolumn.cbt.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
uses-permission: name='com.threecolumn.cbt.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
这份清单里没有 INTERNET。Android 上,没有这个权限的进程不会被放进内核里允许创建 socket 的那个组;任何创建 socket 的调用都会失败,不管发起调用的是应用自己的代码还是链接进来的库。限制在内核一层执行,用上面那条命令就能核对。
依赖清单回答另一半问题:应用里有没有会尝试联网的代码。全部运行时依赖都是 AndroidX——没有 HTTP 客户端,没有统计 SDK,没有崩溃上报。JSON 用平台自带的 org.json 处理,所以序列化库也没有。
app/build.gradle.kts 里的运行时依赖,每个库一行androidx.compose(BOM 2024.06.00):ui、material3、icons
androidx.navigation:navigation-compose androidx.room:room-runtime、room-ktx
androidx.biometric:biometric androidx.core、appcompat、lifecycle、activity
二、导出、导入与分享
数据必须有出口,否则手机丢了笔记就没了。出口一共三条,每条都要经过一个由用户亲手操作的系统对话框:
- 导出走存储访问框架的
CreateDocument:系统问你文件存到哪,应用只拿到指向那一个文件的描述符。它不申请存储权限——除这个文件之外,它不需要碰文件系统里的任何东西。 - 导入走
OpenDocument,方向相反;批量录入也用它。 - 分享把单条记录作为纯文本交给系统分享面板,去向由用户选。
三条出口都不会在后台发生:应用里没有定时任务,也没有后台任务,数据每离开一次,用户都要当场操作一次。同步正好相反——用户同意一次,之后一直在后台运行——所以这个应用没有同步。
导出格式是普通 JSON。「关于」页面印着格式说明,所以也可以手写一个文件批量导入。
DataTransferSection.kt{
"thoughtRecords": [
{
"situation": "Optional context",
"automaticThought": "The upsetting thought",
"distortions": ["ALL_OR_NOTHING", "LABELING"],
"rationalResponse": "A fairer response",
"beliefBefore": 80,
"beliefAfter": 30
}
],
"journalEntries": [
{ "body": "Free-form text for a page" }
]
}
格式里有两个决定值得说明。认知歪曲按枚举名存储,不按界面文字存储,所以中文界面导出的文件可以原样导入英文界面,也能用任何文本编辑器打开。导入是故意从宽的:缺 createdAt 就取当前时间;不认识的歪曲代码丢弃,不让整个文件失败;只有连自动思维都没有的记录才会被跳过。
三、屏幕与应用锁
存储之外,屏幕是另一条泄露途径。这个应用要防的威胁都在本地:没锁屏的手机被别人拿起来;为别的事截图,画面里带上了一条记录;「最近任务」的缩略图被旁边的人看到。可选的应用锁针对这些情况,不针对远程攻击者。MainActivity.kt 里有四个相关的决定:
- 锁打开时窗口带上
FLAG_SECURE:应用在「最近任务」里的缩略图变成空白,截屏也被禁止。锁关闭时清除这个标志。 - 解锁接受
BIOMETRIC_WEAK或DEVICE_CREDENTIAL——指纹、面容,或手机自身的 PIN、图案、密码。应用不保存任何秘密,只存一个「锁是否开启」的布尔值。 - 在后台待满十秒才重新上锁。应用自己的分享面板也会触发一次短暂的 onStop;如果每分享一条记录就要按一次指纹,锁很快会被用户关掉。
- 设备上没有录入凭据时,开关不可用;凭据之后被删掉时,锁自动打开,不把用户锁在外面。
MainActivity.kt 里的宽限期和 FLAG_SECURE// Set on onStop, cleared on onStart. Re-locking on every brief onStop (e.g. the system share
// sheet launched by this app's own Share button) would be annoying, so only re-lock if the
// app was actually backgrounded for a while.
const val LOCK_GRACE_PERIOD_MS = 10_000L
/** Hides content from the recents/app-switcher thumbnail and blocks screenshots while the lock is on. */
private fun applySecureFlag(secure: Boolean) {
if (secure) {
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
四、系统备份
1.6.0 之前还有一条途径,出自项目模板,不是我自己写的代码。manifest 里 allowBackup="true",模板自带的备份规则把 Room 数据库也包了进去:只要用户开着 Android 自带的备份,每条记录都会有一份副本进入他的 Google 账号。Android 9 及以后这份备份用设备凭据加密,不是明文放在 Google 的服务器上;但它是设备之外的存储,而且不需要用户做任何事,开头那条规则被它违反了。这个问题是写这篇文章初稿时发现的。修复很小:manifest 改三行,再删两个规则文件。
AndroidManifest.xml;两份规则文件一并删除- android:allowBackup="true"
+ android:allowBackup="false"
- android:dataExtractionRules="@xml/data_extraction_rules"
- android:fullBackupContent="@xml/backup_rules"
从 1.6.0 起,导出文件是唯一的恢复途径:手机丢了又从没导出过,记录就找不回来。这个代价从设计第一天就被接受。
还有一处没有关上。Android 12 及以上,allowBackup="false" 只关掉云备份和 adb 备份;新手机在开机设置里直接从旧手机拉取数据的迁移不归它管,要在 dataExtractionRules 里用 <device-transfer> 单独排除。这种迁移必须由用户在新手机上亲手发起,数据在两台设备之间直接传输,不经过云端,所以不违反开头的规则;但 1.6.0 没有排除它,这是下一个要补的地方。
代价
- 没有同步。换手机就是先导出,再导入。
- 没有崩溃上报。应用在你的手机上崩溃时,除非你写信告诉我,否则我不会知道。
- 没有使用统计。哪些功能有人用,我不知道;每个产品决定都来自我自己的测试。
- Google Play 之外没有自动更新。本站下载的 APK 要手动安装。
四条都是真实的损失,同步、崩溃上报和使用统计也都是我本来想要的功能。去掉它们,开头那条规则才成立。
自己核对
最重要的两条声明,可以用 Android 构建工具在下载页的 APK 上直接核对:
aapt dump permissions The-Three-Column-Method-v1.7.2.apk
aapt dump xmltree The-Three-Column-Method-v1.7.2.apk AndroidManifest.xml | grep allowBackup
第一条命令的输出应当与「网络」一节的证据逐行一致:两个生物识别权限,加上那个应用私有权限的声明和使用,没有 INTERNET。第二条应当显示 allowBackup 的值是 0x0,即 false;它核对的是云备份和 adb 备份,设备间迁移见上一节。
AgentGuard 是同一个思路在更大程序上的应用:先决定一个程序可以碰什么,再把限制放在程序绕不过去的位置。对这个笔记本,这个位置就是一个从未授予它的权限。