The Three Column Method is an Android notebook, 33 Kotlin files and about 3,300 lines, for practicing David D. Burns's three-column technique: write down the automatic thought, mark the cognitive distortions in it, write a response. Thought records are private, so the whole design follows one rule: data never leaves the phone on its own; it leaves only when you export or share it.

A rule like that can be verified item by item: list every way data could leave the phone — the network, the filesystem and other apps, the screen, the system backup — then check each one and attach evidence. The source is public; for anyone who would rather not read it, the last section gives commands that check the important claims on the released APK directly.

1. The network

The manifest in the repository declares no uses-permission element at all. Every permission visible in the packaged APK is brought in by AndroidX during manifest merging: the two entries starting with android.permission. come from the biometric library and serve only the optional app lock; the other two lines are the declaration and use of a single app-private permission that androidx.core adds on its own. That permission is signature-level, so only an app signed with the same key can hold it; it exists to restrict the app's internal broadcast receivers and grants no system capability.

Evidence — every permission in the 1.7.2 APK (unchanged since 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 is not in that list. On Android, a process without that permission is never placed in the kernel group allowed to create sockets, so every attempt to open one fails, whether the call comes from the app's own code or from a linked library. The kernel enforces the restriction, and the command above is enough to check it.

The dependency list answers the other half of the question: whether the app contains any code that would try to reach the network. Every runtime dependency is AndroidX — no HTTP client, no analytics SDK, no crash reporter. JSON is handled with the platform's own org.json, so there is no serialization library either.

Evidence — the runtime dependencies in app/build.gradle.kts, one library per line
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

2. Export, import, share

Data must have a way out, or a lost phone means lost notes. There are three exits, and each one passes through a system dialog the user operates by hand:

None of the three can happen in the background: the app has no scheduled tasks and no background work, so each time data leaves, the user acts at that moment. Sync works the other way around — the user agrees once and it runs in the background from then on — so this app has no sync.

The export format is plain JSON. The About screen prints the format, so a hand-written file can be imported in bulk as well.

Evidence — the import format the app shows you, from 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" }
  ]
}

Two decisions in that format are worth explaining. Distortions are stored by enum name, not by display text, so a file exported from the Chinese UI imports unchanged into the English one and opens in any text editor. Import is deliberately lenient: a missing createdAt takes the current time; an unknown distortion code is dropped instead of failing the whole file; only a record with no automatic thought at all is skipped.

3. The screen and the app lock

Besides storage, the screen is another way data can leak. The threats this app has to handle are local: an unlocked phone picked up by someone else; a screenshot taken for another purpose that catches a record; a Recents thumbnail seen by a person standing nearby. The optional app lock is for those cases, not for remote attackers. Four decisions in MainActivity.kt matter here:

Evidence — MainActivity.kt, the grace period and 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)
    }
}

4. System backup

Before 1.6.0 there was one more way out, and it came from the project template, not from code I wrote. The manifest carried allowBackup="true", and the template's backup rules covered the Room database: for any user with Android's built-in backup enabled, every record got a copy in their Google account. From Android 9 on, that backup is encrypted with the device credential rather than stored in plaintext on Google's servers; but it is storage off the device, and it happens without any user action, so it breaks the rule at the top. I found it while drafting this post. The fix was small: three changed lines in the manifest and two deleted rule files.

Evidence — AndroidManifest.xml in the commit shipped with 1.6.0; both rule files deleted alongside
-        android:allowBackup="true"
+        android:allowBackup="false"
-        android:dataExtractionRules="@xml/data_extraction_rules"
-        android:fullBackupContent="@xml/backup_rules"

Since 1.6.0, an exported file is the only way to recover: lose the phone without ever having exported, and the records are gone. The design accepted that cost from day one.

One opening remains. On Android 12 and later, allowBackup="false" turns off only cloud backup and adb backup; device-to-device migration, where the new phone pulls data straight from the old one during setup, is outside its control and has to be excluded separately with <device-transfer> in dataExtractionRules. That migration must be started by the user on the new phone, and the data moves directly between the two devices without touching the cloud, so it does not break the rule at the top; but 1.6.0 does not exclude it, and it is the next thing to fix.

What it cost

All four are real losses, and sync, crash reports and usage data are all features I wanted. Removing them is what makes the rule at the top hold.

Check it yourself

The two claims that matter most can be checked directly on the APK from the download page, with the Android build tools:

Evidence — two commands against the released 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

The first command should print, line for line, what the evidence in the Network section shows: the two biometric permissions, plus the declaration and use of the app-private permission, and no INTERNET. The second should show allowBackup with the value 0x0, that is, false; it checks cloud and adb backup — for device-to-device migration, see the previous section.

AgentGuard applies the same idea to a much larger program: decide what a program may touch, then place the limit where the program cannot get around it. For this notebook, that place is a permission it was never granted.