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.
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.xmlin the decompiled APK, or the merged manifest in the release bundle. - The allowlist and the
assertAirGaptask: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
BILLINGand nothing else. I checked the merged manifest before writing that sentence, because an earlier plan assumed billing would dragINTERNETin 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.