diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 936f70851..ce400067c 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -3,13 +3,10 @@ import android.annotation.SuppressLint; import android.content.Intent; import android.content.pm.PackageInfo; -import android.content.res.ColorStateList; import android.graphics.Color; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.util.Log; -import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -22,8 +19,6 @@ import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; -import android.widget.TextView; -import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; @@ -33,39 +28,27 @@ 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 java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; -import java.util.List; +import java.util.Map; 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.properties.DocumentProperty; import app.grapheneos.pdfviewer.outline.OutlineFragment; import app.grapheneos.pdfviewer.viewModel.PdfViewModel; -public class PdfViewer extends AppCompatActivity implements LoaderManager.LoaderCallbacks> { +public class PdfViewer extends AppCompatActivity { private static final String TAG = "PdfViewer"; - private static final String STATE_WEBVIEW_CRASHED = "webview_crashed"; - private static final String STATE_URI = "uri"; - private static final String STATE_PAGE = "page"; - private static final String STATE_ZOOM_RATIO = "zoomRatio"; - private static final String STATE_DOCUMENT_ORIENTATION_DEGREES = "documentOrientationDegrees"; - private static final String STATE_ENCRYPTED_DOCUMENT_PASSWORD = "encrypted_document_password"; - private static final String KEY_PROPERTIES = "properties"; private static final int MIN_WEBVIEW_RELEASE = 133; private static final String CONTENT_SECURITY_POLICY = @@ -114,28 +97,18 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private static final int MAX_RENDER_PIXELS = 1 << 23; // 8 mega-pixels private static final int ALPHA_LOW = 130; private static final int ALPHA_HIGH = 255; - private static final int STATE_LOADED = 1; - private static final int STATE_END = 2; - private static final int PADDING = 10; - - private boolean webViewCrashed; - private Uri mUri; - public int mPage; - public int mNumPages; - private float mZoomRatio = 1f; - private float mZoomFocusX = 0f; - private float mZoomFocusY = 0f; - private int mDocumentOrientationDegrees; - private int mDocumentState; - private String mEncryptedDocumentPassword; - private List mDocumentProperties; - private InputStream mInputStream; + + private final Object streamLock = new Object(); + + private volatile float zoomFocusX = 0f; + private volatile float zoomFocusY = 0f; + private boolean documentLoaded; + private volatile InputStream inputStream; + private volatile boolean documentPropertiesLoaded; private PdfviewerBinding binding; - private TextView mTextView; - private Toast mToast; private Snackbar snackbar; - private PasswordPromptFragment mPasswordPromptFragment; + private PasswordPromptFragment passwordPromptFragment; public PdfViewModel viewModel; private final ActivityResultLauncher openDocumentLauncher = registerForActivityResult( @@ -144,11 +117,13 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader if (result.getResultCode() != RESULT_OK) return; Intent resultData = result.getData(); if (resultData != null) { - mUri = result.getData().getData(); - mPage = 1; - mDocumentProperties = null; - mEncryptedDocumentPassword = ""; + handleNewUri(resultData.getData()); + viewModel.setPage(1); + documentPropertiesLoaded = false; + viewModel.clearDocumentProperties(); + viewModel.setEncryptedDocumentPassword(""); viewModel.clearOutline(); + viewModel.setZoomRatio(0f); loadPdf(); invalidateOptionsMenu(); } @@ -170,27 +145,29 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private class Channel { @JavascriptInterface public void setHasDocumentOutline(final boolean hasOutline) { - viewModel.setHasOutline(hasOutline); + runOnUiThread(() -> viewModel.setHasOutline(hasOutline)); } @JavascriptInterface public void setDocumentOutline(final String outline) { - viewModel.parseOutlineString(outline); + runOnUiThread(() -> viewModel.parseOutlineString(outline)); } @JavascriptInterface public int getPage() { - return mPage; + return viewModel.getPage(); } @JavascriptInterface public float getZoomRatio() { - return mZoomRatio; + return viewModel.getZoomRatio(); } @JavascriptInterface public void setZoomRatio(final float ratio) { - mZoomRatio = Math.max(Math.min(ratio, MAX_ZOOM_RATIO), MIN_ZOOM_RATIO); + runOnUiThread(() -> + viewModel.setZoomRatio(Math.max(Math.min(ratio, MAX_ZOOM_RATIO), MIN_ZOOM_RATIO)) + ); } @JavascriptInterface @@ -200,12 +177,12 @@ public int getMaxRenderPixels() { @JavascriptInterface public float getZoomFocusX() { - return mZoomFocusX; + return zoomFocusX; } @JavascriptInterface public float getZoomFocusY() { - return mZoomFocusY; + return zoomFocusY; } @JavascriptInterface @@ -220,50 +197,56 @@ public float getMaxZoomRatio() { @JavascriptInterface public int getDocumentOrientationDegrees() { - return mDocumentOrientationDegrees; + return viewModel.getDocumentOrientationDegrees(); } @JavascriptInterface public void setNumPages(int numPages) { - mNumPages = numPages; + viewModel.setNumPages(numPages); runOnUiThread(PdfViewer.this::invalidateOptionsMenu); } @JavascriptInterface public void setDocumentProperties(final String properties) { - if (mDocumentProperties != null) { - throw new SecurityException("mDocumentProperties not null"); + if (documentPropertiesLoaded) { + throw new SecurityException("setDocumentProperties already called"); + } + documentPropertiesLoaded = true; + final int numPages = viewModel.getNumPages(); + final Uri uri = viewModel.getUri(); + if (uri != null) { + runOnUiThread(() -> viewModel.retrieveDocumentProperties(properties, numPages, uri)); } - - final Bundle args = new Bundle(); - args.putString(KEY_PROPERTIES, properties); - runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this).restartLoader(DocumentPropertiesAsyncTaskLoader.ID, args, PdfViewer.this)); } @JavascriptInterface public void showPasswordPrompt() { - if (!getPasswordPromptFragment().isAdded()){ - getPasswordPromptFragment().show(getSupportFragmentManager(), PasswordPromptFragment.class.getName()); - } + runOnUiThread(() -> { + if (!getPasswordPromptFragment().isAdded()) { + getPasswordPromptFragment().show(getSupportFragmentManager(), PasswordPromptFragment.class.getName()); + } + }); viewModel.passwordMissing(); } @JavascriptInterface public void invalidPassword() { - runOnUiThread(() -> viewModel.invalid()); + viewModel.invalid(); } @JavascriptInterface public void onLoaded() { viewModel.validated(); - if (getPasswordPromptFragment().isAdded()) { - getPasswordPromptFragment().dismiss(); - } + runOnUiThread(() -> { + if (getPasswordPromptFragment().isAdded()) { + getPasswordPromptFragment().dismiss(); + } + }); } @JavascriptInterface public String getPassword() { - return mEncryptedDocumentPassword != null ? mEncryptedDocumentPassword : ""; + return viewModel.getEncryptedDocumentPassword(); } } @@ -284,7 +267,7 @@ protected void onCreate(Bundle savedInstanceState) { binding = PdfviewerBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); setSupportActionBar(binding.toolbar); - viewModel = new ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(getApplication())).get(PdfViewModel.class); + viewModel = new ViewModelProvider(this).get(PdfViewModel.class); viewModel.getOutline().observe(this, requested -> { if (requested instanceof PdfViewModel.OutlineStatus.Requested) { @@ -293,6 +276,18 @@ protected void onCreate(Bundle savedInstanceState) { } }); + viewModel.getSaveError().observe(this, error -> { + if (error) { + snackbar.setText(R.string.error_while_saving).show(); + viewModel.clearSaveError(); + } + }); + + viewModel.getDocumentProperties().observe(this, properties -> { + setToolbarTitleWithDocumentName(); + invalidateOptionsMenu(); + }); + getSupportFragmentManager().setFragmentResultListener(OutlineFragment.RESULT_KEY, this, (requestKey, result) -> { final int newPage = result.getInt(OutlineFragment.PAGE_KEY, -1); @@ -336,6 +331,8 @@ private WebResourceResponse fromAsset(final String mime, final String path) { @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + if (viewModel.getUri() == null) return null; + if (!"GET".equals(request.getMethod())) { return null; } @@ -349,18 +346,20 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque Log.d(TAG, "path " + path); if ("/placeholder.pdf".equals(path)) { - maybeCloseInputStream(); - try { - mInputStream = getContentResolver().openInputStream(mUri); - if (mInputStream == null) { - throw new FileNotFoundException(); + synchronized (streamLock) { + maybeCloseInputStream(); + try { + inputStream = getContentResolver().openInputStream(viewModel.getUri()); + if (inputStream == null) { + throw new FileNotFoundException(); + } + } catch (final FileNotFoundException | IllegalArgumentException | + IllegalStateException | SecurityException ignored) { + runOnUiThread(() -> snackbar.setText(R.string.error_while_opening).show()); + return null; } - } catch (final FileNotFoundException | IllegalArgumentException | - IllegalStateException | SecurityException ignored) { - snackbar.setText(R.string.error_while_opening).show(); - return null; + return new WebResourceResponse("application/pdf", null, inputStream); } - return new WebResourceResponse("application/pdf", null, mInputStream); } if ("/viewer/index.html".equals(path)) { @@ -413,15 +412,15 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public void onPageFinished(WebView view, String url) { - mDocumentState = STATE_LOADED; + documentLoaded = true; invalidateOptionsMenu(); - loadPdfWithPassword(mEncryptedDocumentPassword); + loadPdfWithPassword(viewModel.getEncryptedDocumentPassword()); } @Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { if (detail.didCrash()) { - webViewCrashed = true; + viewModel.setWebViewCrashed(true); showWebViewCrashed(); invalidateOptionsMenu(); purgeWebView(); @@ -435,10 +434,10 @@ public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) new GestureHelper.GestureListener() { @Override public boolean onTapUp() { - if (mUri != null) { + if (viewModel.getUri() != null) { binding.webview.evaluateJavascript("isTextSelected()", selection -> { if (!Boolean.parseBoolean(selection)) { - if (getSupportActionBar().isShowing()) { + if (getSupportActionBar() != null && getSupportActionBar().isShowing()) { hideSystemUi(); } else { showSystemUi(); @@ -461,21 +460,10 @@ public void onZoomEnd() { } }); - mTextView = new TextView(this); - mTextView.setBackgroundColor(Color.DKGRAY); - mTextView.setTextColor(ColorStateList.valueOf(Color.WHITE)); - mTextView.setTextSize(18); - mTextView.setPadding(PADDING, 0, PADDING, 0); - - // If loaders are not being initialized in onCreate(), the result will not be delivered - // after orientation change (See FragmentHostCallback), thus initialize the - // loader manager impl so that the result will be delivered. - LoaderManager.getInstance(this); - snackbar = Snackbar.make(binding.getRoot(), "", Snackbar.LENGTH_LONG); final Intent intent = getIntent(); - if (Intent.ACTION_VIEW.equals(intent.getAction())) { + if (savedInstanceState == null && Intent.ACTION_VIEW.equals(intent.getAction())) { final String type = intent.getType(); if (!"application/pdf".equals(type) && type != null) { snackbar.setText(R.string.invalid_mime_type).show(); @@ -484,38 +472,23 @@ public void onZoomEnd() { if (type == null) { Log.w(TAG, "MIME type is null, but we'll try to load it anyway"); } - mUri = intent.getData(); - mPage = 1; - } - - if (savedInstanceState != null) { - webViewCrashed = savedInstanceState.getBoolean(STATE_WEBVIEW_CRASHED); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - mUri = savedInstanceState.getParcelable(STATE_URI, Uri.class); - } else { - @SuppressWarnings("deprecation") - final Uri uri = savedInstanceState.getParcelable(STATE_URI); - mUri = uri; - } - mPage = savedInstanceState.getInt(STATE_PAGE); - mZoomRatio = savedInstanceState.getFloat(STATE_ZOOM_RATIO); - mDocumentOrientationDegrees = savedInstanceState.getInt(STATE_DOCUMENT_ORIENTATION_DEGREES); - mEncryptedDocumentPassword = savedInstanceState.getString(STATE_ENCRYPTED_DOCUMENT_PASSWORD); + handleNewUri(intent.getData()); + viewModel.setZoomRatio(0f); + viewModel.setPage(1); } binding.webviewAlertReload.setOnClickListener(v -> { - webViewCrashed = false; + viewModel.setWebViewCrashed(false); recreate(); }); - if (webViewCrashed) { + if (viewModel.getWebViewCrashed()) { showWebViewCrashed(); - } else if (mUri != null) { - if ("file".equals(mUri.getScheme())) { + } else if (viewModel.getUri() != null) { + if ("file".equals(viewModel.getUri().getScheme())) { snackbar.setText(R.string.legacy_file_uri).show(); return; } - loadPdf(); } } @@ -534,26 +507,28 @@ protected void onDestroy() { } void maybeCloseInputStream() { - InputStream stream = mInputStream; - if (stream == null) { - return; + synchronized (streamLock) { + InputStream stream = inputStream; + if (stream == null) { + return; + } + inputStream = null; + try { + stream.close(); + } catch (IOException ignored) {} } - mInputStream = null; - try { - stream.close(); - } catch (IOException ignored) {} } private PasswordPromptFragment getPasswordPromptFragment() { - if (mPasswordPromptFragment == null) { + if (passwordPromptFragment == null) { final Fragment fragment = getSupportFragmentManager().findFragmentByTag(PasswordPromptFragment.class.getName()); if (fragment != null) { - mPasswordPromptFragment = (PasswordPromptFragment) fragment; + passwordPromptFragment = (PasswordPromptFragment) fragment; } else { - mPasswordPromptFragment = new PasswordPromptFragment(); + passwordPromptFragment = new PasswordPromptFragment(); } } - return mPasswordPromptFragment; + return passwordPromptFragment; } private void setToolbarTitleWithDocumentName() { @@ -569,7 +544,7 @@ private void setToolbarTitleWithDocumentName() { protected void onResume() { super.onResume(); - if (!webViewCrashed) { + if (!viewModel.getWebViewCrashed()) { // The user could have left the activity to update the WebView invalidateOptionsMenu(); if (getWebViewRelease() >= MIN_WEBVIEW_RELEASE) { @@ -590,34 +565,16 @@ private int getWebViewRelease() { return Integer.parseInt(webViewVersionName.substring(0, webViewVersionName.indexOf("."))); } - @NonNull - @Override - public Loader> onCreateLoader(int id, Bundle args) { - return new DocumentPropertiesAsyncTaskLoader(this, args.getString(KEY_PROPERTIES), mNumPages, mUri); - } - - @Override - public void onLoadFinished(@NonNull Loader> loader, List data) { - mDocumentProperties = data; - invalidateOptionsMenu(); - setToolbarTitleWithDocumentName(); - LoaderManager.getInstance(this).destroyLoader(DocumentPropertiesAsyncTaskLoader.ID); - } - - @Override - public void onLoaderReset(@NonNull Loader> loader) { - mDocumentProperties = null; - } - private void loadPdf() { - mDocumentState = 0; + documentPropertiesLoaded = false; + documentLoaded = false; showSystemUi(); invalidateOptionsMenu(); binding.webview.loadUrl("https://localhost/viewer/index.html"); } public void loadPdfWithPassword(final String password) { - mEncryptedDocumentPassword = password; + viewModel.setEncryptedDocumentPassword(password); binding.webview.evaluateJavascript("loadDocument()", null); } @@ -626,10 +583,11 @@ private void renderPage(final int zoom) { } private void documentOrientationChanged(final int orientationDegreesOffset) { - mDocumentOrientationDegrees = (mDocumentOrientationDegrees + orientationDegreesOffset) % 360; - if (mDocumentOrientationDegrees < 0) { - mDocumentOrientationDegrees += 360; + int degrees = (viewModel.getDocumentOrientationDegrees() + orientationDegreesOffset) % 360; + if (degrees < 0) { + degrees += 360; } + viewModel.setDocumentOrientationDegrees(degrees); renderPage(0); } @@ -641,10 +599,10 @@ private void openDocument() { } private void shareDocument() { - if (mUri != null) { + if (viewModel.getUri() != null) { Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setDataAndTypeAndNormalize(mUri, "application/pdf"); - shareIntent.putExtra(Intent.EXTRA_STREAM, mUri); + shareIntent.setDataAndTypeAndNormalize(viewModel.getUri(), "application/pdf"); + shareIntent.putExtra(Intent.EXTRA_STREAM, viewModel.getUri()); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(shareIntent, getString(R.string.action_share))); } else { @@ -653,9 +611,9 @@ private void shareDocument() { } private void zoom(float scaleFactor, float focusX, float focusY, boolean end) { - mZoomRatio = Math.min(Math.max(mZoomRatio * scaleFactor, MIN_ZOOM_RATIO), MAX_ZOOM_RATIO); - mZoomFocusX = focusX; - mZoomFocusY = focusY; + viewModel.setZoomRatio(Math.min(Math.max(viewModel.getZoomRatio() * scaleFactor, MIN_ZOOM_RATIO), MAX_ZOOM_RATIO)); + zoomFocusX = focusX; + zoomFocusY = focusY; renderPage(end ? 1 : 2); invalidateOptionsMenu(); } @@ -664,16 +622,17 @@ private void zoomEnd() { renderPage(1); } - private static void enableDisableMenuItem(MenuItem item, boolean enable) { - item.setEnabled(enable); + private static void setMenuItemState(MenuItem item, boolean visible, boolean enabled) { + item.setVisible(visible); + item.setEnabled(enabled); if (item.getIcon() != null) { - item.getIcon().setAlpha(enable ? ALPHA_HIGH : ALPHA_LOW); + item.getIcon().setAlpha(enabled ? ALPHA_HIGH : ALPHA_LOW); } } public void onJumpToPageInDocument(final int selected_page) { - if (selected_page >= 1 && selected_page <= mNumPages && mPage != selected_page) { - mPage = selected_page; + if (selected_page >= 1 && selected_page <= viewModel.getNumPages() && viewModel.getPage() != selected_page) { + viewModel.setPage(selected_page); renderPage(0); showPageNumber(); invalidateOptionsMenu(); @@ -690,27 +649,10 @@ private void hideSystemUi() { getSupportActionBar().hide(); } - @Override - public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - savedInstanceState.putBoolean(STATE_WEBVIEW_CRASHED, webViewCrashed); - savedInstanceState.putParcelable(STATE_URI, mUri); - savedInstanceState.putInt(STATE_PAGE, mPage); - savedInstanceState.putFloat(STATE_ZOOM_RATIO, mZoomRatio); - savedInstanceState.putInt(STATE_DOCUMENT_ORIENTATION_DEGREES, mDocumentOrientationDegrees); - savedInstanceState.putString(STATE_ENCRYPTED_DOCUMENT_PASSWORD, mEncryptedDocumentPassword); - } - private void showPageNumber() { - if (mToast != null) { - mToast.cancel(); - } - mTextView.setText(String.format("%s/%s", mPage, mNumPages)); - mToast = new Toast(this); - mToast.setGravity(Gravity.BOTTOM | Gravity.END, PADDING, PADDING); - mToast.setDuration(Toast.LENGTH_SHORT); - mToast.setView(mTextView); - mToast.show(); + Snackbar.make(binding.webview, + String.format("%s/%s", viewModel.getPage(), viewModel.getNumPages()), + Snackbar.LENGTH_SHORT).show(); } @Override @@ -726,48 +668,35 @@ public boolean onCreateOptionsMenu(@NonNull Menu menu) { @Override public boolean onPrepareOptionsMenu(@NonNull Menu menu) { - final ArrayList 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_outline)); - if (BuildConfig.DEBUG) { - ids.add(R.id.debug_action_toggle_text_layer_visibility); - ids.add(R.id.debug_action_crash_webview); - } - if (mDocumentState < STATE_LOADED) { - for (final int id : ids) { - final MenuItem item = menu.findItem(id); - if (item.isVisible()) { - item.setVisible(false); - } - } - } else if (mDocumentState == STATE_LOADED) { - for (final int id : ids) { - final MenuItem item = menu.findItem(id); - if (!item.isVisible()) { - item.setVisible(true); - } - } - mDocumentState = STATE_END; - } + final boolean loaded = documentLoaded; + final boolean crashed = viewModel.getWebViewCrashed(); + final boolean enabled = loaded && !crashed; + + setMenuItemState(menu.findItem(R.id.action_open), true, + !crashed && getWebViewRelease() >= MIN_WEBVIEW_RELEASE); + setMenuItemState(menu.findItem(R.id.action_jump_to_page), loaded, enabled); + setMenuItemState(menu.findItem(R.id.action_next), loaded, + enabled && viewModel.getPage() < viewModel.getNumPages()); + setMenuItemState(menu.findItem(R.id.action_previous), loaded, + enabled && viewModel.getPage() > 1); + setMenuItemState(menu.findItem(R.id.action_first), loaded, enabled); + setMenuItemState(menu.findItem(R.id.action_last), loaded, enabled); + setMenuItemState(menu.findItem(R.id.action_rotate_clockwise), loaded, enabled); + setMenuItemState(menu.findItem(R.id.action_rotate_counterclockwise), loaded, enabled); + setMenuItemState(menu.findItem(R.id.action_view_document_properties), loaded, + enabled && viewModel.getDocumentProperties().getValue() != null); + setMenuItemState(menu.findItem(R.id.action_share), loaded, + enabled && viewModel.getUri() != null); + setMenuItemState(menu.findItem(R.id.action_save_as), loaded, + enabled && viewModel.getUri() != null); + setMenuItemState(menu.findItem(R.id.action_outline), + loaded && viewModel.hasOutline(), enabled); - - enableDisableMenuItem(menu.findItem(R.id.action_open), - !webViewCrashed && getWebViewRelease() >= MIN_WEBVIEW_RELEASE); - enableDisableMenuItem(menu.findItem(R.id.action_share), mUri != null); - enableDisableMenuItem(menu.findItem(R.id.action_next), mPage < mNumPages); - enableDisableMenuItem(menu.findItem(R.id.action_previous), mPage > 1); - enableDisableMenuItem(menu.findItem(R.id.action_save_as), mUri != null); - enableDisableMenuItem(menu.findItem(R.id.action_view_document_properties), - mDocumentProperties != null); - - menu.findItem(R.id.action_outline).setVisible(viewModel.hasOutline()); - - if (webViewCrashed) { - for (final int id : ids) { - enableDisableMenuItem(menu.findItem(id), false); - } + if (BuildConfig.DEBUG) { + setMenuItemState(menu.findItem(R.id.debug_action_toggle_text_layer_visibility), + loaded, enabled); + setMenuItemState(menu.findItem(R.id.debug_action_crash_webview), + loaded, enabled); } return true; @@ -777,16 +706,16 @@ public boolean onPrepareOptionsMenu(@NonNull Menu menu) { public boolean onOptionsItemSelected(MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.action_previous) { - onJumpToPageInDocument(mPage - 1); + onJumpToPageInDocument(viewModel.getPage() - 1); return true; } else if (itemId == R.id.action_next) { - onJumpToPageInDocument(mPage + 1); + onJumpToPageInDocument(viewModel.getPage() + 1); return true; } else if (itemId == R.id.action_first) { onJumpToPageInDocument(1); return true; } else if (itemId == R.id.action_last) { - onJumpToPageInDocument(mNumPages); + onJumpToPageInDocument(viewModel.getNumPages()); return true; } else if (itemId == R.id.action_open) { openDocument(); @@ -799,7 +728,7 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (itemId == R.id.action_outline) { OutlineFragment outlineFragment = - OutlineFragment.newInstance(mPage, getCurrentDocumentName()); + OutlineFragment.newInstance(viewModel.getPage(), getCurrentDocumentName()); getSupportFragmentManager().beginTransaction() .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) // fullscreen fragment, since content root view == activity's root view @@ -809,8 +738,8 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (itemId == R.id.action_view_document_properties) { DocumentPropertiesFragment - .newInstance(mDocumentProperties) - .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); + .newInstance() + .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); return true; } else if (itemId == R.id.action_jump_to_page) { new JumpToPageFragment() @@ -821,6 +750,7 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } else if (itemId == R.id.action_save_as) { saveDocument(); + return true; } else if (itemId == R.id.debug_action_toggle_text_layer_visibility) { binding.webview.evaluateJavascript("toggleTextLayerVisibility()", null); return true; @@ -841,38 +771,28 @@ private void saveDocument() { } 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; + Map properties = viewModel.getDocumentProperties().getValue(); + if (properties == null || properties.isEmpty()) return ""; + String fileName = properties.getOrDefault(DocumentProperty.FileName, ""); + String title = properties.getOrDefault(DocumentProperty.Title, ""); + return !(fileName != null && fileName.isEmpty()) ? fileName : title; } - private void saveDocumentAs(final Uri uri) { - try (final InputStream input = getContentResolver().openInputStream(mUri); - final OutputStream output = getContentResolver().openOutputStream(uri)) { - if (input == null || output == null) { - throw new FileNotFoundException(); - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - input.transferTo(output); - } else { - final byte[] buffer = new byte[16384]; - int read; - while ((read = input.read(buffer)) != -1) { - output.write(buffer, 0, read); - } - } - } catch (final IOException | IllegalArgumentException | IllegalStateException | - SecurityException e) { - snackbar.setText(R.string.error_while_saving).show(); + private void saveDocumentAs(final Uri saveUri) { + if (viewModel.getUri() == null) return; + viewModel.saveDocumentAs(getContentResolver(), viewModel.getUri(), saveUri); + } + + private void handleNewUri(Uri newUri) { + Uri oldUri = viewModel.getUri(); + if (oldUri != null) { + try { + getContentResolver().releasePersistableUriPermission(oldUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (SecurityException ignored) {} } + try { + getContentResolver().takePersistableUriPermission(newUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (SecurityException ignored) {} + viewModel.setUri(newUri); } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.kt b/app/src/main/java/app/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.kt index 49d175fb5..1d71b23b4 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.kt @@ -1,29 +1,53 @@ package app.grapheneos.pdfviewer.fragment import android.app.Dialog +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan import android.widget.ArrayAdapter import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels import app.grapheneos.pdfviewer.R +import app.grapheneos.pdfviewer.properties.DocumentProperty +import app.grapheneos.pdfviewer.viewModel.PdfViewModel 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 by lazy { - requireArguments().getStringArrayList(KEY_DOCUMENT_PROPERTIES)?.toList() ?: emptyList() + private val viewModel by activityViewModels() + + private fun formatProperties(properties: Map): List { + return properties.map { (property, value) -> + val name = getString(property.nameResource) + SpannableStringBuilder() + .append(name) + .append(":\n") + .append(value) + .apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + name.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val properties = viewModel.documentProperties.value + return MaterialAlertDialogBuilder(requireActivity()) .setPositiveButton(android.R.string.ok, null).apply { - if (mDocumentProperties.isNotEmpty()) { + if (!properties.isNullOrEmpty()) { setTitle(getString(R.string.action_view_document_properties)) setAdapter( ArrayAdapter( requireActivity(), android.R.layout.simple_list_item_1, - mDocumentProperties + formatProperties(properties) ), null ) } else { @@ -36,18 +60,8 @@ class DocumentPropertiesFragment : DialogFragment() { companion object { const val TAG = "DocumentPropertiesFragment" - private const val KEY_DOCUMENT_PROPERTIES = "document_properties" @JvmStatic - fun newInstance(metaData: List): DocumentPropertiesFragment { - val fragment = DocumentPropertiesFragment() - val args = Bundle() - args.putCharSequenceArrayList( - KEY_DOCUMENT_PROPERTIES, - metaData as ArrayList - ) - fragment.arguments = args - return fragment - } + fun newInstance() = DocumentPropertiesFragment() } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/fragment/JumpToPageFragment.kt b/app/src/main/java/app/grapheneos/pdfviewer/fragment/JumpToPageFragment.kt index aaf3d607e..432974775 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/fragment/JumpToPageFragment.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/fragment/JumpToPageFragment.kt @@ -19,24 +19,24 @@ class JumpToPageFragment : DialogFragment() { private const val STATE_PICKER_MAX = "picker_max" } - private val mPicker: NumberPicker by lazy { NumberPicker(requireActivity()) } + private val picker: NumberPicker by lazy { NumberPicker(requireActivity()) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val viewerActivity: PdfViewer = (requireActivity() as PdfViewer) if (savedInstanceState != null) { - mPicker.minValue = savedInstanceState.getInt(STATE_PICKER_MIN) - mPicker.maxValue = savedInstanceState.getInt(STATE_PICKER_MAX) - mPicker.value = savedInstanceState.getInt(STATE_PICKER_CUR) + picker.minValue = savedInstanceState.getInt(STATE_PICKER_MIN) + picker.maxValue = savedInstanceState.getInt(STATE_PICKER_MAX) + picker.value = savedInstanceState.getInt(STATE_PICKER_CUR) } else { - mPicker.minValue = 1 - mPicker.maxValue = viewerActivity.mNumPages - mPicker.value = viewerActivity.mPage + picker.minValue = 1 + picker.maxValue = viewerActivity.viewModel.numPages + picker.value = viewerActivity.viewModel.page } val layout = FrameLayout(requireActivity()) layout.addView( - mPicker, FrameLayout.LayoutParams( + picker, FrameLayout.LayoutParams( FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER @@ -45,16 +45,16 @@ class JumpToPageFragment : DialogFragment() { return MaterialAlertDialogBuilder(requireActivity()) .setView(layout) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - mPicker.clearFocus() - viewerActivity.onJumpToPageInDocument(mPicker.value) + picker.clearFocus() + viewerActivity.onJumpToPageInDocument(picker.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) + outState.putInt(STATE_PICKER_MIN, picker.minValue) + outState.putInt(STATE_PICKER_MAX, picker.maxValue) + outState.putInt(STATE_PICKER_CUR, picker.value) } } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesAsyncTaskLoader.java b/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesAsyncTaskLoader.java deleted file mode 100644 index 62d974572..000000000 --- a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesAsyncTaskLoader.java +++ /dev/null @@ -1,48 +0,0 @@ -package app.grapheneos.pdfviewer.loader; - -import android.content.Context; -import android.net.Uri; - -import androidx.annotation.Nullable; -import androidx.loader.content.AsyncTaskLoader; - -import java.util.List; - -public class DocumentPropertiesAsyncTaskLoader extends AsyncTaskLoader> { - - public static final String TAG = "DocumentPropertiesLoader"; - - public static final int ID = 1; - - private final String mProperties; - private final int mNumPages; - private final Uri mUri; - - public DocumentPropertiesAsyncTaskLoader(Context context, String properties, int numPages, Uri uri) { - super(context); - - mProperties = properties; - mNumPages = numPages; - mUri = uri; - } - - - @Override - protected void onStartLoading() { - forceLoad(); - } - - @Nullable - @Override - public List loadInBackground() { - - DocumentPropertiesLoader loader = new DocumentPropertiesLoader( - getContext(), - mProperties, - mNumPages, - mUri - ); - - return loader.loadAsList(); - } -} diff --git a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.kt b/app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentPropertiesRetriever.kt similarity index 69% rename from app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.kt rename to app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentPropertiesRetriever.kt index 7f98dec53..7de261350 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentPropertiesRetriever.kt @@ -1,46 +1,26 @@ -package app.grapheneos.pdfviewer.loader +package app.grapheneos.pdfviewer.properties import android.content.Context -import android.graphics.Typeface import android.net.Uri import android.provider.OpenableColumns -import android.text.SpannableStringBuilder -import android.text.Spanned import android.text.format.Formatter -import android.text.style.StyleSpan import android.util.Log import androidx.core.database.getLongOrNull import app.grapheneos.pdfviewer.R import org.json.JSONException -class DocumentPropertiesLoader( +class DocumentPropertiesRetriever( private val context: Context, private val properties: String, private val numPages: Int, - private val mUri: Uri + private val uri: Uri ) { - fun loadAsList(): List { - 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 - ) - } - } + companion object { + const val TAG = "DocumentPropertiesRetriever" } - private fun load(): Map { + fun retrieve(): Map { val result = mutableMapOf() result.addFileProperties() result.addPageSizeProperty() @@ -67,16 +47,13 @@ class DocumentPropertiesLoader( context.getString(R.string.document_properties_invalid_date), parseExceptionListener = { parseException, value -> Log.w( - DocumentPropertiesAsyncTaskLoader.TAG, + TAG, "${parseException.message} for $value at offset: ${parseException.errorOffset}" ) } ).convert() - } catch (e: JSONException) { - Log.w( - DocumentPropertiesAsyncTaskLoader.TAG, - "invalid properties" - ) + } catch (_: JSONException) { + Log.w(TAG, "invalid properties") emptyMap() } } @@ -89,7 +66,7 @@ class DocumentPropertiesLoader( ) context.contentResolver.query( - mUri, + uri, proj, null, null diff --git a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentProperty.kt b/app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentProperty.kt similarity index 94% rename from app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentProperty.kt rename to app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentProperty.kt index bee1873c3..e84f28035 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/loader/DocumentProperty.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/properties/DocumentProperty.kt @@ -1,4 +1,4 @@ -package app.grapheneos.pdfviewer.loader +package app.grapheneos.pdfviewer.properties import androidx.annotation.StringRes import app.grapheneos.pdfviewer.R @@ -17,7 +17,7 @@ const val DEFAULT_VALUE = "-" enum class DocumentProperty( val key: String = "", - @StringRes val nameResource: Int, + @param:StringRes val nameResource: Int, val isDate: Boolean = false ) { FileName(key = "", nameResource = R.string.file_name), diff --git a/app/src/main/java/app/grapheneos/pdfviewer/loader/PDFJsPropertiesToDocumentPropertyConverter.kt b/app/src/main/java/app/grapheneos/pdfviewer/properties/PDFJsPropertiesToDocumentPropertyConverter.kt similarity index 97% rename from app/src/main/java/app/grapheneos/pdfviewer/loader/PDFJsPropertiesToDocumentPropertyConverter.kt rename to app/src/main/java/app/grapheneos/pdfviewer/properties/PDFJsPropertiesToDocumentPropertyConverter.kt index 3bf8137cf..3df2805cb 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/loader/PDFJsPropertiesToDocumentPropertyConverter.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/properties/PDFJsPropertiesToDocumentPropertyConverter.kt @@ -1,4 +1,4 @@ -package app.grapheneos.pdfviewer.loader +package app.grapheneos.pdfviewer.properties import app.grapheneos.pdfviewer.Utils import org.json.JSONException diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index fd8b05563..698445fba 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -1,15 +1,73 @@ package app.grapheneos.pdfviewer.viewModel +import android.app.Application +import android.content.ContentResolver +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import app.grapheneos.pdfviewer.properties.DocumentPropertiesRetriever +import app.grapheneos.pdfviewer.properties.DocumentProperty import app.grapheneos.pdfviewer.outline.OutlineNode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileNotFoundException +import java.io.IOException + +class PdfViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + + companion object { + private const val STATE_URI: String = "uri" + private const val STATE_PAGE: String = "page" + private const val STATE_ZOOM_RATIO: String = "zoomRatio" + private const val STATE_DOCUMENT_ORIENTATION_DEGREES: String = "documentOrientationDegrees" + } + + @Volatile + var uri: Uri? = savedStateHandle[STATE_URI] + set(value) { + field = value + savedStateHandle[STATE_URI] = value + } + + @Volatile + var page: Int = savedStateHandle[STATE_PAGE] ?: 1 + set(value) { + field = value + savedStateHandle[STATE_PAGE] = value + } + + @Volatile + var zoomRatio: Float = savedStateHandle[STATE_ZOOM_RATIO] ?: 0f + set(value) { + field = value + savedStateHandle[STATE_ZOOM_RATIO] = value + } + + @Volatile + var documentOrientationDegrees: Int = savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] ?: 0 + set(value) { + field = value + savedStateHandle[STATE_DOCUMENT_ORIENTATION_DEGREES] = value + } -class PdfViewModel : ViewModel() { + @Volatile + var numPages: Int = 0 + + @Volatile + var encryptedDocumentPassword: String = "" + + var webViewCrashed: Boolean = false enum class PasswordStatus { MissingPassword, @@ -32,6 +90,12 @@ class PdfViewModel : ViewModel() { // WebView to get outline. Lazily loaded, and will be cached until a different PDF is loaded. val outline: MutableLiveData = MutableLiveData(OutlineStatus.NotLoaded) + private val _saveError = MutableLiveData() + val saveError: LiveData get() = _saveError + private val _documentProperties = MutableLiveData?>() + val documentProperties: LiveData?> get() = _documentProperties + private var documentPropertiesLoading = false + private val scope = CoroutineScope(Dispatchers.IO) override fun onCleared() { @@ -90,4 +154,49 @@ class PdfViewModel : ViewModel() { outline.postValue(if (hasOutline) OutlineStatus.Available else OutlineStatus.NoOutline) } } + + fun clearSaveError() { + _saveError.value = false + } + + fun saveDocumentAs(contentResolver: ContentResolver, source: Uri, destination: Uri) { + viewModelScope.launch(Dispatchers.IO) { + try { + contentResolver.openInputStream(source)?.use { input -> + contentResolver.openOutputStream(destination)?.use { output -> + input.copyTo(output) + } ?: throw FileNotFoundException() + } ?: throw FileNotFoundException() + } catch (e: Exception) { + coroutineContext.ensureActive() + when (e) { + is IOException, is IllegalArgumentException, + is IllegalStateException, is SecurityException -> { + withContext(Dispatchers.Main) { + _saveError.value = true + } + } + else -> throw e + } + } + } + } + + fun retrieveDocumentProperties(properties: String, numPages: Int, uri: Uri) { + viewModelScope.launch(Dispatchers.IO) { + val loader = DocumentPropertiesRetriever(getApplication(), properties, numPages, uri) + val result = loader.retrieve() + withContext(Dispatchers.Main) { + if (documentPropertiesLoading) { + _documentProperties.value = result + } + } + } + documentPropertiesLoading = true + } + + fun clearDocumentProperties() { + _documentProperties.value = null + documentPropertiesLoading = false + } } diff --git a/viewer/js/index.js b/viewer/js/index.js index 3eed8feec..39dfff7be 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -215,7 +215,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { const defaultZoomRatio = getDefaultZoomRatio(page, orientationDegrees); - if (cache.length === 0) { + if (newZoomRatio === 0) { zoomRatio = defaultZoomRatio; newZoomRatio = defaultZoomRatio; channel.setZoomRatio(defaultZoomRatio);