RECEIPT · 2026-09-06 · the FrankenKeys log

Five permissions held, and the one deliberately missing

The complete FrankenKeys permission list, where each entry comes from, why two network permissions are stripped, and the build rule that keeps it so.

2026-09-06 · by Corey Redwine · android keyboard with no internet permission

This is the whole list. Every permission the released FrankenKeys build requests from Android, as read from the merged manifest, the file the build actually ships.

Permission Why it is there Where it comes from
android.permission.VIBRATE Haptics when you press a key. Twenty profiles, all of them need this one line. Our manifest
android.permission.RECORD_AUDIO Voice typing. The audio goes to the system recogniser, not to us. Optional, and asked for only when you tap the microphone. Our manifest
com.google.android.apps.aicore.service.BIND_SERVICE Talking to Android's on-device AI service for grammar and tone rewrites. The ML Kit GenAI libraries
com.android.vending.BILLING The one-time Pro unlock, over IPC to the Play Store app. The Play Billing library
studio.forgedev.forgeboard.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION A private signal the app sends to itself. Android 13 requires a receiver to declare whether other apps may reach it; this is the "they may not" version. AndroidX core

There is no sixth. In particular there is no android.permission.INTERNET and no android.permission.ACCESS_NETWORK_STATE.

The two that are stripped on purpose

Some of the libraries above would like network access. ML Kit and Play services libraries carry their own manifest fragments, and when Android's build tools merge them into the app's manifest, permissions merge too. Left alone, that is how a keyboard ends up holding INTERNET without anyone on the team having typed the word.

The FrankenKeys manifest names both network permissions and marks them for removal:

<uses-permission android:name="android.permission.INTERNET"
                 tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"
                 tools:node="remove" />

tools:node="remove" tells the merger to drop that permission no matter which library contributed it. The libraries still work. Gemini Nano runs in Android's own AICore process and is reached over binder, not over a socket, and purchases travel by IPC to the Play Store app, which does its own networking on its own permission.

The rule that keeps it true

A comment asking the next person to re-read the merge report is not a control. So the build checks itself. The release configuration carries an allowlist of exactly the five permissions above, and after the manifest is merged a task named assertAirGap reads the result and compares:

val allowedPermissions = setOf(
    "android.permission.VIBRATE",
    "android.permission.RECORD_AUDIO",
    "com.google.android.apps.aicore.service.BIND_SERVICE",
    "com.android.vending.BILLING",
    "studio.forgedev.forgeboard.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
)

Anything not on the list fails the build with the message "AIR GAP VIOLATED" and the name of the offender. The task is bound to the manifest-processing step itself, so it runs on every assemble and every bundle, including release builds started from the IDE. A dependency that quietly pulls in ACCESS_WIFI_STATE or the advertising-ID permission does not get to ship.

N/I

Five permissions, verifiable three ways. Settings, Apps, FrankenKeys, Permissions, three dots, All permissions: no "have full network access" line. The Play listing's App permissions detail: same. adb shell dumpsys package studio.forgedev.forgeboard: five requested permissions, none of them network. Steps in the verify guide.

Why ACCESS_NETWORK_STATE goes too

It is harmless on its own. It lets an app ask whether the phone is online. I strip it anyway, for two reasons. First, an app that cannot use the network has no business asking about it, and a permission with no purpose is a permission a reader has to wonder about. Second, the list is the receipt. A five-line receipt that a stranger can hold up against the manifest is worth more than a seven-line one with two entries I would have to explain.

What the microphone permission does and does not mean

RECORD_AUDIO is the one entry a careful reader should pause on, so here is the mechanism. When you tap the microphone key, FrankenKeys asks Android's SpeechRecognizer to listen and hands back text. On Android 12 and later it creates the recogniser with createOnDeviceSpeechRecognizer, which either gives an on-device engine or throws, and the keyboard treats the throw as "no offline recogniser, do nothing". On Android 10 and 11 that factory does not exist, so it uses the ordinary recogniser with EXTRA_PREFER_OFFLINE, and Android's documentation is candid that this is a preference the recogniser may ignore.

Either way the audio is captured by the system service, in the system service's process. FrankenKeys receives transcribed text. It never stores audio, and with no network permission it could not send audio anywhere even if it did. The full account is on the voice typing page.

Where each fact above lives

  • The manifest with the two tools:node="remove" lines: app/src/main/AndroidManifest.xml in the decompiled APK, or the merged manifest in the release bundle.
  • The allowlist and the assertAirGap task: app/build.gradle.kts. Not shipped in the APK, but its effect is: the merged manifest cannot contain anything else.
  • The Play Billing library adds BILLING and nothing else. I checked the merged manifest before writing that sentence, because an earlier plan assumed billing would drag INTERNET in and would have shipped Pro as a separate app on that assumption.

Release builds ship with class names intact, so if you decompile the APK you will find assertAirGap has already done its job: the manifest inside is the one described here. Details in why release builds ship unobfuscated.