Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for PDF outlines #437

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ android {
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.fragment:fragment-ktx:1.8.5")
implementation("com.google.android.material:material:1.12.0")
}

Expand Down
19 changes: 18 additions & 1 deletion app/src/main/java/app/grapheneos/pdfviewer/KtUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package app.grapheneos.pdfviewer

import android.content.Context
import android.net.Uri
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
Expand All @@ -14,7 +18,6 @@ import java.io.OutputStream
OutOfMemoryError::class
)
fun saveAs(context: Context, existingUri: Uri, saveAs: Uri) {

context.asInputStream(existingUri)?.use { inputStream ->
context.asOutputStream(saveAs)?.use { outputStream ->
outputStream.write(inputStream.readBytes())
Expand All @@ -23,6 +26,20 @@ fun saveAs(context: Context, existingUri: Uri, saveAs: Uri) {

}

fun applySystemBarMargins(view: View, applyBottom: Boolean = false) {
ViewCompat.setOnApplyWindowInsetsListener(view) { v: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val mlp = v.layoutParams as MarginLayoutParams
mlp.leftMargin = insets.left
mlp.rightMargin = insets.right
if (applyBottom) {
mlp.bottomMargin = insets.bottom
}
v.layoutParams = mlp
windowInsets
}
}

@Throws(FileNotFoundException::class)
private fun Context.asInputStream(uri: Uri): InputStream? = contentResolver.openInputStream(uri)

Expand Down
84 changes: 57 additions & 27 deletions app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
Expand All @@ -30,32 +29,31 @@
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;

import com.google.android.material.snackbar.Snackbar;

import app.grapheneos.pdfviewer.databinding.PdfviewerBinding;
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.viewModel.PasswordStatus;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;

import app.grapheneos.pdfviewer.databinding.PdfviewerBinding;
import app.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment;
import app.grapheneos.pdfviewer.fragment.JumpToPageFragment;
import app.grapheneos.pdfviewer.fragment.PasswordPromptFragment;
import app.grapheneos.pdfviewer.ktx.ViewKt;
import app.grapheneos.pdfviewer.loader.DocumentPropertiesAsyncTaskLoader;
import app.grapheneos.pdfviewer.outline.OutlineFragment;
import app.grapheneos.pdfviewer.viewModel.PdfViewModel;

public class PdfViewer extends AppCompatActivity implements LoaderManager.LoaderCallbacks<List<CharSequence>> {
public static final String TAG = "PdfViewer";

Expand Down Expand Up @@ -134,7 +132,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader
private Toast mToast;
private Snackbar snackbar;
private PasswordPromptFragment mPasswordPromptFragment;
public PasswordStatus passwordValidationViewModel;
public PdfViewModel viewModel;

private final ActivityResultLauncher<Intent> openDocumentLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), result -> {
Expand All @@ -146,6 +144,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader
mPage = 1;
mDocumentProperties = null;
mEncryptedDocumentPassword = "";
viewModel.clearOutline();
loadPdf();
invalidateOptionsMenu();
}
Expand All @@ -165,6 +164,11 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader
});

private class Channel {
@JavascriptInterface
public void setDocumentOutline(final String outline) {
viewModel.parseOutlineString(outline);
}

@JavascriptInterface
public int getPage() {
return mPage;
Expand Down Expand Up @@ -232,17 +236,17 @@ public void showPasswordPrompt() {
if (!getPasswordPromptFragment().isAdded()){
getPasswordPromptFragment().show(getSupportFragmentManager(), PasswordPromptFragment.class.getName());
}
passwordValidationViewModel.passwordMissing();
viewModel.passwordMissing();
}

@JavascriptInterface
public void invalidPassword() {
runOnUiThread(() -> passwordValidationViewModel.invalid());
runOnUiThread(() -> viewModel.invalid());
}

@JavascriptInterface
public void onLoaded() {
passwordValidationViewModel.validated();
viewModel.validated();
if (getPasswordPromptFragment().isAdded()) {
getPasswordPromptFragment().dismiss();
}
Expand All @@ -261,20 +265,33 @@ protected void onCreate(Bundle savedInstanceState) {
binding = PdfviewerBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbar);
passwordValidationViewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())).get(PasswordStatus.class);
viewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())).get(PdfViewModel.class);

viewModel.getOutline().observe(this, requested -> {
if (requested instanceof PdfViewModel.OutlineStatus.Requested) {
viewModel.setLoadingOutline();
binding.webview.evaluateJavascript("getDocumentOutline()", null);
}
});


getSupportFragmentManager().setFragmentResultListener(OutlineFragment.RESULT_KEY, this,
(requestKey, result) -> {
final int newPage = result.getInt(OutlineFragment.PAGE_KEY, -1);
if (viewModel.shouldAbortOutline()) {
Log.d(TAG, "aborting outline operations");
binding.webview.evaluateJavascript("abortDocumentOutline()", null);
viewModel.clearOutline();
} else {
onJumpToPageInDocument(newPage);
}
});

EdgeToEdge.enable(this);

// Margins for the toolbar are needed, so that content of the toolbar
// is not covered by a system button navigation bar when in landscape.
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar, (v, windowInsets) -> {
Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
mlp.leftMargin = insets.left;
mlp.rightMargin = insets.right;
v.setLayoutParams(mlp);
return windowInsets;
});
KtUtilsKt.applySystemBarMargins(binding.toolbar, false);

binding.webview.setBackgroundColor(Color.TRANSPARENT);

Expand Down Expand Up @@ -446,6 +463,8 @@ public void onZoomEnd() {
}
}



@Override
protected void onDestroy() {
super.onDestroy();
Expand Down Expand Up @@ -659,7 +678,8 @@ 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, R.id.action_share, R.id.action_save_as));
R.id.action_view_document_properties, R.id.action_share, R.id.action_save_as,
R.id.action_outline));
if (BuildConfig.DEBUG) {
ids.add(R.id.debug_action_toggle_text_layer_visibility);
}
Expand Down Expand Up @@ -713,6 +733,16 @@ public boolean onOptionsItemSelected(MenuItem item) {
} else if (itemId == R.id.action_rotate_counterclockwise) {
documentOrientationChanged(-90);
return true;
} else if (itemId == R.id.action_outline) {
OutlineFragment outlineFragment =
OutlineFragment.newInstance(mPage, getCurrentDocumentName());
getSupportFragmentManager().beginTransaction()
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
// fullscreen fragment, since content root view == activity's root view
.add(android.R.id.content, outlineFragment)
.addToBackStack(null)
.commit();
return true;
} else if (itemId == R.id.action_view_document_properties) {
DocumentPropertiesFragment
.newInstance(mDocumentProperties)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import android.os.Bundle
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.inputmethod.EditorInfo.IME_ACTION_DONE
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import app.grapheneos.pdfviewer.PdfViewer
import app.grapheneos.pdfviewer.R
import app.grapheneos.pdfviewer.databinding.PasswordDialogFragmentBinding
import app.grapheneos.pdfviewer.viewModel.PasswordStatus
import app.grapheneos.pdfviewer.viewModel.PdfViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
Expand Down Expand Up @@ -50,20 +49,20 @@ class PasswordPromptFragment : DialogFragment() {
isCancelable = false
dialog.setCanceledOnTouchOutside(false)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
(requireActivity() as PdfViewer).passwordValidationViewModel.status.observe(
(requireActivity() as PdfViewer).viewModel.passwordStatus.observe(
this
) {
when (it) {
PasswordStatus.Status.MissingPassword -> {
PdfViewModel.PasswordStatus.MissingPassword -> {
passwordEditText.editableText.clear()
passwordDialogFragmentBinding.title.setText(R.string.password_prompt_description)
}
PasswordStatus.Status.InvalidPassword -> {
PdfViewModel.PasswordStatus.InvalidPassword -> {
passwordEditText.editableText.clear()
passwordDialogFragmentBinding.pdfPasswordTextInputLayout.error =
"invalid password"
}
PasswordStatus.Status.Validated -> {
PdfViewModel.PasswordStatus.Validated -> {
//Activity will dismiss the dialog
}
else -> {
Expand Down
Loading
Loading