三栏法是一个 Android 笔记本,33 个 Kotlin 文件,约 3300 行,用来练 David D. Burns 的三栏技术:写下自动思维,标出其中的认知歪曲,写下回应。思维记录是私密的,所以整个设计只定了一条规则:数据不会自己离开手机;只在你导出或分享时离开。

这条规则可以逐项验证:把数据可能离开手机的途径全部列出来——网络、文件系统和其他应用、屏幕、系统备份——然后一条一条检查,每条给出证据。源码是公开的;不想读源码,最后一节给出直接在发布 APK 上核对的命令。

一、网络

仓库里的 manifest 一个 uses-permission 元素都没有。打包出的 APK 里能看到的权限,全部是 AndroidX 在合并 manifest 时带进来的:两个 android.permission. 开头的权限出自生物识别库,只为可选的应用锁服务;另外两行是同一个应用私有权限的声明和使用,由 androidx.core 自动加入。这个权限是签名级的,只有用同一密钥签名的应用才能持有,作用是限制应用内部的广播接收器,不授予任何系统能力。

证据 —— 1.7.2 APK 里的全部权限(1.6.0 起相同)
$ 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

二、导出、导入与分享

数据必须有出口,否则手机丢了笔记就没了。出口一共三条,每条都要经过一个由用户亲手操作的系统对话框:

三条出口都不会在后台发生:应用里没有定时任务,也没有后台任务,数据每离开一次,用户都要当场操作一次。同步正好相反——用户同意一次,之后一直在后台运行——所以这个应用没有同步。

导出格式是普通 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 里有四个相关的决定:

证据 —— 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 改三行,再删两个规则文件。

证据 —— 随 1.6.0 发布的那次提交里的 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 没有排除它,这是下一个要补的地方。

代价

四条都是真实的损失,同步、崩溃上报和使用统计也都是我本来想要的功能。去掉它们,开头那条规则才成立。

自己核对

最重要的两条声明,可以用 Android 构建工具在下载页的 APK 上直接核对:

证据 —— 对发布 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 是同一个思路在更大程序上的应用:先决定一个程序可以碰什么,再把限制放在程序绕不过去的位置。对这个笔记本,这个位置就是一个从未授予它的权限。