package org.grapheneos.pdfviewer; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.res.ColorStateList; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.webkit.CookieManager; import android.webkit.JavascriptInterface; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import org.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment; import org.grapheneos.pdfviewer.fragment.JumpToPageFragment; import org.grapheneos.pdfviewer.loader.DocumentPropertiesLoader; public class PdfViewer extends AppCompatActivity implements LoaderManager.LoaderCallbacks> { public static final String TAG = "PdfViewer"; private static final String STATE_URI = "uri"; private static final String STATE_PAGE = "page"; private static final String STATE_ZOOM_LEVEL = "zoomLevel"; private static final String KEY_PROPERTIES = "properties"; private static final String CONTENT_SECURITY_POLICY = "default-src 'none'; " + "form-action 'none'; " + "connect-src https://localhost/placeholder.pdf; " + "img-src blob: 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'none'"; private static final String FEATURE_POLICY = "accelerometer 'none'; " + "ambient-light-sensor 'none'; " + "autoplay 'none'; " + "camera 'none'; " + "encrypted-media 'none'; " + "fullscreen 'none'; " + "geolocation 'none'; " + "gyroscope 'none'; " + "magnetometer 'none'; " + "microphone 'none'; " + "midi 'none'; " + "payment 'none'; " + "picture-in-picture 'none'; " + "speaker 'none'; " + "sync-xhr 'none'; " + "usb 'none'; " + "vr 'none'"; private static final int MIN_ZOOM_LEVEL = 0; private static final int MAX_ZOOM_LEVEL = 4; private static final int ALPHA_LOW = 130; private static final int ALPHA_HIGH = 255; private static final int ACTION_OPEN_DOCUMENT_REQUEST_CODE = 1; private static final int STATE_LOADED = 1; private static final int STATE_END = 2; private static final int PADDING = 10; private Uri mUri; public int mPage; public int mNumPages; private int mZoomLevel = 2; private int mDocumentState; private List mDocumentProperties; private InputStream mInputStream; private WebView mWebView; private TextView mTextView; private Toast mToast; private class Channel { @JavascriptInterface public int getPage() { return mPage; } @JavascriptInterface public int getZoomLevel() { return mZoomLevel; } @JavascriptInterface public void setNumPages(int numPages) { mNumPages = numPages; } @JavascriptInterface public void setDocumentProperties(final String properties) { if (mDocumentProperties != null) { throw new SecurityException("mDocumentProperties not null"); } final Bundle args = new Bundle(); args.putString(KEY_PROPERTIES, properties); runOnUiThread(() -> { LoaderManager.getInstance(PdfViewer.this).restartLoader(DocumentPropertiesLoader.ID, args, PdfViewer.this); }); } } // Can be removed once minSdkVersion >= 26 @SuppressWarnings("deprecation") private void disableSaveFormData(final WebSettings settings) { settings.setSaveFormData(false); } @Override @SuppressLint("SetJavaScriptEnabled") protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.webview); mWebView = findViewById(R.id.webview); final WebSettings settings = mWebView.getSettings(); settings.setAllowContentAccess(false); settings.setAllowFileAccess(false); settings.setCacheMode(WebSettings.LOAD_NO_CACHE); settings.setJavaScriptEnabled(true); disableSaveFormData(settings); CookieManager.getInstance().setAcceptCookie(false); mWebView.addJavascriptInterface(new Channel(), "channel"); mWebView.setWebViewClient(new WebViewClient() { private WebResourceResponse fromAsset(final String mime, final String path) { try { InputStream inputStream = getAssets().open(path.substring(1)); return new WebResourceResponse(mime, null, inputStream); } catch (IOException e) { return null; } } @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { if (!"GET".equals(request.getMethod())) { return null; } final Uri url = request.getUrl(); if (!"localhost".equals(url.getHost())) { return null; } final String path = url.getPath(); Log.d(TAG, "path " + path); if ("/placeholder.pdf".equals(path)) { return new WebResourceResponse("application/pdf", null, mInputStream); } if ("/viewer.html".equals(path)) { final WebResourceResponse response = fromAsset("text/html", path); HashMap headers = new HashMap(); headers.put("Content-Security-Policy", CONTENT_SECURITY_POLICY); headers.put("Feature-Policy", FEATURE_POLICY); response.setResponseHeaders(headers); return response; } if ("/viewer.css".equals(path)) { return fromAsset("text/css", path); } if ("/viewer.js".equals(path)) { return fromAsset("application/javascript", path); } if ("/pdf.js".equals(path)) { return fromAsset("application/javascript", path); } if ("/pdf.worker.js".equals(path)) { return fromAsset("application/javascript", path); } return null; } @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { return true; } @Override public void onPageFinished(WebView view, String url) { mDocumentState = STATE_LOADED; invalidateOptionsMenu(); } }); 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); final Intent intent = getIntent(); if (Intent.ACTION_VIEW.equals(intent.getAction())) { if (!"application/pdf".equals(intent.getType())) { Log.e(TAG, "invalid mime type"); finish(); return; } mUri = intent.getData(); mPage = 1; } if (savedInstanceState != null) { mUri = savedInstanceState.getParcelable(STATE_URI); mPage = savedInstanceState.getInt(STATE_PAGE); mZoomLevel = savedInstanceState.getInt(STATE_ZOOM_LEVEL); } if (mUri != null) { loadPdf(); } } @Override public Loader> onCreateLoader(int id, Bundle args) { return new DocumentPropertiesLoader(this, args.getString(KEY_PROPERTIES), mNumPages, mUri); } @Override public void onLoadFinished(Loader> loader, List data) { mDocumentProperties = data; LoaderManager.getInstance(this).destroyLoader(DocumentPropertiesLoader.ID); } @Override public void onLoaderReset(Loader> loader) { mDocumentProperties = null; } private void loadPdf() { try { if (mInputStream != null) { mInputStream.close(); } mInputStream = getContentResolver().openInputStream(mUri); } catch (IOException e) { return; } mWebView.loadUrl("https://localhost/viewer.html"); } private void renderPage(final boolean lazy) { mWebView.evaluateJavascript(lazy ? "onRenderPage(true)" : "onRenderPage(false)", null); } private void openDocument() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/pdf"); startActivityForResult(intent, ACTION_OPEN_DOCUMENT_REQUEST_CODE); } private static void enableDisableMenuItem(MenuItem item, boolean enable) { if (enable) { if (!item.isEnabled()) { item.setEnabled(true); item.getIcon().setAlpha(ALPHA_HIGH); } } else if (item.isEnabled()) { item.setEnabled(false); item.getIcon().setAlpha(ALPHA_LOW); } } public void onJumpToPageInDocument(int selected_page) { if (selected_page >= 1 && selected_page <= mNumPages) { mPage = selected_page; renderPage(false); showPageNumber(); } } @Override public void onSaveInstanceState(Bundle savedInstanceState) { super.onSaveInstanceState(savedInstanceState); savedInstanceState.putParcelable(STATE_URI, mUri); savedInstanceState.putInt(STATE_PAGE, mPage); savedInstanceState.putInt(STATE_ZOOM_LEVEL, mZoomLevel); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent resultData) { if (requestCode == ACTION_OPEN_DOCUMENT_REQUEST_CODE && resultCode == Activity.RESULT_OK) { if (resultData != null) { mUri = resultData.getData(); mPage = 1; mDocumentProperties = null; loadPdf(); invalidateOptionsMenu(); } } } private void showPageNumber() { if (mToast != null) { mToast.cancel(); } mTextView.setText(String.format("%s/%s", mPage, mNumPages)); mToast = new Toast(getApplicationContext()); mToast.setGravity(Gravity.BOTTOM | Gravity.END, PADDING, PADDING); mToast.setDuration(Toast.LENGTH_SHORT); mToast.setView(mTextView); mToast.show(); } @Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.pdf_viewer, menu); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { final int ids[] = { R.id.action_zoom_in, R.id.action_zoom_out, R.id.action_jump_to_page, R.id.action_next, R.id.action_previous, R.id.action_view_document_properties }; 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; } switch (mZoomLevel) { case MAX_ZOOM_LEVEL: enableDisableMenuItem(menu.findItem(R.id.action_zoom_in), false); return true; case MIN_ZOOM_LEVEL: enableDisableMenuItem(menu.findItem(R.id.action_zoom_out), false); return true; default: enableDisableMenuItem(menu.findItem(R.id.action_zoom_in), true); enableDisableMenuItem(menu.findItem(R.id.action_zoom_out), true); return true; } } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.action_previous: if (mPage > 1) { mPage--; renderPage(false); showPageNumber(); } return true; case R.id.action_next: if (mPage < mNumPages) { mPage++; renderPage(false); showPageNumber(); } return true; case R.id.action_open: openDocument(); return super.onOptionsItemSelected(item); case R.id.action_zoom_out: if (mZoomLevel > 0) { mZoomLevel--; renderPage(true); invalidateOptionsMenu(); } return true; case R.id.action_zoom_in: if (mZoomLevel < MAX_ZOOM_LEVEL) { mZoomLevel++; renderPage(true); invalidateOptionsMenu(); } return true; case R.id.action_view_document_properties: DocumentPropertiesFragment .getInstance((ArrayList) mDocumentProperties) .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); return true; case R.id.action_jump_to_page: new JumpToPageFragment() .show(getSupportFragmentManager(), JumpToPageFragment.TAG); return true; default: return super.onOptionsItemSelected(item); } } }