libpdfviewer/app/src/main/java/org/grapheneos/pdfviewer/PdfViewer.java

443 lines
15 KiB
Java

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<List<CharSequence>> {
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<CharSequence> 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<String, String> headers = new HashMap<String, String>();
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<List<CharSequence>> onCreateLoader(int id, Bundle args) {
return new DocumentPropertiesLoader(this, args.getString(KEY_PROPERTIES), mNumPages, mUri);
}
@Override
public void onLoadFinished(Loader<List<CharSequence>> loader, List<CharSequence> data) {
mDocumentProperties = data;
LoaderManager.getInstance(this).destroyLoader(DocumentPropertiesLoader.ID);
}
@Override
public void onLoaderReset(Loader<List<CharSequence>> 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<CharSequence>) 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);
}
}
}