<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: securepdfeditor</title>
    <description>The latest articles on DEV Community by securepdfeditor (@securepdfeditor_7a1b7f42c).</description>
    <link>https://dev.to/securepdfeditor_7a1b7f42c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4138624%2Fe40650b2-0202-417f-af84-64582f5dba36.png</url>
      <title>DEV Community: securepdfeditor</title>
      <link>https://dev.to/securepdfeditor_7a1b7f42c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/securepdfeditor_7a1b7f42c"/>
    <language>en</language>
    <item>
      <title>How We Built PDF OCR with Zero INTERNET Permission on Android</title>
      <dc:creator>securepdfeditor</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:59:06 +0000</pubDate>
      <link>https://dev.to/securepdfeditor_7a1b7f42c/how-we-built-pdf-ocr-with-zero-internet-permission-on-android-3ikm</link>
      <guid>https://dev.to/securepdfeditor_7a1b7f42c/how-we-built-pdf-ocr-with-zero-internet-permission-on-android-3ikm</guid>
      <description>&lt;p&gt;An Android app that declares no &lt;code&gt;INTERNET&lt;/code&gt; permission cannot download models. That is the whole problem, and it shaped every other decision in this post.&lt;/p&gt;

&lt;p&gt;This is VaultPDF, an offline PDF editor (&lt;code&gt;com.securepdfeditor.pro&lt;/code&gt;, v2.0.1). Scanned PDFs become searchable, and can also be rebuilt into editable documents. Everything below runs on device.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "No INTERNET permission" is an artifact property, not a promise
&lt;/h2&gt;

&lt;p&gt;Most "offline" claims are behavioral: the app says it does not upload. Ours is structural. The manifest strips the permission at merge time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.INTERNET"&lt;/span&gt; &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_NETWORK_STATE"&lt;/span&gt; &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every release bundle is checked with &lt;code&gt;aapt2 dump permissions&lt;/code&gt;, and the release is blocked if &lt;code&gt;INTERNET&lt;/code&gt; reappears through a transitive dependency. Users can verify the same thing without trusting us: &lt;strong&gt;Settings → Apps → VaultPDF → Permissions&lt;/strong&gt; has no network entry at all.&lt;/p&gt;

&lt;p&gt;That constraint turns OCR into a delivery problem before it is a recognition problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Getting 254 MB of models onto a device that never downloads
&lt;/h2&gt;

&lt;p&gt;Cloud APIs, runtime language packs, and "download the model on first launch" are all dead on arrival. What is left is &lt;strong&gt;Play Asset Delivery&lt;/strong&gt;, where Google Play performs the transfer and the app still has no network capability.&lt;/p&gt;

&lt;p&gt;We ship exactly one &lt;strong&gt;install-time&lt;/strong&gt; pack, &lt;code&gt;ocr_models&lt;/code&gt;, about 254 MB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;layout.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;214 MB&lt;/td&gt;
&lt;td&gt;PP-DocLayoutV2 page layout analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rec.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21.2 MB&lt;/td&gt;
&lt;td&gt;PP-OCRv6 text recognition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rec_korean.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13.5 MB&lt;/td&gt;
&lt;td&gt;Korean recognition (see §8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;det.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9.9 MB&lt;/td&gt;
&lt;td&gt;text detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;table.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7.8 MB&lt;/td&gt;
&lt;td&gt;SLANet+ table structure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Install-time means it is present at first launch, so there is no "OCR is still downloading" state and no first-run network dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0-INTERNET does not mean the device never touches the network.&lt;/strong&gt; It means the app itself has no network capability, and distribution is delegated to Play. That distinction matters, and it is the honest version of the claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Why not ML Kit
&lt;/h2&gt;

&lt;p&gt;ML Kit answers "what text is in this image?" A PDF editor needs more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;layout classes — body, title, table, figure, formula, header, footer;&lt;/li&gt;
&lt;li&gt;table structure — rows, columns, cells;&lt;/li&gt;
&lt;li&gt;reading order for multi-column pages;&lt;/li&gt;
&lt;li&gt;PDF object reconstruction — invisible text layers, editable text objects, table grids;&lt;/li&gt;
&lt;li&gt;CJK-safe search, copy, and editing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ML Kit is not bad. It solves a smaller problem than the one a PDF editor has.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Searchable and editable are two different outputs
&lt;/h2&gt;

&lt;p&gt;The recognition result is not the product. The product question is: &lt;strong&gt;how does the text get written back into the PDF?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Searchable mode (default).&lt;/strong&gt; The original scan is untouched. Text objects are added with &lt;code&gt;renderMode=3&lt;/code&gt;, so they are invisible but searchable and copyable. Same idea as Acrobat's "Searchable Image".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editable mode.&lt;/strong&gt; A new document is generated from visible text objects, table lines, and cell text. The original file is never mutated in place.&lt;/p&gt;

&lt;p&gt;Our early failure was drawing visible OCR text on top of a page that still contained the scan, so both texts printed over each other. The fix was not coordinate tuning — it was semantic separation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;searchable = original image + invisible text;&lt;/li&gt;
&lt;li&gt;editable = new document + visible reconstructed text;&lt;/li&gt;
&lt;li&gt;the two live as separate documents, and the user switches between them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That also removes the worst failure mode of editable OCR: &lt;strong&gt;a bad rebuild can never destroy the original scan.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OCR changes what redaction has to mean
&lt;/h2&gt;

&lt;p&gt;Before OCR, a scanned page is pixels. After OCR, it may also contain a hidden text layer.&lt;/p&gt;

&lt;p&gt;Draw a black rectangle over the pixels and the text underneath is still searchable and copyable. Real redaction has to remove both the visible content and the text layer.&lt;/p&gt;

&lt;p&gt;This is a test, not a slogan — the suite includes &lt;code&gt;searchablePdfWriter_ocrTextIsRemovedByErase&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The CJK bug that taught us the most: Kangxi radical substitution
&lt;/h2&gt;

&lt;p&gt;One of the nastiest bugs came from our custom &lt;code&gt;libpdfium.so&lt;/code&gt; ToUnicode CMap behavior: some ideographs that share shapes with Kangxi radicals were stored as radical codepoints.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;高 (U+9AD8)&lt;/code&gt; came back as &lt;code&gt;⾼ (U+2FBC)&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;白&lt;/code&gt; as &lt;code&gt;⽩&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;力&lt;/code&gt; as &lt;code&gt;⼒&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One root cause surfaced as three separate product bugs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;copy and extraction returned radical codepoints;&lt;/li&gt;
&lt;li&gt;search failed, because the user typed the real ideograph while the PDF stored the radical form;&lt;/li&gt;
&lt;li&gt;editing failed, because &lt;code&gt;char → charcode&lt;/code&gt; mapping could not resolve.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix was a &lt;code&gt;CjkNormalize&lt;/code&gt; layer: display/copy/extraction map radicals back to canonical ideographs, search checks both forms, and editing registers canonical aliases in the charcode map.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;the model can recognize text perfectly and PDF write-back can still corrupt its semantics.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The real-device OOM: 214 MB is not something you casually &lt;code&gt;readBytes()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;layout.onnx&lt;/code&gt; is 214 MB. Opening it from a normal file path worked in development. Through the real install-time PAD path it did not, because the early code did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ocr-models/layout.onnx"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;readBytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That allocates ~214 MB in the JVM heap. On device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Throwing OutOfMemoryError "Failed to allocate a 213964005 byte allocation"
model missing: layout.onnx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI symptom was misleading: OCR simply "found no text."&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;stream-extract to a cache file, then let ONNX Runtime mmap it by path.&lt;/strong&gt; Copy with a 1 MB buffer, never materialize the model in the heap, extract once and reuse.&lt;/p&gt;

&lt;p&gt;This only reproduced through the release path — AAB + &lt;code&gt;bundletool --local-testing&lt;/code&gt; + real device + logcat. A debug build hid it completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Japanese and Korean
&lt;/h2&gt;

&lt;p&gt;Japanese works through the default PP-OCRv6 dictionary; kana and shared Han characters are covered, and on-device tests recognize mixed-script lines correctly.&lt;/p&gt;

&lt;p&gt;Korean needed its own path. The default dictionary has no Hangul, so we added a dedicated PP-OCRv5 Korean recognizer (13.5 MB) and route low-confidence suspected-Korean regions to it.&lt;/p&gt;

&lt;p&gt;The general lesson: multilingual OCR is not "the model supports 100+ languages." Dictionaries, fallback routing, and confidence strategy all have to ship as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The tier we measured and then did not ship
&lt;/h2&gt;

&lt;p&gt;We also built a much stronger high-accuracy tier: PaddleOCR-VL 1.6 Q4 running through llama.cpp/mtmd with Vulkan offload, via JNI. On a natural 150-page sample it scored &lt;strong&gt;0.0499&lt;/strong&gt; mean text-edit, with English at 0.0368 and English/Chinese mixed at 0.0322 — at or near the public PaddleOCR-VL reference line. On a Xiaomi XRing O1 device, Vulkan offload cut one crop from ~34 s to ~20 s with byte-identical output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not in the current release.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PaddleOCR-VL is about 1.1 GB, which cannot be an install-time pack. It has to be a fast-follow pack — and fast-follow extraction requires a foreground service. The &lt;code&gt;asset-delivery&lt;/code&gt; library then declares &lt;code&gt;FOREGROUND_SERVICE&lt;/code&gt; and &lt;code&gt;FOREGROUND_SERVICE_DATA_SYNC&lt;/code&gt;, which means a Play foreground-service declaration and a justification video for an app whose entire pitch is an empty permission list.&lt;/p&gt;

&lt;p&gt;We chose the empty permission list. The manifest now strips those permissions too, and ships only the install-time pack.&lt;/p&gt;

&lt;p&gt;That is the real trade-off behind "zero permissions": it is not free, and this is what it cost us. The VLM path stays in the codebase and is measurable, but the current build does not carry it.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Results, stated plainly
&lt;/h2&gt;

&lt;p&gt;Fast tier (the shipped one), official OmniDocBench &lt;code&gt;text_block&lt;/code&gt; edit distance, full 1651-page set, lower is better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text_edit: 0.263
latency:   1.3–1.9 s/page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;strong&gt;not&lt;/strong&gt; leaderboard SOTA, and we are not going to pretend otherwise. It is a fast, stable, fully offline default that handles everyday scanned documents.&lt;/p&gt;

&lt;p&gt;The numbers people usually quote for this model family come from the large VLM tier — the one described in §9 that we did not ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Testing: 338 instrumentation tests
&lt;/h2&gt;

&lt;p&gt;Validation was not "click a few pages." The Android test suite currently has &lt;strong&gt;338 &lt;code&gt;@Test&lt;/code&gt; cases&lt;/strong&gt;, with OCR-relevant coverage including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;searchable PDF writer with invisible text layer;&lt;/li&gt;
&lt;li&gt;replacement of a previous invisible OCR layer;&lt;/li&gt;
&lt;li&gt;reconstructed PDF writer with visible editable text;&lt;/li&gt;
&lt;li&gt;rotated pages;&lt;/li&gt;
&lt;li&gt;OCR text removed by erase/redaction;&lt;/li&gt;
&lt;li&gt;CJK font, editing, search, and page-geometry regressions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also used &lt;code&gt;bundletool --local-testing&lt;/code&gt; to reproduce Play's delivery behavior locally — that is how the 214 MB OOM was caught.&lt;/p&gt;

&lt;p&gt;Generating code is cheap now. These checks are what make it engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What this cost, and what it bought
&lt;/h2&gt;

&lt;p&gt;The hard part was never one model. It was the chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;delivering models without giving the app network capability;&lt;/li&gt;
&lt;li&gt;loading large models without OOM;&lt;/li&gt;
&lt;li&gt;writing results back without destroying the original;&lt;/li&gt;
&lt;li&gt;keeping search, copy, edit, and redaction on one semantic layer;&lt;/li&gt;
&lt;li&gt;surviving PDF font and ToUnicode traps;&lt;/li&gt;
&lt;li&gt;and deciding what to leave out when a feature and a principle collided.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shipped shape is: fast on-device OCR by default, searchable and editable outputs, true redaction that also removes the OCR layer, and no &lt;code&gt;INTERNET&lt;/code&gt; permission in the app.&lt;/p&gt;

&lt;p&gt;You can verify the last one yourself in Settings.&lt;/p&gt;




&lt;p&gt;VaultPDF is on Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.securepdfeditor.pro" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.securepdfeditor.pro&lt;/a&gt;&lt;/p&gt;

</description>
      <category>androiddev</category>
      <category>kotlin</category>
      <category>ocr</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Zero Permissions: how an Android PDF editor ships with no INTERNET permission</title>
      <dc:creator>securepdfeditor</dc:creator>
      <pubDate>Wed, 23 Sep 2026 06:21:28 +0000</pubDate>
      <link>https://dev.to/securepdfeditor_7a1b7f42c/zero-permissions-how-an-android-pdf-editor-ships-with-no-internet-permission-a6l</link>
      <guid>https://dev.to/securepdfeditor_7a1b7f42c/zero-permissions-how-an-android-pdf-editor-ships-with-no-internet-permission-a6l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;How an Android PDF editor ships with no &lt;code&gt;INTERNET&lt;/code&gt; permission — and why that is a different kind of privacy claim&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  1. What the shipped manifest actually contains
&lt;/h2&gt;

&lt;p&gt;Here is the relevant part of the source manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.INTERNET"&lt;/span&gt; &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_NETWORK_STATE"&lt;/span&gt; &lt;span class="na"&gt;tools:node=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;That distinction matters, because the interesting question is not "how do I not write &lt;code&gt;&amp;lt;uses-permission INTERNET&amp;gt;&lt;/code&gt;". It is: &lt;em&gt;why would it have been there in the first place?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because your dependencies put it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your dependencies are declaring permissions for you
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;com.google.mlkit:text-recognition-chinese&lt;/code&gt; — on-device OCR&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;com.google.android.gms:play-services-mlkit-document-scanner&lt;/code&gt; — camera scanning&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;com.android.billingclient:billing-ktx&lt;/code&gt; — in-app purchases&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.android.vending.BILLING"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.securepdfeditor.pro.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  3. The telemetry pipeline that is still in the APK
&lt;/h2&gt;

&lt;p&gt;After stripping &lt;code&gt;INTERNET&lt;/code&gt;, the merged manifest still contains this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.datatransport.runtime.backends.TransportBackendDiscovery"&lt;/span&gt;
    &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt;
        &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"backend:com.google.android.datatransport.cct.CctBackendFactory"&lt;/span&gt;
        &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"cct"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/service&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"&lt;/span&gt;
    &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;
    &lt;span class="na"&gt;android:permission=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.BIND_JOB_SERVICE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;receiver&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.datatransport.runtime.scheduling.jobscheduling.AlarmManagerSchedulerBroadcastReceiver"&lt;/span&gt;
    &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;This is the most useful thing to understand about permission-based privacy: &lt;strong&gt;the permission is the switch, not the code.&lt;/strong&gt; 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 &lt;code&gt;INTERNET&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Billing without &lt;code&gt;INTERNET&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;It doesn't. &lt;strong&gt;The Play Store app does it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;INTERNET&lt;/code&gt; — performs the actual network communication. Your app never touches the network, and never needs to.&lt;/p&gt;

&lt;p&gt;The merged manifest shows both halves of that arrangement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.android.vending.BILLING"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;queries&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;intent&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"com.android.vending.billing.InAppBillingService.BIND"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/intent&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/queries&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;queries&amp;gt;&lt;/code&gt; element declares the intent your app wants to resolve — this is how you find the billing service under Android 11+ package visibility rules. The &lt;code&gt;BILLING&lt;/code&gt; permission is what lets you bind to it.&lt;/p&gt;

&lt;p&gt;So the permission profile is coherent rather than suspicious: an app that holds &lt;code&gt;BILLING&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The billing products in this app are &lt;code&gt;securepdf_monthly_sub&lt;/code&gt; and &lt;code&gt;securepdf_lifetime&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OCR without &lt;code&gt;INTERNET&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The alternative is a bundled model. The dependency here is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.mlkit:text-recognition-chinese:16.0.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is which artifact this is. ML Kit ships in two flavours: a &lt;strong&gt;bundled&lt;/strong&gt; variant, where the model and native pipeline are inside the AAR and get packaged into your APK, and a &lt;strong&gt;Play Services&lt;/strong&gt; 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 &lt;code&gt;INTERNET&lt;/code&gt; to your merged manifest.&lt;/p&gt;

&lt;p&gt;The tradeoff is size. The bundled models and &lt;code&gt;libmlkit_google_ocr_pipeline.so&lt;/code&gt; are in the APK. That is a real cost, paid in download size, in exchange for the recognition working in airplane mode.&lt;/p&gt;

&lt;p&gt;The output is written as an &lt;strong&gt;invisible text layer&lt;/strong&gt; — PDF text render mode &lt;code&gt;3&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OCR-created text uses PDF text render mode 3 (invisible).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;CropBox&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The invisible text layer is a redaction trap
&lt;/h3&gt;

&lt;p&gt;Here is a consequence that is easy to miss, and that matters more than the OCR feature itself.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;TRUE REDACT&lt;/code&gt; removes overlapping OCR invisible text along with visible page objects.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The camera permission you don't need
&lt;/h2&gt;

&lt;p&gt;Document scanning normally means &lt;code&gt;CAMERA&lt;/code&gt;. This project requests no camera permission, and the reason is again architectural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On-device document scanner (camera + edge detection + PDF output run in&lt;/span&gt;
&lt;span class="c1"&gt;// Google Play Services; the app itself needs no CAMERA/INTERNET permission).&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  7. Backup is a data-exit channel
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;This project excludes everything from both paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;data-extraction-rules&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;cloud-backup&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"database"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"sharedpref"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"external"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/cloud-backup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;device-transfer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"database"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"sharedpref"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;exclude&lt;/span&gt; &lt;span class="na"&gt;domain=&lt;/span&gt;&lt;span class="s"&gt;"external"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/device-transfer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/data-extraction-rules&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;code&gt;pdf_fonts&lt;/code&gt;). A font cache sounds like an implementation detail. It is not: &lt;strong&gt;embedded font subsets are derived from the document&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Redaction that deletes rather than covers
&lt;/h2&gt;

&lt;p&gt;The distinction this whole product is built on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Covering&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting&lt;/strong&gt; removes the text from the file's content stream, so there is nothing left to extract.&lt;/p&gt;

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

&lt;p&gt;Two things make this harder than it sounds:&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Text is often not at page level.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The implementation carries both paths, plus a &lt;code&gt;protectedPointers&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;alpha&lt;/strong&gt; (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.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Where this guarantee is weaker
&lt;/h2&gt;

&lt;p&gt;A writeup that only lists strengths is an advertisement. So:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guarantee is strongest on Android, because the OS enforces it.&lt;/strong&gt; No &lt;code&gt;INTERNET&lt;/code&gt; permission means no sockets, enforced below the app. That is a hard, checkable property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On macOS it is an implementation choice, not an enforced one.&lt;/strong&gt; The macOS build is not sandboxed, and there is no &lt;code&gt;com.apple.security.network.client&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing a permission does not remove capability that travels by other means.&lt;/strong&gt; 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 &lt;code&gt;INTERNET&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The telemetry stack described in section 3 is still in the binary.&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  10. Verify it yourself
&lt;/h2&gt;

&lt;p&gt;The point of this class of guarantee is that it is checkable by someone who does not trust the developer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check permissions at runtime.&lt;/strong&gt; Settings → Apps → VaultPDF → Permissions. Nothing should be listed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the manifest without running anything.&lt;/strong&gt; &lt;code&gt;aapt dump permissions &amp;lt;apk&amp;gt;&lt;/code&gt;, or open the APK and read &lt;code&gt;AndroidManifest.xml&lt;/code&gt;. Look for &lt;code&gt;uses-permission&lt;/code&gt; entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the features offline.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the failure mode.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  11. What this cost
&lt;/h2&gt;

&lt;p&gt;Real numbers, from the repository, not rounded up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commits&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First commit&lt;/td&gt;
&lt;td&gt;2026-03-14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last commit&lt;/td&gt;
&lt;td&gt;2026-09-21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android (Kotlin)&lt;/td&gt;
&lt;td&gt;15,658 lines, 32 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS (Swift + Objective-C)&lt;/td&gt;
&lt;td&gt;7,836 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFium JNI bridge (C)&lt;/td&gt;
&lt;td&gt;1,953 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JNI functions exposed&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test files&lt;/td&gt;
&lt;td&gt;31 Android instrumented + 31 XCTest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;minSdk&lt;/code&gt; / &lt;code&gt;targetSdk&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;24 / 36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFium&lt;/td&gt;
&lt;td&gt;built from source for Android, 423 exported &lt;code&gt;FPDF&lt;/code&gt; APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;Privacy claims are usually a promise. This one is a constraint.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;VaultPDF is on Google Play: &lt;strong&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.securepdfeditor.pro" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.securepdfeditor.pro&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>privacy</category>
      <category>security</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
