Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,44 @@ ClearPDF uses open-source components and keeps their notices with the project. R
- License: Apache License 2.0 (as attributed in the Settings screen)
- Use: the app's translucent glass surfaces, backdrop effects, and shared UI components.

## Google ML Kit Text Recognition

- Artifact: `com.google.mlkit:text-recognition:16.0.1`
- License: Google APIs Terms of Service (proprietary; the client library, not the on-device model, carries Apache-2.0-style redistribution terms — see Google's ML Kit terms)
- Use: primary on-device OCR engine for scanned/image-only PDF pages (`ocr-core` module). This is the **bundled** artifact — the recognition model ships inside the APK, not the Play-Services-downloaded variant — so it runs fully offline with no network call and no Play Services requirement.
- Notice practice: comply with Google's ML Kit Terms of Service for redistribution; do not imply Google endorses ClearPDF.

## Tesseract4Android / Tesseract OCR / Leptonica

- Artifact: `cz.adaptech.tesseract4android:tesseract4android:4.9.0` (Copyright 2019 Adaptech s.r.o., Robert Pösel)
- License: Apache License 2.0 (wraps the Tesseract OCR engine, also Apache-2.0, and the Leptonica imaging library, BSD-2-Clause)
- Use: fully open-source, offline OCR fallback (`ocr-core` module) used when the bundled ML Kit engine fails to initialize or recognize on a given device. Bundles `eng.traineddata` (English) as a module asset so the fallback needs no download.
- Notice practice: keep the Apache 2.0 license text and the BSD-2-Clause Leptonica notice available with redistributed builds.

## Feature-set inspiration — Pdf_Tools

- Project: `Karna14314/Pdf_Tools` (https://github.com/Karna14314/Pdf_Tools)
- License: Apache License 2.0
- Use: informed ClearPDF's on-device tool set (e.g. PDF-to-Images export). ClearPDF's implementations are original code written against the app's own architecture and `backdrop` UI; no source was copied. This acknowledgement is provided in good faith for the shared feature direction.

The app does not add GPL or LGPL components for document rendering. If a future dependency changes that, its license and redistribution obligations must be reviewed before release.

## docx-preview (docxjs)

- Artifact: `docx-preview.min.js` 0.4.0, vendored at `app/src/main/assets/docx/`
- License: Apache License 2.0
- Source: https://github.com/VolodymyrBaydalka/docxjs
- Use: lays out .docx documents inside an offscreen WebView, which is then printed to PDF. Chosen
over a native Office engine purely on size — `app.opendocument:odr-core-android` is a 100 MB AAR
and Apache POI's OOXML half is ~17 MB of jars that only parse, not render.
- Notice practice: the upstream Apache-2.0 banner is preserved verbatim at the top of the vendored
file, and the library is credited in Settings → Licenses.

## JSZip

- Artifact: `jszip.min.js` 3.10.1, vendored at `app/src/main/assets/docx/`
- License: MIT (upstream is dual MIT / GPL-3.0; this app uses it under the MIT option)
- Source: https://github.com/Stuk/jszip
- Use: required by docx-preview to read the .docx zip container in the browser context.
- Notice practice: the upstream banner (including its pako attribution) is preserved verbatim at the
top of the vendored file, and the library is credited in Settings → Licenses.
15 changes: 15 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ android {
buildFeatures {
compose = true
}
// Bundling on-device OCR (bundled ML Kit + Tesseract4Android) added native .so libs for
// 4 CPU architectures; without splitting, every install carries all 4. This produces one
// APK per ABI (~1/4 the native-lib weight each) plus a universal fallback for sideloading.
// Play Store distribution via an Android App Bundle (`./gradlew bundleRelease`) already does
// this automatically and needs no config here — this `splits` block only matters for raw
// APK builds/installs (`assembleDebug`/`assembleRelease`, `installDebug`, sideloading).
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
isUniversalApk = true
}
}
packaging {
resources {
excludes += arrayOf(
Expand Down Expand Up @@ -128,6 +142,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(project(":backdrop"))
implementation(project(":pdf-core"))
implementation(project(":ocr-core"))
// Apache POI provides legacy .doc/.xls/.ppt text extraction. It is Apache-2.0
// licensed; see THIRD_PARTY_NOTICES.md for redistribution requirements.
implementation("org.apache.poi:poi:3.17")
Expand Down
15 changes: 15 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,18 @@
-keep class com.google.mlkit.** { *; }
-keep class com.google.android.gms.internal.mlkit_vision_document_scanner.** { *; }
-dontwarn com.google.mlkit.**

# ── .docx viewer (DocxWebRenderer) ──
# These two live in `android.print` on purpose: the print framework's result callbacks have
# package-private constructors, and being in that package is the only way to subclass them and
# drive a PrintDocumentAdapter without the system print dialog. R8 renaming or repackaging them
# would move them out of `android.print` and the access check would fail at runtime — on release
# builds only, which is the worst way to find out. `-keep` pins both the name and the package.
-keep class android.print.OpenLayoutResultCallback { *; }
-keep class android.print.OpenWriteResultCallback { *; }

# The WebView bridge is only ever called from JavaScript, so nothing in the app references these
# methods and R8 would otherwise consider them unused.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
8 changes: 8 additions & 0 deletions app/src/main/assets/docx/docx-preview.min.js

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions app/src/main/assets/docx/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!doctype html>
<!--
Offscreen host page for the .docx viewer.

Loaded from file:///android_asset/docx/, never from the network — and the app declares no
INTERNET permission at all, so the WebView running this page is sandboxed from the network by
the OS regardless of what any script tries. Both libraries below are vendored beside this file.

docx-preview — Apache-2.0, https://github.com/VolodymyrBaydalka/docxjs
JSZip — MIT (dual MIT/GPLv3; used under MIT), https://github.com/Stuk/jszip
-->
<html>
<head>
<meta charset="utf-8">
<script src="jszip.min.js"></script>
<script src="docx-preview.min.js"></script>
<style>
html, body { margin: 0; padding: 0; background: #fff; }

/* docx-preview emits one <section class="docx"> per page it can identify — that is, at explicit
and section breaks, since it does not reflow-paginate. Forcing a page break after each one
keeps those authored breaks landing on real PDF pages; the print pipeline flows whatever
overruns, which is the part docx-preview leaves to the renderer. */
section.docx { break-after: page; page-break-after: always; }
section.docx:last-child { break-after: auto; page-break-after: auto; }

/* The section's own margins are already applied as padding by docx-preview, and the PDF page is
built at the document's real page size, so nothing here may add a second margin. */
@page { margin: 0; }
</style>
</head>
<body>
<div id="container"></div>
<script>
function renderDocx(base64) {
try {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);

docx.renderAsync(bytes.buffer, document.getElementById('container'), null, {
className: 'docx',
// No outer wrapper: its grey page-surround is a screen affordance and would print as a
// grey border around every page.
inWrapper: false,
ignoreWidth: false,
// Let a page box grow to its content instead of clipping it. Content that overruns the
// authored page is then carried onto the next PDF page by the print pagination rather
// than being silently cut off.
ignoreHeight: true,
breakPages: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
// Data URIs rather than blob: object URLs — a blob URL is revoked with its document and
// has been unreliable across the print snapshot.
useBase64URL: true
}).then(function () {
AndroidDocx.onRendered();
}).catch(function (e) {
AndroidDocx.onFailed(String(e));
});
} catch (e) {
AndroidDocx.onFailed(String(e));
}
}
</script>
</body>
</html>
13 changes: 13 additions & 0 deletions app/src/main/assets/docx/jszip.min.js

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions app/src/main/java/android/print/PrintAdapterCallbacks.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package android.print

/**
* Openable subclasses of the print framework's two result callbacks.
*
* `PrintDocumentAdapter.LayoutResultCallback` and `WriteResultCallback` are public abstract classes
* with *package-private constructors* — the framework hands you instances, and the public API gives
* you no way to construct one. That is fine when the system print UI is driving the adapter, but it
* blocks the one thing this app needs: driving a `PrintDocumentAdapter` directly to turn an
* offscreen WebView into a PDF file, with no print dialog and no user interaction.
*
* Declaring these two here — in `android.print`, inside the app's own dex — puts them in the same
* *package* as the constructors they call, which is what the access check compares. Everything else
* about them is ordinary public API.
*
* This is the one genuinely load-bearing assumption in the .docx pipeline, so its single caller
* ([com.chethan616.clearpdf.utils.DocxWebRenderer]) treats any `Throwable` from touching these — a
* verification or access error included — as "this route is unavailable on this device" and falls
* back to the hand-written converter. A device where the trick fails renders .docx exactly as it
* did before; it never fails visibly.
*/
abstract class OpenLayoutResultCallback : PrintDocumentAdapter.LayoutResultCallback()

/** @see OpenLayoutResultCallback */
abstract class OpenWriteResultCallback : PrintDocumentAdapter.WriteResultCallback()
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.chethan616.clearpdf.data.repository

import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest

/**
* Keeps a durable, private copy of every externally-picked document the app opens, keyed by the
* URI the user picked — so a document opened via "Open with ClearPDF" from another app is still
* openable from Recents days later, not just in the minutes right after.
*
* Why this is needed at all: a URI handed to the app in a share/view intent from a foreign app
* (WhatsApp, Gmail, a file manager, ...) carries only a *temporary* read grant. `takePersistableUriPermission`
* is called on every such URI throughout this app, but it only succeeds when the sender actually
* attached `FLAG_GRANT_PERSISTABLE_URI_PERMISSION` — many senders don't, and the call then throws
* and is (correctly) swallowed. Without a persistable grant, Android revokes read access once the
* sending app's task finishes, which on many devices is within minutes, not "after some time" as it
* might appear from inside this app. The document then opened once and can never be read again —
* which is exactly "Failed to open PDF." / "Couldn't read this spreadsheet." from Recents.
*
* SAF picks (this app's own document picker) mostly don't need this — `takePersistableUriPermission`
* on those genuinely persists. This mirror is a no-op safety net for them: [resolve] sees the
* original is still readable and returns it unchanged, only refreshing the mirror file in the
* background for the case where that ever stops being true (a doc-provider revoking access, an SD
* card removed, ...).
*/
object LocalDocumentMirror {
private const val PREFS_NAME = "clearpdf_doc_mirror"

private fun prefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

private fun key(uri: Uri): String {
val digest = MessageDigest.getInstance("SHA-256").digest(uri.toString().toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}

/** App-private and durable — survives a system cache clear, unlike `cacheDir`. */
private fun mirrorDir(context: Context): File {
val base = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: context.filesDir
return File(base, "mirrored").apply { mkdirs() }
}

/**
* The best URI to read [original] from right now.
*
* - If [original] is still readable, this returns it as-is (no detour through a copy for the
* common case) and opportunistically writes/refreshes the mirror for later.
* - If [original] is no longer readable, this returns a previously-saved mirror instead, if one
* exists.
* - If neither is readable — the very first open of a URI whose grant is already gone, which
* this app never had a chance to save — [original] is returned unchanged and the caller's
* normal "couldn't open" handling applies. There is nothing this mirror can do about a file
* it was never able to read even once.
*
* @param extensionHint the source file's real extension (`"pdf"`, `"xlsx"`, `"docx"`, …),
* without a dot — so the mirror's filename still sniffs as the right format downstream.
*/
fun resolve(context: Context, original: Uri, extensionHint: String): Uri {
val originalSize = runCatching {
context.contentResolver.openFileDescriptor(original, "r")?.use { it.statSize }
}.getOrNull()

if (originalSize != null && originalSize > 0L) {
runCatching { mirror(context, original, extensionHint, originalSize) }
return original
}

val savedPath = prefs(context).getString(key(original), null)
val mirrored = savedPath?.let { File(it) }
if (mirrored != null && mirrored.exists() && mirrored.length() > 0L) {
return FileProvider.getUriForFile(context, "${context.packageName}.provider", mirrored)
}
return original
}

/** Copies [original] into durable storage, skipping the copy if an up-to-date one already exists. */
private fun mirror(context: Context, original: Uri, extensionHint: String, currentSize: Long) {
val cleanExt = extensionHint.trimStart('.').ifBlank { "pdf" }
val file = File(mirrorDir(context), "${key(original)}.$cleanExt")

// A document that hasn't changed size since it was last mirrored is treated as unchanged.
// This mirror exists purely as an access-durability net, not a sync mechanism, so a cheap
// heuristic that avoids re-copying on every single open is the right trade-off.
if (file.exists() && file.length() == currentSize) {
prefs(context).edit().putString(key(original), file.absolutePath).apply()
return
}

val tmp = File(file.parentFile, "${file.name}.tmp")
context.contentResolver.openInputStream(original)?.use { input ->
FileOutputStream(tmp).use { input.copyTo(it) }
} ?: return

if (tmp.length() <= 0L) {
tmp.delete()
return
}
file.delete()
if (tmp.renameTo(file)) {
prefs(context).edit().putString(key(original), file.absolutePath).apply()
} else {
tmp.delete()
}
}

/** Forgets and deletes the mirror for one URI — called when its Recents entry is removed. */
fun forget(context: Context, original: Uri) {
val savedPath = prefs(context).getString(key(original), null) ?: return
runCatching { File(savedPath).delete() }
prefs(context).edit().remove(key(original)).apply()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.kyant.pdfcore.viewer.PdfViewer
import com.kyant.pdfcore.viewer.PdfViewerImpl
import com.kyant.pdfcore.text.PdfTextService
import com.kyant.pdfcore.text.PdfTextServiceImpl
import com.kyant.ocrcore.OcrService
import com.kyant.ocrcore.OcrServiceImpl

object PdfServiceLocator {
val pdfViewer: PdfViewer by lazy { PdfViewerImpl() }
Expand All @@ -26,4 +28,6 @@ object PdfServiceLocator {
val pdfEditor: PdfEditor by lazy { PdfEditorImpl() }
val pdfConverter: PdfConverter by lazy { PdfConverterImpl() }
val pdfTextService: PdfTextService by lazy { PdfTextServiceImpl() }
/** Fully on-device OCR — ML Kit primary, Tesseract4Android fallback. No network access. */
val ocrService: OcrService by lazy { OcrServiceImpl() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ object RecentFilesManager {
}

fun clearRecents(context: Context) {
getRecents(context).forEach { LocalDocumentMirror.forget(context, it.uri) }
prefs(context).edit().remove(KEY_RECENTS).apply()
}

fun removeRecent(context: Context, uri: Uri) {
LocalDocumentMirror.forget(context, uri)
val remaining = getRecents(context).filterNot { it.uriString == uri.toString() }
prefs(context).edit().apply {
if (remaining.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,15 @@ fun GlassSearchHeader(
// The bounce rides its own clock so it can overshoot without dragging `progress` — and therefore
// the layout width — past 1. Everything it drives is a draw-time property, so the overshoot costs
// a layer-matrix update, not a re-measure: `drawBackdrop` never re-runs its blur or lens.
// Open only. Overshooting on close drives the scale under its floor and reads as a glitch.
// Symmetric on purpose: this used to spring open with `morph()` but close with the fully
// rigid `settle()` (no overshoot at all), on the theory that overshooting past the field's
// floor scale on the way in would look broken. It doesn't — every use of `bounce` below either
// clamps to [0,1] (the pill's shrink) or tolerates a few percent past its floor for a couple of
// frames (the field's grow-in, the icon's spin) — so there was nothing actually protecting
// against, just an animation that sprang open and then went dead on the way back, closing every
// single time compared with opening.
val bounce by transition.animateFloat(
transitionSpec = { if (targetState) GlassMotion.morph() else GlassMotion.settle() },
transitionSpec = { GlassMotion.morph() },
label = "searchBounce"
) { if (it) 1f else 0f }

Expand Down
Loading
Loading