DEV Community

securepdfeditor
securepdfeditor

Posted on AI-assisted

Zero Permissions: how an Android PDF editor ships with no INTERNET permission

How an Android PDF editor ships with no INTERNET permission — and why that is a different kind of privacy claim

Most privacy claims in app stores are unfalsifiable. "We don't sell your data." "Your files are never shared." "Privacy first." You cannot check any of these. You are asked to trust a policy document written by the party you are trying to evaluate.

There is a second kind of privacy claim: one the operating system enforces on your behalf. On Android, android.permission.INTERNET is a hard gate. An app that does not declare it cannot open a network socket. Not "promises not to" — cannot. socket() fails, URL.openConnection() throws, and the failure is at the OS level, not in the app's own code.

This article is about how VaultPDF — a cross-platform PDF editor with real content-stream text editing, redaction, and offline OCR — ships with that permission removed. It is a writeup of specific engineering problems, including the parts that are unglamorous and the places where the guarantee is weaker than the marketing would suggest.

If you want to check the claim before reading further, it takes about fifteen seconds: install the app, then Settings → Apps → VaultPDF → Permissions. You will find no permissions listed. Not camera, not storage, not network.


1. What the shipped manifest actually contains

Here is the relevant part of the source manifest:

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

Those two lines are the whole trick, and they are also not the whole story. The tools:node="remove" attribute is a manifest-merger instruction. It does not declare a permission — it deletes one that would otherwise be merged in from a library.

That distinction matters, because the interesting question is not "how do I not write <uses-permission INTERNET>". It is: why would it have been there in the first place?

Because your dependencies put it there.

2. Your dependencies are declaring permissions for you

Gradle's manifest merger combines your manifest with the manifests of every library in your dependency graph. Libraries routinely declare permissions they believe they need. In this project the offenders were:

  • com.google.mlkit:text-recognition-chinese — on-device OCR
  • com.google.android.gms:play-services-mlkit-document-scanner — camera scanning
  • com.android.billingclient:billing-ktx — in-app purchases

Pulling in ML Kit and Play Billing is how a project ends up with INTERNET, ACCESS_NETWORK_STATE, and sometimes more, without a single line in your own manifest asking for them. You inherit capability you never requested and may not want.

tools:node="remove" is the supported way to reject the inheritance. The merged output for the release build confirms it worked — the final AndroidManifest.xml in build/intermediates/ contains exactly two <uses-permission> entries, and neither is network-related:

<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="com.securepdfeditor.pro.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" />
Enter fullscreen mode Exit fullscreen mode

com.android.vending.BILLING is required for in-app purchases. The second is an app-local, signature-level permission that AndroidX generates automatically. That is the complete list.

There is a tradeoff worth stating plainly: removing a permission does not remove the code that wanted it. More on that in the next section, because it is the most interesting part of this whole exercise.

3. The telemetry pipeline that is still in the APK

After stripping INTERNET, the merged manifest still contains this:

<service
    android:name="com.google.android.datatransport.runtime.backends.TransportBackendDiscovery"
    android:exported="false" >
    <meta-data
        android:name="backend:com.google.android.datatransport.cct.CctBackendFactory"
        android:value="cct" />
</service>
<service
    android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
    android:exported="false"
    android:permission="android.permission.BIND_JOB_SERVICE" />
<receiver
    android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.AlarmManagerSchedulerBroadcastReceiver"
    android:exported="false" />
Enter fullscreen mode Exit fullscreen mode

This is Google's datatransport stack — the telemetry transport layer that ML Kit and other Google libraries use to ship events back to Google. cct is the CCT backend factory, i.e. the path used for Google's own telemetry ingestion.

It is still there. It was merged in from a dependency, and no tools:node="remove" was applied to it. There is a service registered, a job scheduler, an alarm receiver — the entire apparatus for scheduling and transmitting usage events.

And it cannot transmit anything, because the process it runs in has no INTERNET permission. Any socket it opens will throw SecurityException. The pipeline is present and permanently inert.

This is the most useful thing to understand about permission-based privacy: the permission is the switch, not the code. Auditing for trackers by reading library names or scanning for SDK classes will not tell you whether an app can exfiltrate data. The permission model will. An app can bundle every analytics SDK in existence and still be structurally incapable of phoning home, and conversely an app with a tiny dependency list can phone home freely if it holds INTERNET.

For anyone building a privacy-positioned app, this suggests a genuinely stronger design: do not audit your way to a clean dependency graph — which is a moving target as libraries change. Remove the capability. Then the dependency graph stops mattering.

4. Billing without INTERNET

This is the part people assume is impossible. An app that takes payments must talk to Google's servers. So how does it do that with no network permission?

It doesn't. The Play Store app does it.

Play Billing works over IPC. Your app binds to the billing service exposed by the Play Store, sends a purchase request, and receives the result. The Play Store — which does hold INTERNET — performs the actual network communication. Your app never touches the network, and never needs to.

The merged manifest shows both halves of that arrangement:

<uses-permission android:name="com.android.vending.BILLING" />

<queries>
    <intent>
        <action android:name="com.android.vending.billing.InAppBillingService.BIND" />
    </intent>
</queries>
Enter fullscreen mode Exit fullscreen mode

The <queries> element declares the intent your app wants to resolve — this is how you find the billing service under Android 11+ package visibility rules. The BILLING permission is what lets you bind to it.

So the permission profile is coherent rather than suspicious: an app that holds BILLING and binds to a billing service, and holds no network permission, is describing a client that delegates network I/O to a separate process. That is exactly what is happening.

The billing products in this app are securepdf_monthly_sub and securepdf_lifetime. Both exist purely as a donation mechanism — the app has no feature gates, so there is nothing for a purchase to unlock. That is a product decision, but it is also why the permission profile stays small: there is no entitlement server, no license check, no account system to justify a network stack.

5. OCR without INTERNET

Optical character recognition is the other feature that conventionally implies a server. Most "scan to searchable PDF" pipelines upload the page, run recognition in the cloud, and return text.

The alternative is a bundled model. The dependency here is:

implementation("com.google.mlkit:text-recognition-chinese:16.0.0")
Enter fullscreen mode Exit fullscreen mode

The important detail is which artifact this is. ML Kit ships in two flavours: a bundled variant, where the model and native pipeline are inside the AAR and get packaged into your APK, and a Play Services variant, where the model is downloaded on demand at runtime. Only the bundled variant can work with no network, and only the bundled variant avoids adding INTERNET to your merged manifest.

The tradeoff is size. The bundled models and libmlkit_google_ocr_pipeline.so are in the APK. That is a real cost, paid in download size, in exchange for the recognition working in airplane mode.

The output is written as an invisible text layer — PDF text render mode 3:

OCR-created text uses PDF text render mode 3 (invisible).
Enter fullscreen mode Exit fullscreen mode

The scanned image is preserved underneath, so the page looks identical, but the text is now selectable and searchable. Coordinates come from bitmap space and have to be mapped into PDF user space, which means handling CropBox offsets and page rotation — a step that is easy to get subtly wrong and produces text that is invisible but offset from the image it is supposed to sit on.

The invisible text layer is a redaction trap

Here is a consequence that is easy to miss, and that matters more than the OCR feature itself.

If a tool rasterizes a page and re-OCRs it, and then a user draws a black box over a name and saves — the visible image may be covered, and the invisible OCR text layer may still contain the name in machine-readable form, extractable with any text tool. The redaction looks complete and is not.

Any honest redaction implementation has to treat the invisible OCR layer as content to be removed, not as metadata to be left alone. In this codebase that is an explicit, tested behaviour:

TRUE REDACT removes overlapping OCR invisible text along with visible page objects.

The general lesson: once you introduce a feature that writes hidden machine-readable text into a document, every downstream deletion feature inherits a new obligation. Test for it explicitly.

6. The camera permission you don't need

Document scanning normally means CAMERA. This project requests no camera permission, and the reason is again architectural:

// On-device document scanner (camera + edge detection + PDF output run in
// Google Play Services; the app itself needs no CAMERA/INTERNET permission).
implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1")
Enter fullscreen mode Exit fullscreen mode

The scanner UI, camera capture, edge detection, and PDF assembly all run inside Play Services, which holds its own permissions. The app hands off to a Play Services activity and receives the result. The camera is used, but not by this process — so this process does not need to be trusted with it.

This is a pattern worth internalising: delegating work to a platform component moves the permission to the platform component. If you are fighting to justify a permission in your listing, first ask whether the work can be delegated instead of performed.

7. Backup is a data-exit channel

Removing network permission does not automatically keep documents on the device. Android has two separate mechanisms that copy app data off a phone, and disabling one does not disable the other.

allowBackup="false" covers Google cloud backup and adb backup. But on Android 12+, device-to-device transfer is governed separately, by data_extraction_rules. An app that only sets allowBackup="false" and assumes it is covered is wrong — during a phone-to-phone migration, its private data can still be copied.

This project excludes everything from both paths:

<data-extraction-rules>
    <cloud-backup>
        <exclude domain="root" />
        <exclude domain="file" />
        <exclude domain="database" />
        <exclude domain="sharedpref" />
        <exclude domain="external" />
    </cloud-backup>
    <device-transfer>
        <exclude domain="root" />
        <exclude domain="file" />
        <exclude domain="database" />
        <exclude domain="sharedpref" />
        <exclude domain="external" />
    </device-transfer>
</data-extraction-rules>
Enter fullscreen mode Exit fullscreen mode

The reason this matters more than it looks is the font cache. To render edit overlays that match the original document, the app extracts embedded fonts from the PDF and caches them (pdf_fonts). A font cache sounds like an implementation detail. It is not: embedded font subsets are derived from the document, and a subset can carry identifying information about the document it came from. A cached font from a client's contract is a fragment of that contract, sitting in app-private storage.

Treating document-derived caches as user data — not as scratch space — is the right default. It is also the kind of thing that is easy to get wrong precisely because the file extension looks innocuous.

8. Redaction that deletes rather than covers

The distinction this whole product is built on:

Covering draws something on top of the text. The text is still in the file. Selecting the area and copying it returns the original characters. This is the single most common failure in "redaction" features, and it has caused real document-disclosure incidents — including government ones.

Deleting removes the text from the file's content stream, so there is nothing left to extract.

PDF stores text as graphics operators. A BT/ET block brackets a text object, and inside it Tj and TJ operators draw strings. Removing text properly means operating at that level.

Two things make this harder than it sounds:

Text objects are not paragraphs. A single visual line can be split across several text objects, and one BT block can contain several lines. Deleting a whole BT block to remove one line takes out its neighbours. The implementation here does sub-block surgery — blanking individual Tj/TJ operators while leaving the rest of the block intact:

Sub-BT-block stream surgery: blank individual Tj/TJ operators (replace operand+operator with spaces). This preserves all other text in the same BT block.

Text is often not at page level. Content frequently lives inside Form XObjects. There is no single operation that removes it, because the right strategy depends on what else the form contains. Text-only forms can be flattened safely. Forms that also contain paths or images cannot, because flattening loses graphics state — those require editing the form's own content stream and then regenerating it, while leaving the paths and images untouched.

The implementation carries both paths, plus a protectedPointers set so objects that must survive a region deletion are explicitly excluded, and it works in reverse index order to avoid index shifting as objects are removed.

There is also a watermark problem. Many PDFs carry low-opacity watermark text. If it enters the editable text layer, users see phantom editable text that belongs to the watermark rather than the document. The filter here uses fill-colour alpha (below 40/255), not perceived brightness — so light-but-opaque body text is never mistaken for a watermark. Using brightness would silently drop legitimate content in documents with pale text.

9. Where this guarantee is weaker

A writeup that only lists strengths is an advertisement. So:

The guarantee is strongest on Android, because the OS enforces it. No INTERNET permission means no sockets, enforced below the app. That is a hard, checkable property.

On macOS it is an implementation choice, not an enforced one. The macOS build is not sandboxed, and there is no com.apple.security.network.client entitlement — but an unsandboxed binary is not restricted by entitlement declarations, so the absence is a statement of intent rather than a technical barrier. A user on macOS is trusting that no network code was written, not relying on the OS to make network code fail. That is a meaningfully weaker position than the Android side, and it should be described as such.

Removing a permission does not remove capability that travels by other means. The app process cannot open a socket. It can still hand data to another process through an intent or a content provider — and a different app, one that does hold INTERNET, could transmit it. That requires a user-visible action in this design, and no storage permission is held, but "cannot open a socket" is a narrower claim than "data can never leave the device by any route," and it is the accurate one.

The telemetry stack described in section 3 is still in the binary. It is inert, and that inertness is enforced by the permission model rather than by code removal. Anyone auditing the APK will find it. It is better to say so up front than to be caught by it.

These limits do not weaken the core claim. They define it. The core claim — this process cannot open a network socket, and you can verify that in two taps — is true, and it is a stronger claim than most apps in this category are able to make.

10. Verify it yourself

The point of this class of guarantee is that it is checkable by someone who does not trust the developer.

  1. Check permissions at runtime. Settings → Apps → VaultPDF → Permissions. Nothing should be listed.
  2. Check the manifest without running anything. aapt dump permissions <apk>, or open the APK and read AndroidManifest.xml. Look for uses-permission entries.
  3. Test the features offline. Enable airplane mode, then use OCR, editing, redaction, and signing. All of it should work. Recognition and editing have no server path to fail back to.
  4. Test the failure mode. With the app in airplane mode, attempt a purchase. Billing requires the Play Store's network path, so it will not complete — which is itself confirmation that the app is not the one doing the network work.

11. What this cost

Real numbers, from the repository, not rounded up:

Commits 148
First commit 2026-03-14
Last commit 2026-09-21
Android (Kotlin) 15,658 lines, 32 files
macOS (Swift + Objective-C) 7,836 lines
PDFium JNI bridge (C) 1,953 lines
JNI functions exposed 122
Test files 31 Android instrumented + 31 XCTest
minSdk / targetSdk 24 / 36
PDFium built from source for Android, 423 exported FPDF APIs

The editing engine — text extraction, paragraph merging, font resolution, content-stream editing, redaction — exists twice, in Swift and Kotlin, with matching behaviour. That duplication is not an accident of the project; there is no shared runtime between the platforms, so the second implementation is the price of the second platform.


The point

Privacy claims are usually a promise. This one is a constraint.

A promise can be broken by a policy change, an acquisition, a bad quarter, or a well-meaning engineer adding an analytics SDK to debug a crash. A constraint enforced by the operating system can only be broken by requesting a permission — which is visible in the app store listing, and visible again on the device.

That is the whole argument for building this way. It is not that this code is more trustworthy than someone else's. It is that you do not have to decide whether to trust it.

VaultPDF is on Google Play: https://play.google.com/store/apps/details?id=com.securepdfeditor.pro

Free, no ads, no feature gates, no network permission. Every feature works on first launch. There is an optional purchase that unlocks nothing — it exists so that people who find it useful can pay for it if they want to.


Technical details in this article are drawn from the actual source repository. Numbers in section 11 are counted from the commit history and source tree rather than estimated.

Top comments (0)