← Architecture
Shipped and running

The internet permission we never wrote, and how it got into our app anyway

PDF Toolkit does its optical character recognition on the phone. We had written on our own homepage that its Android build carries no internet permission. Then we dumped the bundle and found six permissions, five of them contributed by libraries — including the one we had told people was absent. This is what we found, and the line that fixed it.

4 September 2026

Check it yourself. bundletool dump manifest --bundle=app-release.aab

The six permissions in the PDF Toolkit release bundle, with android.permission.INTERNET highlighted as contributed by a library rather than written by us.

The homepage of this site says that PDF Toolkit — a document app we build and publish ourselves — ships without the internet permission, so the operating system itself cannot let it upload anything. It invites the reader to check with a one-line command.

On 3 September we ran that command against our own release bundle, properly, for the first time. It came back with six permissions. We had written one of them.

bundletool dump manifest — app-release.aab (1.0.0, versionCode 14), 3 September 2026
1<manifest ... package="com.boffincoders.pdfunlocker"
2          android:versionCode="14" android:versionName="1.0.0" ...>
3  <uses-permission android:name="android.permission.CAMERA"/>
4  <uses-permission android:name="com.google.android.apps.aicore.service.BIND_SERVICE"/>
5  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
6  <uses-permission android:name="android.permission.WAKE_LOCK"/>
7  <uses-permission android:name="com.boffincoders.pdfunlocker.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"/>
8  <uses-permission android:name="android.permission.INTERNET"/>

INTERNET is in there. Nobody at this company typed it. This piece is about how it got in, what we did about it the next day, and why the gap between the manifest a developer writes and the one a user installs is worth an afternoon of anybody's time — particularly if you are about to tell a customer in Germany or the Netherlands that their documents cannot leave the device.

What the app does, and why we bothered

PDF Toolkit scans paper and ID cards, merges, splits, compresses, unlocks, protects, annotates, fills forms, exports to Office formats and runs OCR. It is one of the apps we build and publish under our own name, rather than client work we can only describe. All of that runs on the phone. There is no backend, no account, and no analytics SDK in the dependency list.

That is a build decision you can weigh rather than a marketing preference, because the recognition engine ships inside the download. In the 1.0.0 bundle, base/assets/mlkit-google-ocr-models/ was 5,404,582 bytes of models and libmlkit_google_ocr_pipeline.so another 11,064,544 bytes of native pipeline for arm64 — about a third of a 45,202,522-byte release, spent so that a scanned page never has to be posted anywhere. A server-side OCR endpoint would have made the app roughly 16 MB smaller and much less interesting.

The loop itself is unglamorous. For a PDF the app opens the document with pdfx and for each page renders it at twice its natural size to a JPEG at quality 82, writes that JPEG to the temporary directory, and hands the file path to ML Kit's recogniser. The page object is closed in a finally block. The result is not.

  1. 1pdfx opens the filePdfDocument.openFile
  2. 2render the page at 2×JPEG, quality 82, white background
  3. 3write a temp JPEGocr_page_<ts>_<n>.jpg
  4. 4recogniseTextRecognizer.processImage
  5. 5keep the resulttext + imageBytes + blocks
  6. next page — there is no cap
Held in state

3 page images retained, plus 3 JPEGs left in the temporary directory. Nothing here is freed until the screen closes.

The recognition loop. Each finished page keeps its text, its bounding boxes and the full JPEG it was recognised from, in memory, for as long as the screen is open.

That accumulation is the honest cost of doing this locally. A server pipeline recognises a page and throws the image away; ours holds every page image because the user may still export a searchable PDF from them. There is no page limit on PDF input — the loop runs from page one to document.pagesCount — so the ceiling is whatever the device tolerates. We have not measured where that is. It is a real gap, and it is in the list at the end.

Why reading your own manifest proves nothing

The app's own manifest has always requested one permission and explicitly stripped the storage and media permissions most PDF apps ask for, because file and photo imports go through the system pickers. It never declared INTERNET.

android/app/src/main/AndroidManifest.xml · 1.0.1

CAMERA

the one permission the app requests

Refused since the first release

  • READ_EXTERNAL_STORAGEtools:node="remove"
  • WRITE_EXTERNAL_STORAGEtools:node="remove"
  • READ_MEDIA_IMAGEStools:node="remove"
  • READ_MEDIA_VIDEOtools:node="remove"
  • READ_MEDIA_AUDIOtools:node="remove"
  • READ_MEDIA_VISUAL_USER_SELECTEDtools:node="remove"

Refused as of 1.0.1

  • INTERNETtools:node="remove"
  • ACCESS_NETWORK_STATEtools:node="remove"
  • WAKE_LOCKtools:node="remove"
The app’s own manifest as of 1.0.1. The six storage removals have been there from the start; the three at the bottom are the fix this piece is about.

If you stop there, you conclude what we concluded, and you write it on your homepage. The obvious check — grep your own manifest — returns exactly the answer you were hoping for:

two files, one build
1# what the developer wrote
2grep INTERNET android/app/src/main/AndroidManifest.xml
3# no match
4
5# what the build assembled
6grep INTERNET build/app/intermediates/merged_manifest/release/\
7  processReleaseMainManifest/AndroidManifest.xml
8# <uses-permission android:name="android.permission.INTERNET"/>

Opposite answers. The first is what a developer writes. The second is what Gradle assembles from your manifest plus a manifest fragment from every library in the dependency graph, and it is the second one that becomes the app. Nobody grants network access to your app in your file. It arrives four levels down somebody else's.

Who added what

The merger writes a report of every decision it makes, so the answer is not a matter of opinion. Step through the contributors below, then switch the release and watch three of them change side.

Six manifests merge into one. In 1.0.0 the network permissions are added; in 1.0.1 the app manifest refuses them, as it already refused storage.MANIFESTS THAT FED THE BUILDWHAT SHIPPEDREJECTEDandroid/app/src/main/AndroidManifest.xmlthe file we wrote — it asks for CAMERAopen_filexasked for storage and the media librarytransport-backend-cct:2.3.3arrives with the OCR enginemedia3-common / exoplayer:1.9.2arrives with a video playergenai-prompt:1.0.0-beta2arrives with flutter_local_aiandroidx.core:1.18.0arrives with everythingCAMERAwe wrote this oneaicore.BIND_SERVICEmerged from genai-promptACCESS_NETWORK_STATEmerged from media3WAKE_LOCKmerged from media3DYNAMIC_RECEIVER_…merged from androidx.coreINTERNETmerged from transport-backend-cctcom.boffincoders.pdfunlockerversionCode 14 · dumped 3 September 2026
Six manifests in, one out. Switch between the release Play is serving and the one that refuses the network permissions; tap a contributor to isolate its edge.

INTERNET was added by com.google.android.datatransport:transport-backend-cct:2.3.3. We had never heard of it either. It is not in our pubspec.yaml. It arrives with the Google ML Kit and Play services stack — which is to say, it arrives with the OCR engine that exists so documents can stay on the phone. The same merge registers a transport backend, a job scheduler service and an alarm receiver into our application.

ACCESS_NETWORK_STATE and WAKE_LOCK have a sillier lineage. They come from Media3 — Android's video playback stack — which is in a PDF app because the on-device model plugin depends on a package that depends on a video player.

The OCR enginegrants INTERNET

google_mlkit_text_recognition 0.16.0

in our pubspec.yaml

ML Kit / Play services stack

we never asked for this

transport-backend-cct 2.3.3

we never asked for this

The on-device model plugingrants ACCESS_NETWORK_STATE · WAKE_LOCK

flutter_local_ai 0.0.16

in our pubspec.yaml

genui 0.9.2

we never asked for this

video_player 2.13.0

we never asked for this

media3 1.9.2

we never asked for this

Two dependency chains, neither of them typed by anyone here. The gold box is the line in our pubspec; everything after it is inherited.

Nothing here is a bug in anyone's library. Transitive dependencies are the point of a package manager. But a dependency can hand your app a capability, and the file you wrote will never mention it.

The fix was one line we had already written

One entry in that report is worth as much as the embarrassing ones. When open_filex asked for READ_EXTERNAL_STORAGE and the READ_MEDIA_* family, the merger refused, because our manifest names those permissions and removes them.

So the mechanism worked, we knew about it, and we had used it — on storage. We had simply never pointed it at the network. The 1.0.1 manifest does, with the same three-word attribute:

android/app/src/main/AndroidManifest.xml — added for 1.0.1
1<!-- Contributed by ML Kit's transport stack and by media3, which arrives
2     with a video player this app never uses. Same treatment as the
3     storage permissions above. -->
4<uses-permission android:name="android.permission.INTERNET"
5    tools:node="remove" />
6<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"
7    tools:node="remove" />
8<uses-permission android:name="android.permission.WAKE_LOCK"
9    tools:node="remove" />

Rebuilt on 4 September, the merged release manifest comes back with three permissions and no network of any kind: CAMERA, the optional AICore service binding used by the on-device model, and an AndroidX signature-level permission that no app can grant itself. The merger report now marks the library INTERNET as REJECTED, in the same paragraph where it used to say ADDED.

merged release manifest — 1.0.1, 4 September 2026
1$ grep uses-permission build/app/intermediates/merged_manifest/release/\
2    processReleaseMainManifest/AndroidManifest.xml
3
4android.permission.CAMERA
5com.google.android.apps.aicore.service.BIND_SERVICE
6com.boffincoders.pdfunlocker.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
7
8$ grep -c 'INTERNET\|ACCESS_NETWORK_STATE\|WAKE_LOCK' \
9    build/app/intermediates/merged_manifest/release/\
10    processReleaseMainManifest/AndroidManifest.xml
110

On Android this is a real control rather than a declaration. A process without INTERNET is not placed in the networking group, and its socket calls fail. That is why the claim was worth making in the first place — and why making it before it was true was worse than not making it at all.

What can be said honestly, and what cannot

These are the claims that survive contact with the build artefacts:

  • The app-owned code makes no network call. Recognition is TextRecognizer.processImage against a local file path, and a repository-wide search across the Dart, Kotlin and Swift we wrote finds no HTTP client, no Dio, no launchUrl, and no upload endpoint.
  • There is no analytics, crash-reporting or advertising SDK. No Firebase, Crashlytics, AdMob, Sentry, Amplitude or Mixpanel in the dependency list; no google-services.json; no Google Services Gradle plugin.
  • The recognition models are in the bundle, not fetched at runtime. You can unzip the AAB and list them.
  • Storage, media and — from 1.0.1 — network permissions are refused at merge time, by name, and the merger report records each refusal.

And these are the ones we will not make:

  • That Google Play services can never touch the network. It is a separate package with its own permissions. Our app process cannot; theirs is not ours to speak for.
  • That the 1.0.0 build people are running today has no internet permission. It does. That is the whole reason this page exists.
  • Any accuracy percentage, pages-per-minute figure, or memory ceiling. None of those has been measured here.

What on-device cost us elsewhere

The permission story is the one we got wrong publicly, but it is not the only price of this design, and a page that listed only the flattering trade-offs would be an advert.

The searchable PDF is not really searchable

Exporting a searchable PDF stacks the recognised text over the page image at 1% opacity. It is one text block per page — the whole page string — not word-aligned boxes derived from the recogniser's bounding boxes, even though we have those boxes and keep them in state. Text extraction from the exported file works; selecting a word in another PDF reader and having the highlight land on that word does not.

Script coverage is narrower than the model set

The interface offers Latin, Devanagari, Chinese, Japanese and Korean. Arabic, Cyrillic and Thai are not offered at all. And the OCR fallback used when exporting to DOCX or TXT is hard-coded to Latin regardless of what the user picked on the OCR screen — a real defect, found while writing this, not while testing.

Inconsistent limits

The document scanner is capped at 50 pages from Dart, while the iOS native handler defaults to 20 when the argument is missing. PDF recognition is capped at nothing. Three limits, three different values, in one feature.

Housekeeping we skipped

Each recognised page leaves a JPEG in the temporary directory and we do not delete it. The operating system may clear that directory eventually; we do not, and we have not measured how much it accumulates.

Arm64 only

The release excludes 32-bit ABIs, so older devices are out of the distribution by design. That is a deliberate size trade against a native pipeline library that is 11 MB per architecture.

Where this breaks

A long PDF is the obvious one. Every page recognised is a page image held until the screen closes, on a device with a memory budget nobody here has written down. We know the shape of the failure and not its threshold, which is a bad place to be and an honest thing to say.

Android document scanning is the second. It runs through Google Play services' unbundled scanner activity rather than the recognition library we ship, so on a device without current Play services the scan entry point is at the mercy of something we do not control. Recognition itself still works on a photo or a PDF; the camera flow is the exposed part. If that activity fetches a module on first run, it does so in the Play services process, on Play services' permissions — which is precisely why stripping ours proves something narrower than people will assume.

And the third is the one this whole piece is about: any future dependency bump can add a permission back, and no test we currently run would fail because of it. Stripping three permissions by name fixes today's build. It does not fix next quarter's.

What we are changing next

A permission diff belongs in the build, not in a blog post written after the fact. The strip is a fixed list of names; a dependency that arrives with RECORD_AUDIO tomorrow sails straight through it. What we want is a release check that reads the merged manifest, compares it against an allowed list, and fails the build on anything new — the same way a test fails on an unexpected change anywhere else.

Until that exists, this is a habit rather than a guarantee, and habits are exactly what fail quietly for a year. We would rather write that down than let the fix read as more permanent than it is.

Check any of this yourself

None of the above needs to be taken on trust. With a release bundle and the Android build tools:

four commands, in order of how much they tell you
1# 1. What a bundle declares. Check the versionCode it prints:
2#    14 is the build with INTERNET, 15 is the build without.
3bundletool dump manifest --bundle=app-release.aab
4
5# 2. What the developer wrote, versus what was assembled
6grep INTERNET android/app/src/main/AndroidManifest.xml
7grep INTERNET build/app/intermediates/merged_manifest/release/\
8  processReleaseMainManifest/AndroidManifest.xml
9
10# 3. Which library asked for it, and whether it was refused
11grep -A5 'uses-permission#android.permission.INTERNET' \
12  build/app/outputs/logs/manifest-merger-release-report.txt
13
14# 4. That the recognition models really are in the download
15unzip -l app-release.aab | grep -E 'mlkit-google-ocr-models|ocr_pipeline'

Run the first one against your own app before you run it against ours. Most teams have never looked, and the interesting result is not what we shipped — it is what you did.

Ready to Build Something
That Actually Works?

Stop patching legacy code. Let's engineer a platform that scales with your ambition.