package org.grapheneos.pdfviewer; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.util.AttributeSet; 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.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import org.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment; import org.grapheneos.pdfviewer.fragment.JumpToPageFragment; import org.grapheneos.pdfviewer.loader.DocumentPropertiesLoader; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.List; public class PdfViewer extends WebView implements LoaderManager.LoaderCallbacks> { public static final String TAG = "PdfViewer"; 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 PERMISSIONS_POLICY = "accelerometer=(), " + "ambient-light-sensor=(), " + "autoplay=(), " + "battery=(), " + "camera=(), " + "clipboard-read=(), " + "clipboard-write=(), " + "display-capture=(), " + "document-domain=(), " + "encrypted-media=(), " + "fullscreen=(), " + "geolocation=(), " + "gyroscope=(), " + "hid=(), " + "idle-detection=(), " + "interest-cohort=(), " + "magnetometer=(), " + "microphone=(), " + "midi=(), " + "payment=(), " + "picture-in-picture=(), " + "publickey-credentials-get=(), " + "screen-wake-lock=(), " + "serial=(), " + "sync-xhr=(), " + "usb=(), " + "xr-spatial-tracking=()"; 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; private static final int STATE_LOADED = 1; private static final int STATE_END = 2; private static final int PADDING = 10; public int mPage; public int mNumPages; private float mZoomRatio = 1f; private int mDocumentOrientationDegrees; private int mDocumentState; private int windowInsetTop; private List mDocumentProperties; private InputStream mInputStream; private TextView mTextView; private Toast mToast; public AppCompatActivity activity; String fileName; Long fileSize; void init(Context context) { setBackgroundColor(Color.TRANSPARENT); if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true); } setOnApplyWindowInsetsListener((view, insets) -> { windowInsetTop = insets.getSystemWindowInsetTop(); evaluateJavascript("updateInset()", null); return insets; }); final WebSettings settings = getSettings(); settings.setAllowContentAccess(false); settings.setAllowFileAccess(false); settings.setCacheMode(WebSettings.LOAD_NO_CACHE); settings.setJavaScriptEnabled(true); CookieManager.getInstance().setAcceptCookie(false); addJavascriptInterface(new Channel(), "channel"); setWebViewClient(new WebViewClient() { private WebResourceResponse fromAsset(final String mime, final String path) { try { InputStream inputStream = context.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("Permissions-Policy", PERMISSIONS_POLICY); headers.put("X-Content-Type-Options", "nosniff"); response.setResponseHeaders(headers); return response; } if ("/viewer.css".equals(path)) { return fromAsset("text/css", path); } if ("/viewer.js".equals(path) || "/pdf.js".equals(path) || "/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; activity.invalidateOptionsMenu(); } }); GestureHelper.attach(context, this, new GestureHelper.GestureListener() { @Override public void onZoomIn(float value) { zoomIn(value, false); } @Override public void onZoomOut(float value) { zoomOut(value, false); } @Override public void onZoomEnd() { zoomEnd(); } }); mTextView = new TextView(context); mTextView.setBackgroundColor(Color.DKGRAY); mTextView.setTextColor(ColorStateList.valueOf(Color.WHITE)); mTextView.setTextSize(18); mTextView.setPadding(PADDING, 0, PADDING, 0); } public PdfViewer(@NonNull Context context) { super(context); init(context); } public PdfViewer(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(context); } public PdfViewer(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } public void onCreateOptionMenu(Menu menu) { MenuInflater inflater = activity.getMenuInflater(); inflater.inflate(R.menu.pdf_viewer, menu); } private class Channel { @JavascriptInterface public int getWindowInsetTop() { return windowInsetTop; } @JavascriptInterface public int getPage() { return mPage; } @JavascriptInterface public float getZoomRatio() { return mZoomRatio; } @JavascriptInterface public int getDocumentOrientationDegrees() { return mDocumentOrientationDegrees; } @JavascriptInterface public void setNumPages(int numPages) { mNumPages = numPages; activity.runOnUiThread(activity::invalidateOptionsMenu); } @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); activity.runOnUiThread(() -> LoaderManager.getInstance(PdfViewer.this.activity).restartLoader(DocumentPropertiesLoader.ID, args, PdfViewer.this)); } } @NonNull @Override public Loader> onCreateLoader(int id, Bundle args) { return new DocumentPropertiesLoader(activity, args.getString(KEY_PROPERTIES), mNumPages, fileName, fileSize); } @Override public void onLoadFinished(@NonNull Loader> loader, List data) { mDocumentProperties = data; LoaderManager.getInstance(activity).destroyLoader(DocumentPropertiesLoader.ID); } @Override public void onLoaderReset(@NonNull Loader> loader) { mDocumentProperties = null; } public void loadPdf(InputStream inputStream, String fileName, Long fileSize) { mPage = 1; mDocumentProperties = null; mInputStream = inputStream; this.fileName = fileName; this.fileSize = fileSize; loadUrl("https://localhost/viewer.html"); activity.invalidateOptionsMenu(); } private void renderPage(final int zoom) { evaluateJavascript("onRenderPage(" + zoom + ")", null); } private void documentOrientationChanged(final int orientationDegreesOffset) { mDocumentOrientationDegrees = (mDocumentOrientationDegrees + orientationDegreesOffset) % 360; if (mDocumentOrientationDegrees < 0) { mDocumentOrientationDegrees += 360; } renderPage(0); } private void zoomIn(float value, boolean end) { if (mZoomRatio < MAX_ZOOM_RATIO) { mZoomRatio = Math.min(mZoomRatio + value, MAX_ZOOM_RATIO); renderPage(end ? 1 : 2); activity.invalidateOptionsMenu(); } } private void zoomOut(float value, boolean end) { if (mZoomRatio > MIN_ZOOM_RATIO) { mZoomRatio = Math.max(mZoomRatio - value, MIN_ZOOM_RATIO); renderPage(end ? 1 : 2); activity.invalidateOptionsMenu(); } } private void zoomEnd() { renderPage(1); } private static void enableDisableMenuItem(MenuItem item, boolean enable) { if (enable) { item.setEnabled(true); item.getIcon().setAlpha(ALPHA_HIGH); } else { item.setEnabled(false); item.getIcon().setAlpha(ALPHA_LOW); } } public void onJumpToPageInDocument(final int selected_page) { if (selected_page >= 1 && selected_page <= mNumPages && mPage != selected_page) { mPage = selected_page; renderPage(0); showPageNumber(); activity.invalidateOptionsMenu(); } } private void showPageNumber() { if (mToast != null) { mToast.cancel(); } mTextView.setText(String.format("%s/%s", mPage, mNumPages)); mToast = new Toast(activity); mToast.setGravity(Gravity.BOTTOM | Gravity.END, PADDING, PADDING); mToast.setDuration(Toast.LENGTH_SHORT); mToast.setView(mTextView); mToast.show(); } 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_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); 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; } enableDisableMenuItem(menu.findItem(R.id.action_zoom_in), mZoomRatio != MAX_ZOOM_RATIO); enableDisableMenuItem(menu.findItem(R.id.action_zoom_out), mZoomRatio != MIN_ZOOM_RATIO); enableDisableMenuItem(menu.findItem(R.id.action_next), mPage < mNumPages); enableDisableMenuItem(menu.findItem(R.id.action_previous), mPage > 1); return true; } public boolean onOptionsItemSelected(MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.action_previous) { onJumpToPageInDocument(mPage - 1); return true; } else if (itemId == R.id.action_next) { onJumpToPageInDocument(mPage + 1); return true; } else if (itemId == R.id.action_first) { onJumpToPageInDocument(1); return true; } else if (itemId == R.id.action_last) { onJumpToPageInDocument(mNumPages); return true; } else if (itemId == R.id.action_zoom_out) { zoomOut(0.25f, true); return true; } else if (itemId == R.id.action_zoom_in) { zoomIn(0.25f, true); return true; } else if (itemId == R.id.action_rotate_clockwise) { documentOrientationChanged(90); return true; } else if (itemId == R.id.action_rotate_counterclockwise) { documentOrientationChanged(-90); return true; } else if (itemId == R.id.action_view_document_properties) { DocumentPropertiesFragment .newInstance(mDocumentProperties) .show(activity.getSupportFragmentManager(), DocumentPropertiesFragment.TAG); return true; } else if (itemId == R.id.action_jump_to_page) { JumpToPageFragment.newInstance(this) .show(activity.getSupportFragmentManager(), JumpToPageFragment.TAG); return true; } return false; } }