Compare commits

..

No commits in common. "59973a6b42485b0c430123c8be649fb24689b9f3" and "c74b374ec49a1f47b9879b8fbc7b72b046ef55fd" have entirely different histories.

32 changed files with 388 additions and 2742 deletions

12
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
target-branch: main
- package-ecosystem: gradle
directory: "/"
schedule:
interval: daily
target-branch: main

20
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Build application
on: [pull_request, push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 19
cache: gradle
- name: Build with Gradle
run: ./gradlew build --no-daemon

View File

@ -0,0 +1,11 @@
name: Validate Gradle Wrapper
on: [pull_request, push]
jobs:
validation:
name: Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "pdfjs-dist"]
path = app/pdfjs-dist
url = https://github.com/mozilla/pdfjs-dist.git

View File

@ -1,4 +1,4 @@
Copyright © 2017-2023 GrapheneOS
Copyright © 2017-2022 GrapheneOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,6 +1,11 @@
Fork of GrapheneOS' [PdfViewer](https://github.com/GrapheneOS/PdfViewer) to work as a library.
## Warning !
The only goal of this library is to be integrated in [DroidFS](https://forge.chapril.org/hardcoresushi/DroidFS). Use it at your own risk !
The npm dependency has been removed. Instead, `pdf.min.js` and `pdf.worker.min.js` must be manually placed in `app/pdfjs-dist/build/`.
Simple Android PDF viewer based on pdf.js and content providers. The app
doesn't require any permissions. The PDF stream is fed into the sandboxed
WebView without giving it access to content or files. Content-Security-Policy
is used to enforce that the JavaScript and styling properties within the
WebView are entirely static content from the apk assets. It reuses the hardened
Chromium rendering stack while only exposing a tiny subset of the attack
surface compared to actual web content. The PDF rendering code itself is memory
safe with dynamic code evaluation disabled, and even if an attacker did gain
code execution by exploiting the underlying web rendering engine, they're
within the Chromium renderer sandbox with no access to the network (unlike a
browser), files, or other content.

View File

@ -13,12 +13,6 @@ plugins {
id("kotlin-android")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
android {
if (useKeystoreProperties) {
signingConfigs {
@ -39,7 +33,7 @@ android {
}
compileSdk = 33
buildToolsVersion = "33.0.2"
buildToolsVersion = "33.0.0"
namespace = "app.grapheneos.pdfviewer"
@ -67,21 +61,20 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
}
}
compileOptions {
sourceCompatibility(JavaVersion.VERSION_17)
targetCompatibility(JavaVersion.VERSION_17)
sourceCompatibility(JavaVersion.VERSION_11)
targetCompatibility(JavaVersion.VERSION_11)
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = JavaVersion.VERSION_11.toString()
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.9.0")
implementation("androidx.appcompat:appcompat:1.5.1")
implementation("com.google.android.material:material:1.7.0")
}

View File

@ -1,8 +1,3 @@
:root {
--text-layer-opacity: 0.2;
--text-layer-foreground: transparent;
}
html, body {
height: 100%;
}
@ -17,8 +12,6 @@ body {
}
#container {
--scale-factor: 1;
width: 100%;
height: 100%;
display: grid;
@ -31,26 +24,25 @@ body {
grid-column-start: 1;
}
canvas {
canvas, .textLayer {
display: inline-block;
position: relative;
}
.textLayer {
text-align: initial;
position: absolute;
overflow: hidden;
opacity: var(--text-layer-opacity);
opacity: 0.2;
line-height: 1;
}
.textLayer span,
.textLayer br {
color: var(--text-layer-foreground);
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0 0;
transform-origin: 0% 0%;
}
.textLayer .highlight {
@ -99,13 +91,3 @@ canvas {
.textLayer .endOfContent.active {
top: 0;
}
[data-main-rotation="90"] {
transform: rotate(90deg);
}
[data-main-rotation="180"] {
transform: rotate(180deg);
}
[data-main-rotation="270"] {
transform: rotate(270deg);
}

View File

@ -6,8 +6,7 @@ let pdfDoc = null;
let pageRendering = false;
let renderPending = false;
let renderPendingZoom = 0;
const canvas = document.getElementById("content");
const container = document.getElementById("container");
const canvas = document.getElementById('content');
let orientationDegrees = 0;
let zoomRatio = 1;
let textLayerDiv = document.getElementById("text");
@ -20,8 +19,6 @@ let useRender;
const cache = [];
const maxCached = 6;
let isTextLayerVisible = false;
function maybeRenderNextPage() {
if (renderPending) {
pageRendering = false;
@ -64,21 +61,6 @@ function display(newCanvas, zoom) {
}
}
function setLayerTransform(pageWidth, pageHeight, layerDiv) {
const translate = {
X: Math.max(0, pageWidth - document.body.clientWidth) / 2,
Y: Math.max(0, pageHeight - document.body.clientHeight) / 2
};
layerDiv.style.translate = `${translate.X}px ${translate.Y}px`;
}
function getDefaultZoomRatio(page, orientationDegrees) {
const viewport = page.getViewport({scale: 1, rotation: orientationDegrees});
const widthZoomRatio = document.body.clientWidth / viewport.width;
const heightZoomRatio = document.body.clientHeight / viewport.height;
return Math.max(Math.min(widthZoomRatio, heightZoomRatio, channel.getMaxZoomRatio()), channel.getMinZoomRatio());
}
function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
pageRendering = true;
useRender = !prerender;
@ -100,8 +82,6 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
textLayerDiv.replaceWith(cached.textLayerDiv);
textLayerDiv = cached.textLayerDiv;
setLayerTransform(cached.pageWidth, cached.pageHeight, textLayerDiv);
container.style.setProperty("--scale-factor", newZoomRatio.toString());
}
pageRendering = false;
@ -115,15 +95,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
return;
}
const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees);
if (cache.length === 0) {
zoomRatio = defaultZoomRatio;
newZoomRatio = defaultZoomRatio;
channel.setZoomRatio(defaultZoomRatio);
}
const viewport = page.getViewport({scale: newZoomRatio, rotation: orientationDegrees});
const viewport = page.getViewport({scale: newZoomRatio, rotation: orientationDegrees})
if (useRender) {
if (newZoomRatio !== zoomRatio) {
@ -133,7 +105,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
zoomRatio = newZoomRatio;
}
if (zoom === 2) {
if (zoom == 2) {
pageRendering = false;
return;
}
@ -165,10 +137,10 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
}
render();
const newTextLayerDiv = textLayerDiv.cloneNode();
const textLayerFrag = document.createDocumentFragment();
task = pdfjsLib.renderTextLayer({
textContentSource: page.streamTextContent(),
container: newTextLayerDiv,
textContentStream: page.streamTextContent(),
container: textLayerFrag,
viewport: viewport
});
task.promise.then(function() {
@ -176,36 +148,24 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger=0) {
render();
// We use CSS transform to rotate a text layer div of zero
// degrees rotation. So, when the rotation is 90 or 270
// degrees, set width and height of the text layer div to the
// height and width of the canvas, respectively, to prevent
// text layer misalignment.
if (orientationDegrees % 180 === 0) {
newTextLayerDiv.style.height = newCanvas.style.height;
newTextLayerDiv.style.width = newCanvas.style.width;
} else {
newTextLayerDiv.style.height = newCanvas.style.width;
newTextLayerDiv.style.width = newCanvas.style.height;
}
setLayerTransform(viewport.width, viewport.height, newTextLayerDiv);
const newTextLayerDiv = textLayerDiv.cloneNode();
newTextLayerDiv.style.height = newCanvas.style.height;
newTextLayerDiv.style.width = newCanvas.style.width;
if (useRender) {
textLayerDiv.replaceWith(newTextLayerDiv);
textLayerDiv = newTextLayerDiv;
container.style.setProperty("--scale-factor", newZoomRatio.toString());
}
newTextLayerDiv.appendChild(textLayerFrag);
if (cache.length === maxCached) {
cache.shift();
cache.shift()
}
cache.push({
pageNumber: pageNumber,
zoomRatio: newZoomRatio,
orientationDegrees: orientationDegrees,
canvas: newCanvas,
textLayerDiv: newTextLayerDiv,
pageWidth: viewport.width,
pageHeight: viewport.height
textLayerDiv: newTextLayerDiv
});
pageRendering = false;
@ -238,18 +198,6 @@ function isTextSelected() {
return window.getSelection().toString() !== "";
}
function toggleTextLayerVisibility() {
let textLayerForeground = "red";
let textLayerOpacity = 1;
if (isTextLayerVisible) {
textLayerForeground = "transparent";
textLayerOpacity = 0.2;
}
document.documentElement.style.setProperty("--text-layer-foreground", textLayerForeground);
document.documentElement.style.setProperty("--text-layer-opacity", textLayerOpacity.toString());
isTextLayerVisible = !isTextLayerVisible;
}
function loadDocument() {
const pdfPassword = channel.getPassword();
const loadingTask = pdfjsLib.getDocument({ url: "https://localhost/placeholder.pdf", password: pdfPassword });
@ -259,7 +207,7 @@ function loadDocument() {
} else if (error === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD) {
channel.invalidPassword();
}
};
}
loadingTask.promise.then(function (newDoc) {
channel.onLoaded();
@ -275,7 +223,3 @@ function loadDocument() {
console.error(reason.name + ": " + reason.message);
});
}
window.onresize = () => {
setLayerTransform(canvas.clientWidth, canvas.clientHeight, textLayerDiv);
};

View File

@ -42,14 +42,12 @@ import app.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment;
import app.grapheneos.pdfviewer.fragment.PasswordPromptFragment;
import app.grapheneos.pdfviewer.fragment.JumpToPageFragment;
import app.grapheneos.pdfviewer.ktx.ViewKt;
import app.grapheneos.pdfviewer.loader.DocumentPropertiesAsyncTaskLoader;
import app.grapheneos.pdfviewer.loader.DocumentPropertiesLoader;
import app.grapheneos.pdfviewer.viewModel.PasswordStatus;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@ -57,7 +55,7 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
public static final String TAG = "PdfViewer";
private static final String KEY_PROPERTIES = "properties";
private static final int MIN_WEBVIEW_RELEASE = 92;
private static final int MIN_WEBVIEW_RELEASE = 89;
private static final String CONTENT_SECURITY_POLICY =
"default-src 'none'; " +
@ -100,7 +98,7 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
"usb=(), " +
"xr-spatial-tracking=()";
private static final float MIN_ZOOM_RATIO = 0.2f;
private static final float MIN_ZOOM_RATIO = 0.5f;
private static final float MAX_ZOOM_RATIO = 1.5f;
private static final int ALPHA_LOW = 130;
private static final int ALPHA_HIGH = 255;
@ -138,21 +136,6 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
return mZoomRatio;
}
@JavascriptInterface
public void setZoomRatio(final float ratio) {
mZoomRatio = Math.max(Math.min(ratio, MAX_ZOOM_RATIO), MIN_ZOOM_RATIO);
}
@JavascriptInterface
public float getMinZoomRatio() {
return MIN_ZOOM_RATIO;
}
@JavascriptInterface
public float getMaxZoomRatio() {
return MAX_ZOOM_RATIO;
}
@JavascriptInterface
public int getDocumentOrientationDegrees() {
return mDocumentOrientationDegrees;
@ -172,7 +155,7 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
final Bundle args = new Bundle();
args.putString(KEY_PROPERTIES, properties);
activity.runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this.activity).restartLoader(DocumentPropertiesAsyncTaskLoader.ID, args, PdfViewer.this));
activity.runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this.activity).restartLoader(DocumentPropertiesLoader.ID, args, PdfViewer.this));
}
@JavascriptInterface
@ -371,15 +354,6 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
return mPasswordPromptFragment;
}
private void setToolbarTitleWithDocumentName() {
String documentName = getCurrentDocumentName();
if (documentName != null && !documentName.isEmpty()) {
activity.getSupportActionBar().setTitle(documentName);
} else {
activity.getSupportActionBar().setTitle(R.string.app_name);
}
}
public void onResume() {
// The user could have left the activity to update the WebView
activity.invalidateOptionsMenu();
@ -405,14 +379,13 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
@NonNull
@Override
public Loader<List<CharSequence>> onCreateLoader(int id, Bundle args) {
return new DocumentPropertiesAsyncTaskLoader(activity, args.getString(KEY_PROPERTIES), mNumPages, fileName, fileSize);
return new DocumentPropertiesLoader(activity, args.getString(KEY_PROPERTIES), mNumPages, fileName, fileSize);
}
@Override
public void onLoadFinished(@NonNull Loader<List<CharSequence>> loader, List<CharSequence> data) {
mDocumentProperties = data;
setToolbarTitleWithDocumentName();
LoaderManager.getInstance(activity).destroyLoader(DocumentPropertiesAsyncTaskLoader.ID);
LoaderManager.getInstance(activity).destroyLoader(DocumentPropertiesLoader.ID);
}
@Override
@ -512,19 +485,12 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
public void onCreateOptionMenu(@NonNull Menu menu) {
MenuInflater inflater = activity.getMenuInflater();
inflater.inflate(R.menu.pdf_viewer, menu);
if (BuildConfig.DEBUG) {
inflater.inflate(R.menu.pdf_viewer_debug, menu);
}
}
public boolean onPrepareOptionsMenu(@NonNull Menu menu) {
final ArrayList<Integer> ids = new ArrayList<>(Arrays.asList(R.id.action_jump_to_page,
R.id.action_next, R.id.action_previous, R.id.action_first, R.id.action_last,
R.id.action_rotate_clockwise, R.id.action_rotate_counterclockwise,
R.id.action_view_document_properties));
if (BuildConfig.DEBUG) {
ids.add(R.id.debug_action_toggle_text_layer_visibility);
}
final int[] ids = {R.id.action_jump_to_page, R.id.action_next, R.id.action_previous,
R.id.action_first, R.id.action_last, R.id.action_rotate_clockwise,
R.id.action_rotate_counterclockwise, R.id.action_view_document_properties};
if (mDocumentState < STATE_LOADED) {
for (final int id : ids) {
final MenuItem item = menu.findItem(id);
@ -574,29 +540,10 @@ public class PdfViewer implements LoaderManager.LoaderCallbacks<List<CharSequenc
.show(activity.getSupportFragmentManager(), DocumentPropertiesFragment.TAG);
return true;
} else if (itemId == R.id.action_jump_to_page) {
new JumpToPageFragment(this)
JumpToPageFragment.newInstance(this)
.show(activity.getSupportFragmentManager(), JumpToPageFragment.TAG);
return true;
} else if (itemId == R.id.debug_action_toggle_text_layer_visibility) {
binding.webview.evaluateJavascript("toggleTextLayerVisibility()", null);
return true;
}
return false;
}
private String getCurrentDocumentName() {
if (mDocumentProperties == null || mDocumentProperties.isEmpty()) return "";
String fileName = "";
String title = "";
for (CharSequence property : mDocumentProperties) {
if (property.toString().startsWith("File name:")) {
fileName = property.toString().replace("File name:", "");
}
if (property.toString().startsWith("Title:-")) {
title = property.toString().replace("Title:-", "");
}
}
return fileName.length() > 2 ? fileName : title;
}
}

View File

@ -9,6 +9,21 @@ import java.text.ParseException;
import java.util.Calendar;
public class Utils {
public static String parseFileSize(long fileSize) {
final double kb = fileSize / 1000d;
if (kb == 0d) {
return fileSize + " Bytes";
}
final DecimalFormat format = new DecimalFormat("#.##");
format.setRoundingMode(RoundingMode.CEILING);
if (kb < 1000) {
return format.format(kb) + " kB (" + fileSize + " Bytes)";
}
return format.format(kb / 1000) + " MB (" + fileSize + " Bytes)";
}
private static int parseIntSafely(String field) throws ParseException {
try {

View File

@ -0,0 +1,60 @@
package app.grapheneos.pdfviewer.fragment;
import android.app.Activity;
import android.app.Dialog;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import app.grapheneos.pdfviewer.R;
import java.util.ArrayList;
import java.util.List;
public class DocumentPropertiesFragment extends DialogFragment {
public static final String TAG = "DocumentPropertiesFragment";
private static final String KEY_DOCUMENT_PROPERTIES = "document_properties";
private List<String> mDocumentProperties;
public static DocumentPropertiesFragment newInstance(final List<CharSequence> metaData) {
final DocumentPropertiesFragment fragment = new DocumentPropertiesFragment();
final Bundle args = new Bundle();
args.putCharSequenceArrayList(KEY_DOCUMENT_PROPERTIES, (ArrayList<CharSequence>) metaData);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
mDocumentProperties = getArguments().getStringArrayList(KEY_DOCUMENT_PROPERTIES);
}
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
final Activity activity = requireActivity();
final MaterialAlertDialogBuilder dialog = new MaterialAlertDialogBuilder(activity)
.setPositiveButton(android.R.string.ok, null);
if (mDocumentProperties != null) {
dialog.setTitle(getString(R.string.action_view_document_properties));
dialog.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1,
mDocumentProperties), null);
} else {
dialog.setTitle(R.string.document_properties_retrieval_failed);
}
return dialog.create();
}
}

View File

@ -1,53 +0,0 @@
package app.grapheneos.pdfviewer.fragment
import android.app.Dialog
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.fragment.app.DialogFragment
import app.grapheneos.pdfviewer.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class DocumentPropertiesFragment : DialogFragment() {
// TODO replace with nav args once the `PdfViewer` activity is converted to kotlin
private val mDocumentProperties: List<String> by lazy {
requireArguments().getStringArrayList(KEY_DOCUMENT_PROPERTIES)?.toList() ?: emptyList()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireActivity())
.setPositiveButton(android.R.string.ok, null).apply {
if (mDocumentProperties.isNotEmpty()) {
setTitle(getString(R.string.action_view_document_properties))
setAdapter(
ArrayAdapter(
requireActivity(),
android.R.layout.simple_list_item_1,
mDocumentProperties
), null
)
} else {
setTitle(R.string.document_properties_retrieval_failed)
}
}
.create()
}
companion object {
const val TAG = "DocumentPropertiesFragment"
private const val KEY_DOCUMENT_PROPERTIES = "document_properties"
@JvmStatic
fun newInstance(metaData: List<CharSequence>): DocumentPropertiesFragment {
val fragment = DocumentPropertiesFragment()
val args = Bundle()
args.putCharSequenceArrayList(
KEY_DOCUMENT_PROPERTIES,
metaData as ArrayList<CharSequence>
)
fragment.arguments = args
return fragment
}
}
}

View File

@ -0,0 +1,62 @@
package app.grapheneos.pdfviewer.fragment;
import android.app.Dialog;
import android.os.Bundle;
import android.view.Gravity;
import android.widget.FrameLayout;
import android.widget.NumberPicker;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import app.grapheneos.pdfviewer.PdfViewer;
public class JumpToPageFragment extends DialogFragment {
public static final String TAG = "JumpToPageFragment";
private final static String STATE_PICKER_CUR = "picker_cur";
private final static String STATE_PICKER_MIN = "picker_min";
private final static String STATE_PICKER_MAX = "picker_max";
private NumberPicker mPicker;
PdfViewer pdfViewer;
public static JumpToPageFragment newInstance(PdfViewer pdfViewer) {
JumpToPageFragment f = new JumpToPageFragment();
f.pdfViewer = pdfViewer;
return f;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
mPicker = new NumberPicker(getActivity());
mPicker.setMinValue(1);
mPicker.setMaxValue(pdfViewer.mNumPages);
mPicker.setValue(pdfViewer.mPage);
final FrameLayout layout = new FrameLayout(getActivity());
layout.addView(mPicker, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER));
return new MaterialAlertDialogBuilder(requireActivity())
.setView(layout)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
mPicker.clearFocus();
pdfViewer.onJumpToPageInDocument(mPicker.getValue());
})
.setNegativeButton(android.R.string.cancel, null)
.create();
}
@Override
public void onSaveInstanceState(Bundle outState) {
outState.putInt(STATE_PICKER_MIN, mPicker.getMinValue());
outState.putInt(STATE_PICKER_MAX, mPicker.getMaxValue());
outState.putInt(STATE_PICKER_CUR, mPicker.getValue());
}
}

View File

@ -1,58 +0,0 @@
package app.grapheneos.pdfviewer.fragment
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.NumberPicker
import androidx.fragment.app.DialogFragment
import app.grapheneos.pdfviewer.PdfViewer
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class JumpToPageFragment(private val pdfViewer: PdfViewer) : DialogFragment() {
companion object {
const val TAG = "JumpToPageFragment"
private const val STATE_PICKER_CUR = "picker_cur"
private const val STATE_PICKER_MIN = "picker_min"
private const val STATE_PICKER_MAX = "picker_max"
}
private val mPicker: NumberPicker by lazy { NumberPicker(requireActivity()) }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (savedInstanceState != null) {
mPicker.minValue = savedInstanceState.getInt(STATE_PICKER_MIN)
mPicker.maxValue = savedInstanceState.getInt(STATE_PICKER_MAX)
mPicker.value = savedInstanceState.getInt(STATE_PICKER_CUR)
} else {
mPicker.minValue = 1
mPicker.maxValue = pdfViewer.mNumPages
mPicker.value = pdfViewer.mPage
}
val layout = FrameLayout(requireActivity())
layout.addView(
mPicker, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
)
)
return MaterialAlertDialogBuilder(requireActivity())
.setView(layout)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
mPicker.clearFocus()
pdfViewer.onJumpToPageInDocument(mPicker.value)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(STATE_PICKER_MIN, mPicker.minValue)
outState.putInt(STATE_PICKER_MAX, mPicker.maxValue)
outState.putInt(STATE_PICKER_CUR, mPicker.value)
}
}

View File

@ -1,51 +0,0 @@
package app.grapheneos.pdfviewer.loader;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.loader.content.AsyncTaskLoader;
import java.util.List;
public class DocumentPropertiesAsyncTaskLoader extends AsyncTaskLoader<List<CharSequence>> {
public static final String TAG = "DocumentPropertiesLoader";
public static final int ID = 1;
private final String mProperties;
private final int mNumPages;
String fileName;
Long fileSize;
public DocumentPropertiesAsyncTaskLoader(Context context, String properties, int numPages, String fileName, Long fileSize) {
super(context);
mProperties = properties;
mNumPages = numPages;
this.fileName = fileName;
this.fileSize = fileSize;
}
@Override
protected void onStartLoading() {
forceLoad();
}
@Nullable
@Override
public List<CharSequence> loadInBackground() {
DocumentPropertiesLoader loader = new DocumentPropertiesLoader(
getContext(),
mProperties,
mNumPages,
fileName,
fileSize
);
return loader.loadAsList();
}
}

View File

@ -0,0 +1,115 @@
package app.grapheneos.pdfviewer.loader;
import android.content.Context;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.StyleSpan;
import android.util.Log;
import androidx.loader.content.AsyncTaskLoader;
import org.json.JSONException;
import org.json.JSONObject;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;
import app.grapheneos.pdfviewer.R;
import app.grapheneos.pdfviewer.Utils;
public class DocumentPropertiesLoader extends AsyncTaskLoader<List<CharSequence>> {
public static final String TAG = "DocumentPropertiesLoader";
public static final int ID = 1;
private final String mProperties;
private final int mNumPages;
String fileName;
Long fileSize;
public DocumentPropertiesLoader(Context context, String properties, int numPages, String fileName, Long fileSize) {
super(context);
mProperties = properties;
mNumPages = numPages;
this.fileName = fileName;
this.fileSize = fileSize;
}
@Override
public List<CharSequence> loadInBackground() {
final Context context = getContext();
final String[] names = context.getResources().getStringArray(R.array.property_names);
final List<CharSequence> properties = new ArrayList<>(names.length);
properties.add(getProperty(null, names[0], fileName));
properties.add(getProperty(null, names[1], Utils.parseFileSize(fileSize)));
try {
final JSONObject json = new JSONObject(mProperties);
properties.add(getProperty(json, names[2], "Title"));
properties.add(getProperty(json, names[3], "Author"));
properties.add(getProperty(json, names[4], "Subject"));
properties.add(getProperty(json, names[5], "Keywords"));
properties.add(getProperty(json, names[6], "CreationDate"));
properties.add(getProperty(json, names[7], "ModDate"));
properties.add(getProperty(json, names[8], "Producer"));
properties.add(getProperty(json, names[9], "Creator"));
properties.add(getProperty(json, names[10], "PDFFormatVersion"));
properties.add(getProperty(null, names[11], String.valueOf(mNumPages)));
return properties;
} catch (JSONException e) {
e.printStackTrace();
}
return null;
}
@Override
public void deliverResult(List<CharSequence> properties) {
if (!isReset() && isStarted()) {
super.deliverResult(properties);
}
}
@Override
protected void onStartLoading() {
forceLoad();
}
@Override
protected void onStopLoading() {
cancelLoad();
}
@Override
protected void onReset() {
super.onReset();
onStopLoading();
}
private CharSequence getProperty(final JSONObject json, String name, String specName) {
final SpannableStringBuilder property = new SpannableStringBuilder(name).append(":\n");
final String value = json != null ? json.optString(specName, "-") : specName;
if (specName != null && specName.endsWith("Date")) {
final Context context = getContext();
try {
property.append(value.equals("-") ? value : Utils.parseDate(value));
} catch (ParseException e) {
Log.w(TAG, e.getMessage() + " for " + value + " at offset: " + e.getErrorOffset());
property.append(context.getString(R.string.document_properties_invalid_date));
}
} else {
property.append(value);
}
property.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return property;
}
}

View File

@ -1,88 +0,0 @@
package app.grapheneos.pdfviewer.loader
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.Formatter
import android.text.style.StyleSpan
import android.util.Log
import app.grapheneos.pdfviewer.R
import org.json.JSONException
class DocumentPropertiesLoader(
private val context: Context,
private val properties: String,
private val numPages: Int,
private val fileName: String,
private val fileSize: Long,
) {
fun loadAsList(): List<CharSequence> {
return load().map { item ->
val name = context.getString(item.key.nameResource)
val value = item.value
SpannableStringBuilder()
.append(name)
.append(":\n")
.append(value)
.apply {
setSpan(
StyleSpan(Typeface.BOLD),
0,
name.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
private fun load(): Map<DocumentProperty, String> {
val result = mutableMapOf<DocumentProperty, String>()
result.addFileProperties()
result.addPageSizeProperty()
result.addPDFJsProperties()
return result
}
private fun MutableMap<DocumentProperty, String>.addPageSizeProperty() {
this[DocumentProperty.Pages] = java.lang.String.valueOf(numPages)
}
private fun MutableMap<DocumentProperty, String>.addFileProperties() {
putAll(getFileProperties())
}
private fun MutableMap<DocumentProperty, String>.addPDFJsProperties() {
putAll(getPDFJsProperties())
}
private fun getPDFJsProperties(): Map<DocumentProperty, String> {
return try {
PDFJsPropertiesToDocumentPropertyConverter(
properties,
context.getString(R.string.document_properties_invalid_date),
parseExceptionListener = { parseException, value ->
Log.w(
DocumentPropertiesAsyncTaskLoader.TAG,
"${parseException.message} for $value at offset: ${parseException.errorOffset}"
)
}
).convert()
} catch (e: JSONException) {
Log.w(
DocumentPropertiesAsyncTaskLoader.TAG,
"invalid properties"
)
emptyMap()
}
}
private fun getFileProperties(): Map<DocumentProperty, String> {
val collections = mutableMapOf<DocumentProperty, String>()
collections[DocumentProperty.FileName] = fileName
collections[DocumentProperty.FileSize] = Formatter.formatFileSize(context, fileSize)
return collections
}
}

View File

@ -1,35 +0,0 @@
package app.grapheneos.pdfviewer.loader
import androidx.annotation.StringRes
import app.grapheneos.pdfviewer.R
private const val TITLE_KEY = "Title"
private const val AUTHOR_KEY = "Author"
private const val SUBJECT_KEY = "Subject"
private const val KEYWORDS_KEY = "Keywords"
private const val CREATION_DATE_KEY = "CreationDate"
private const val MODIFY_DATE_KEY = "ModDate"
private const val PRODUCER_KEY = "Producer"
private const val CREATOR_KEY = "Creator"
private const val PDF_VERSION_KEY = "PDFFormatVersion"
const val DEFAULT_VALUE = "-"
enum class DocumentProperty(
val key: String = "",
@StringRes val nameResource: Int,
val isDate: Boolean = false
) {
FileName(key = "", nameResource = R.string.file_name),
FileSize(key = "", nameResource = R.string.file_size),
Pages(key = "", nameResource = R.string.pages),
Title(key = TITLE_KEY, nameResource = R.string.title),
Author(key = AUTHOR_KEY, nameResource = R.string.author),
Subject(key = SUBJECT_KEY, nameResource = R.string.subject),
Keywords(key = KEYWORDS_KEY, nameResource = R.string.keywords),
CreationDate(key = CREATION_DATE_KEY, nameResource = R.string.creation_date, isDate = true),
ModifyDate(key = MODIFY_DATE_KEY, nameResource = R.string.modify_date, isDate = true),
Producer(key = PRODUCER_KEY, nameResource = R.string.producer),
Creator(key = CREATOR_KEY, nameResource = R.string.creator),
PDFVersion(key = PDF_VERSION_KEY, nameResource = R.string.pdf_version);
}

View File

@ -1,47 +0,0 @@
package app.grapheneos.pdfviewer.loader
import app.grapheneos.pdfviewer.Utils
import org.json.JSONException
import org.json.JSONObject
import java.text.ParseException
import kotlin.jvm.Throws
class PDFJsPropertiesToDocumentPropertyConverter(
private val properties: String,
private val propertyInvalidDate: String,
private val parseExceptionListener: (e: ParseException, value: String) -> Unit
) {
@Throws(JSONException::class)
fun convert(): Map<DocumentProperty, String> {
val result = mutableMapOf<DocumentProperty, String>()
val json = JSONObject(properties)
addJsonProperties(json, result)
return result
}
private fun addJsonProperties(
json: JSONObject,
collections: MutableMap<DocumentProperty, String>
) {
for (documentProperty in DocumentProperty.values()) {
val key = documentProperty.key
if (key.isEmpty()) continue
val value = json.optString(key, DEFAULT_VALUE)
collections[documentProperty] = prettify(documentProperty, value)
}
}
private fun prettify(property: DocumentProperty, value: String): String {
if (value != DEFAULT_VALUE && property.isDate) {
return try {
Utils.parseDate(value)
} catch (parseException: ParseException) {
parseExceptionListener.invoke(parseException, value)
propertyInvalidDate
}
}
return value
}
}

View File

@ -11,13 +11,13 @@
android:id="@+id/action_previous"
android:icon="@drawable/ic_navigate_before_24dp"
android:title="@string/action_previous"
app:showAsAction="always" />
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_next"
android:icon="@drawable/ic_navigate_next_24dp"
android:title="@string/action_next"
app:showAsAction="always" />
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_first"

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/debug_action_toggle_text_layer_visibility"
android:title="@string/debug_action_toggle_text_layer_visibility"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="property_names">
<item>File name</item>
<item>File size</item>
<item>Title</item>
<item>Author</item>
<item>Subject</item>
<item>Keywords</item>
<item>Creation date</item>
<item>Modify date</item>
<item>Producer</item>
<item>Creator</item>
<item>PDF version</item>
<item>Pages</item>
</string-array>
</resources>

View File

@ -11,8 +11,6 @@
<string name="action_rotate_counterclockwise">Rotate counterclockwise</string>
<string name="action_view_document_properties">Properties</string>
<string name="debug_action_toggle_text_layer_visibility">Toggle text layer visibility</string>
<string name="document_properties_invalid_date">Invalid date</string>
<string name="document_properties_retrieval_failed">Failed to obtain document metadata</string>
@ -23,17 +21,4 @@
<string name="password_prompt_description">Enter the password to decrypt this PDF file</string>
<string name="open">Open</string>
<string name="cancel">Cancel</string>
<string name="file_name">File name</string>
<string name="file_size">File size</string>
<string name="title">Title</string>
<string name="author">Author</string>
<string name="subject">Subject</string>
<string name="keywords">Keywords</string>
<string name="creation_date">Creation date</string>
<string name="modify_date">Modify date</string>
<string name="producer">Producer</string>
<string name="creator">Creator</string>
<string name="pdf_version">PDF version</string>
<string name="pages">Pages</string>
</resources>

View File

@ -1,6 +1,18 @@
plugins {
id("com.android.application") apply false
id("org.jetbrains.kotlin.android") apply false
buildscript {
repositories {
// dependabot cannot handle google()
maven {
url = uri("https://dl.google.com/dl/android/maven2")
}
// dependabot cannot handle mavenCentral()
maven {
url = uri("https://repo.maven.apache.org/maven2")
}
}
dependencies {
classpath("com.android.tools.build:gradle:7.3.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22")
}
}
allprojects {

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableR8.fullMode=true
kotlin.code.style=official

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,8 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
distributionSha256Sum=f6b8596b10cce501591e92f229816aa4046424f3b24d771751b06779d58c8ec4
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

24
gradlew vendored
View File

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -80,11 +80,14 @@ do
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -130,29 +133,22 @@ location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -197,10 +193,6 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in

1
gradlew.bat vendored
View File

@ -26,7 +26,6 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

View File

@ -1,9 +1,3 @@
pluginManagement {
repositories {
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {