diff --git a/app/build.gradle b/app/build.gradle index 19506255c..ad62f2073 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { compileSdkVersion 29 @@ -36,6 +37,12 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation "androidx.core:core-ktx:1.3.1" + implementation "androidx.fragment:fragment:1.3.0-alpha08" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" } def props = new Properties() @@ -61,3 +68,6 @@ if (propFile.canRead()) { println 'signing.properties not found' android.buildTypes.release.signingConfig = null } +repositories { + mavenCentral() +} diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..c4caf6728 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + PDF Viewer Debug + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dc055a63e..29ec74253 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,29 +1,38 @@ - - + package="org.grapheneos.pdfviewer" + android:targetSandboxVersion="2"> + + + + + + - - + + - + + \ No newline at end of file diff --git a/app/src/main/java/org/grapheneos/pdfviewer/PdfViewerActivity.java b/app/src/main/java/org/grapheneos/pdfviewer/PdfViewerActivity.java new file mode 100644 index 000000000..cedd17726 --- /dev/null +++ b/app/src/main/java/org/grapheneos/pdfviewer/PdfViewerActivity.java @@ -0,0 +1,24 @@ +package org.grapheneos.pdfviewer; + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; + +public class PdfViewerActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_pdf_viewer); + + if (savedInstanceState == null) { + PdfViewerFragment fragment = PdfViewerFragment.newInstance(); + getSupportFragmentManager() + .beginTransaction() + .setPrimaryNavigationFragment(fragment) + .add(R.id.pdf_fragment_container, fragment) + .commit(); + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/org/grapheneos/pdfviewer/PdfViewerFragment.java similarity index 57% rename from app/src/main/java/org/grapheneos/pdfviewer/PdfViewer.java rename to app/src/main/java/org/grapheneos/pdfviewer/PdfViewerFragment.java index 22517fa83..97d6aa6f6 100644 --- a/app/src/main/java/org/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/org/grapheneos/pdfviewer/PdfViewerFragment.java @@ -1,7 +1,6 @@ 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; @@ -9,10 +8,12 @@ import android.os.Bundle; import android.util.Log; import android.view.Gravity; +import android.view.LayoutInflater; import android.view.Menu; 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; @@ -23,124 +24,139 @@ import android.widget.TextView; import android.widget.Toast; -import androidx.appcompat.app.AppCompatActivity; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; import com.google.android.material.snackbar.Snackbar; +import org.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment; +import org.grapheneos.pdfviewer.fragment.JumpToPageFragment; +import org.grapheneos.pdfviewer.viewmodel.PdfViewerViewModel; + import java.io.IOException; import java.io.InputStream; 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 class PdfViewerFragment extends Fragment { 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_RATIO = "zoomRatio"; - private static final String STATE_DOCUMENT_ORIENTATION_DEGREES = "documentOrientationDegrees"; - 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' 'resource://pdf.js'; " + - "style-src 'self'; " + - "frame-ancestors 'none'; " + - "base-uri 'none'"; + "default-src 'none'; " + + "form-action 'none'; " + + "connect-src https://localhost/placeholder.pdf; " + + "img-src blob: 'self'; " + + "script-src 'self' 'resource://pdf.js'; " + + "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'"; + "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 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 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 float mZoomRatio = 1f; - private int mDocumentOrientationDegrees; private int mDocumentState; - private int windowInsetTop; - private List mDocumentProperties; + private WebView mWebView; + private int mWindowInsetsTop; private InputStream mInputStream; - private WebView mWebView; - private TextView mTextView; private Toast mToast; - private Snackbar snackbar; + private TextView mTextView; + private Snackbar mSnackbar; + + private PdfViewerViewModel mViewModel; private class Channel { @JavascriptInterface public int getWindowInsetTop() { - return windowInsetTop; + return mWindowInsetsTop; } @JavascriptInterface public int getPage() { - return mPage; + return mViewModel.getPage(); } @JavascriptInterface public float getZoomRatio() { - return mZoomRatio; + return mViewModel.getZoomRatio(); } @JavascriptInterface public int getDocumentOrientationDegrees() { - return mDocumentOrientationDegrees; + return mViewModel.getDocumentOrientationDegrees(); } @JavascriptInterface public void setNumPages(int numPages) { - mNumPages = numPages; - runOnUiThread(PdfViewer.this::invalidateOptionsMenu); + mViewModel.setNumPages(numPages); + requireActivity().runOnUiThread(() -> requireActivity().invalidateOptionsMenu()); } @JavascriptInterface public void setDocumentProperties(final String properties) { - if (mDocumentProperties != null) { - throw new SecurityException("mDocumentProperties not null"); + final List list = mViewModel.getDocumentProperties().getValue(); + if (list != null && list.isEmpty()) { + mViewModel.loadProperties(properties); + } else { + Log.d(TAG, "setDocumentProperties: did not load in properties"); } - - final Bundle args = new Bundle(); - args.putString(KEY_PROPERTIES, properties); - runOnUiThread(() -> { - LoaderManager.getInstance(PdfViewer.this).restartLoader(DocumentPropertiesLoader.ID, args, PdfViewer.this); - }); } } + public PdfViewerFragment() { + // Required empty public constructor + } + + public static PdfViewerFragment newInstance() { + return new PdfViewerFragment(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + getParentFragmentManager().setFragmentResultListener(JumpToPageFragment.REQUEST_KEY, + this, (requestKey, result) -> { + final int newPage = result.getInt(JumpToPageFragment.BUNDLE_KEY); + onJumpToPageInDocument(newPage); + }); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_webview, container, false); + } + // Can be removed once minSdkVersion >= 26 @SuppressWarnings("deprecation") private void disableSaveFormData(final WebSettings settings) { @@ -149,15 +165,15 @@ private void disableSaveFormData(final WebSettings settings) { @Override @SuppressLint({"SetJavaScriptEnabled", "ClickableViewAccessibility"}) - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + - setContentView(R.layout.webview); + mViewModel = new ViewModelProvider(requireActivity()).get(PdfViewerViewModel.class); - mWebView = findViewById(R.id.webview); + mWebView = view.findViewById(R.id.webview); - mWebView.setOnApplyWindowInsetsListener((view, insets) -> { - windowInsetTop = insets.getSystemWindowInsetTop(); + mWebView.setOnApplyWindowInsetsListener((v, insets) -> { + mWindowInsetsTop = insets.getSystemWindowInsetTop(); mWebView.evaluateJavascript("updateInset()", null); return insets; }); @@ -176,7 +192,7 @@ protected void onCreate(Bundle savedInstanceState) { mWebView.setWebViewClient(new WebViewClient() { private WebResourceResponse fromAsset(final String mime, final String path) { try { - InputStream inputStream = getAssets().open(path.substring(1)); + InputStream inputStream = requireActivity().getAssets().open(path.substring(1)); return new WebResourceResponse(mime, null, inputStream); } catch (IOException e) { return null; @@ -230,20 +246,20 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public void onPageFinished(WebView view, String url) { mDocumentState = STATE_LOADED; - invalidateOptionsMenu(); + requireActivity().invalidateOptionsMenu(); } }); showSystemUi(); - GestureHelper.attach(PdfViewer.this, mWebView, + GestureHelper.attach(getContext(), mWebView, new GestureHelper.GestureListener() { @Override public boolean onTapUp() { - if (mUri != null) { + if (mViewModel.getUri() != null) { mWebView.evaluateJavascript("isTextSelected()", selection -> { if (!Boolean.valueOf(selection)) { - if ((getWindow().getDecorView().getSystemUiVisibility() & + if ((requireActivity().getWindow().getDecorView().getSystemUiVisibility() & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { hideSystemUi(); } else { @@ -272,72 +288,57 @@ public void onZoomEnd() { } }); - mTextView = new TextView(this); + mTextView = new TextView(getContext()); 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); + mSnackbar = Snackbar.make(mWebView, "", Snackbar.LENGTH_LONG); - snackbar = Snackbar.make(mWebView, "", Snackbar.LENGTH_LONG); - - final Intent intent = getIntent(); - if (Intent.ACTION_VIEW.equals(intent.getAction())) { + final Intent intent = requireActivity().getIntent(); + if (savedInstanceState == null && Intent.ACTION_VIEW.equals(intent.getAction())) { if (!"application/pdf".equals(intent.getType())) { - snackbar.setText(R.string.invalid_mime_type).show(); + mSnackbar.setText(R.string.invalid_mime_type).show(); return; } - mUri = intent.getData(); - mPage = 1; - } - if (savedInstanceState != null) { - mUri = savedInstanceState.getParcelable(STATE_URI); - mPage = savedInstanceState.getInt(STATE_PAGE); - mZoomRatio = savedInstanceState.getFloat(STATE_ZOOM_RATIO); - mDocumentOrientationDegrees = savedInstanceState.getInt(STATE_DOCUMENT_ORIENTATION_DEGREES); + mViewModel.setUri(intent.getData()); + mViewModel.setPage(1); } - if (mUri != null) { - if ("file".equals(mUri.getScheme())) { - snackbar.setText(R.string.legacy_file_uri).show(); + final Uri uri = mViewModel.getUri(); + if (uri != null) { + if ("file".equals(uri.getScheme())) { + mSnackbar.setText(R.string.legacy_file_uri).show(); return; } - loadPdf(); + loadPdf(savedInstanceState == null); } } - @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(boolean isLoadingNewPdf) { + final Uri uri = mViewModel.getUri(); + if (uri == null) { + return; + } - private void loadPdf() { try { if (mInputStream != null) { mInputStream.close(); } - mInputStream = getContentResolver().openInputStream(mUri); + mInputStream = requireActivity().getContentResolver().openInputStream(uri); } catch (IOException e) { - snackbar.setText(R.string.io_error).show(); + mSnackbar.setText(R.string.io_error).show(); + mViewModel.clearDocumentProperties(); return; } + + if (isLoadingNewPdf) { + mViewModel.clearDocumentProperties(); + } + mWebView.loadUrl("https://localhost/viewer.html"); } @@ -346,33 +347,44 @@ private void renderPage(final int zoom) { } private void documentOrientationChanged(final int orientationDegreesOffset) { - mDocumentOrientationDegrees = (mDocumentOrientationDegrees + orientationDegreesOffset) % 360; - if (mDocumentOrientationDegrees < 0) { - mDocumentOrientationDegrees += 360; + int newOrientation = (mViewModel.getDocumentOrientationDegrees() + + orientationDegreesOffset) % 360; + if (newOrientation < 0) { + newOrientation += 360; } + mViewModel.setDocumentOrientationDegrees(newOrientation); renderPage(0); } + private ActivityResultLauncher mGetDocumentUriLauncher = registerForActivityResult( + new ActivityResultContracts.GetContent(), new ActivityResultCallback() { + @Override + public void onActivityResult(Uri uri) { + mViewModel.setUri(uri); + mViewModel.setPage(1); + loadPdf(true); + } + }); + 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); + mGetDocumentUriLauncher.launch("application/pdf"); } private void zoomIn(float value, boolean end) { - if (mZoomRatio < MAX_ZOOM_RATIO) { - mZoomRatio = Math.min(mZoomRatio + value, MAX_ZOOM_RATIO); + final float zoomRatio = mViewModel.getZoomRatio(); + if (zoomRatio < MAX_ZOOM_RATIO) { + mViewModel.setZoomRatio(Math.min(zoomRatio + value, MAX_ZOOM_RATIO)); renderPage(end ? 1 : 2); - invalidateOptionsMenu(); + requireActivity().invalidateOptionsMenu(); } } private void zoomOut(float value, boolean end) { - if (mZoomRatio > MIN_ZOOM_RATIO) { - mZoomRatio = Math.max(mZoomRatio - value, MIN_ZOOM_RATIO); + final float zoomRatio = mViewModel.getZoomRatio(); + if (zoomRatio > MIN_ZOOM_RATIO) { + mViewModel.setZoomRatio(Math.max(zoomRatio - value, MIN_ZOOM_RATIO)); renderPage(end ? 1 : 2); - invalidateOptionsMenu(); + requireActivity().invalidateOptionsMenu(); } } @@ -391,61 +403,39 @@ private static void enableDisableMenuItem(MenuItem item, boolean enable) { } 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 <= mViewModel.getNumPages() + && mViewModel.getPage() != selected_page) { + mViewModel.setPage(selected_page); renderPage(0); showPageNumber(); - invalidateOptionsMenu(); + requireActivity().invalidateOptionsMenu(); } } private void showSystemUi() { - getWindow().getDecorView().setSystemUiVisibility( + requireActivity().getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); } private void hideSystemUi() { - getWindow().getDecorView().setSystemUiVisibility( + requireActivity().getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_IMMERSIVE); - } - - @Override - public void onSaveInstanceState(Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - savedInstanceState.putParcelable(STATE_URI, mUri); - savedInstanceState.putInt(STATE_PAGE, mPage); - savedInstanceState.putFloat(STATE_ZOOM_RATIO, mZoomRatio); - savedInstanceState.putInt(STATE_DOCUMENT_ORIENTATION_DEGREES, mDocumentOrientationDegrees); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent resultData) { - super.onActivityResult(requestCode, resultCode, resultData); - - if (requestCode == ACTION_OPEN_DOCUMENT_REQUEST_CODE && resultCode == Activity.RESULT_OK) { - if (resultData != null) { - mUri = resultData.getData(); - mPage = 1; - mDocumentProperties = null; - loadPdf(); - invalidateOptionsMenu(); - } - } + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE); } private void showPageNumber() { if (mToast != null) { mToast.cancel(); } - mTextView.setText(String.format("%s/%s", mPage, mNumPages)); - mToast = new Toast(getApplicationContext()); + mTextView.setText(String.format("%s/%s", mViewModel.getPage(), mViewModel.getNumPages())); + mToast = new Toast(getContext()); mToast.setGravity(Gravity.BOTTOM | Gravity.END, PADDING, PADDING); mToast.setDuration(Toast.LENGTH_SHORT); mToast.setView(mTextView); @@ -453,19 +443,17 @@ private void showPageNumber() { } @Override - public boolean onCreateOptionsMenu(Menu menu) { - super.onCreateOptionsMenu(menu); - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.pdf_viewer, menu); - return true; + public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_pdf_viewer, menu); + super.onCreateOptionsMenu(menu, inflater); } @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, + public void onPrepareOptionsMenu(@NonNull 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 }; + R.id.action_view_document_properties}; if (mDocumentState < STATE_LOADED) { for (final int id : ids) { final MenuItem item = menu.findItem(id); @@ -483,23 +471,25 @@ public boolean onPrepareOptionsMenu(Menu menu) { 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; + enableDisableMenuItem(menu.findItem(R.id.action_zoom_in), + mViewModel.getZoomRatio() != MAX_ZOOM_RATIO); + enableDisableMenuItem(menu.findItem(R.id.action_zoom_out), + mViewModel.getZoomRatio() != MIN_ZOOM_RATIO); + enableDisableMenuItem(menu.findItem(R.id.action_next), + mViewModel.getPage() < mViewModel.getNumPages()); + enableDisableMenuItem(menu.findItem(R.id.action_previous), + mViewModel.getPage() > 1); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.action_previous: - onJumpToPageInDocument(mPage - 1); + onJumpToPageInDocument(mViewModel.getPage() - 1); return true; case R.id.action_next: - onJumpToPageInDocument(mPage + 1); + onJumpToPageInDocument(mViewModel.getPage() + 1); return true; case R.id.action_first: @@ -507,7 +497,7 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; case R.id.action_last: - onJumpToPageInDocument(mNumPages); + onJumpToPageInDocument(mViewModel.getNumPages()); return true; case R.id.action_open: @@ -532,17 +522,17 @@ public boolean onOptionsItemSelected(MenuItem item) { case R.id.action_view_document_properties: DocumentPropertiesFragment - .newInstance(mDocumentProperties) - .show(getSupportFragmentManager(), DocumentPropertiesFragment.TAG); + .newInstance() + .show(getParentFragmentManager(), DocumentPropertiesFragment.TAG); return true; case R.id.action_jump_to_page: new JumpToPageFragment() - .show(getSupportFragmentManager(), JumpToPageFragment.TAG); + .show(getParentFragmentManager(), JumpToPageFragment.TAG); return true; default: return super.onOptionsItemSelected(item); } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.java b/app/src/main/java/org/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.java index a2870654e..990fa6326 100644 --- a/app/src/main/java/org/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.java +++ b/app/src/main/java/org/grapheneos/pdfviewer/fragment/DocumentPropertiesFragment.java @@ -8,36 +8,33 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; - -import java.util.ArrayList; -import java.util.List; +import androidx.lifecycle.ViewModelProvider; import org.grapheneos.pdfviewer.R; +import org.grapheneos.pdfviewer.viewmodel.PdfViewerViewModel; + +import java.util.Collections; +import java.util.List; public class DocumentPropertiesFragment extends DialogFragment { public static final String TAG = "DocumentPropertiesFragment"; - private static final String KEY_DOCUMENT_PROPERTIES = "document_properties"; - - private List mDocumentProperties; - - public static DocumentPropertiesFragment newInstance(final List metaData) { - final DocumentPropertiesFragment fragment = new DocumentPropertiesFragment(); - final Bundle args = new Bundle(); + private ArrayAdapter mAdapter; + private PdfViewerViewModel mModel; - args.putCharSequenceArrayList(KEY_DOCUMENT_PROPERTIES, (ArrayList) metaData); - fragment.setArguments(args); - - return fragment; + public static DocumentPropertiesFragment newInstance() { + return new DocumentPropertiesFragment(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (getArguments() != null) { - mDocumentProperties = getArguments().getStringArrayList(KEY_DOCUMENT_PROPERTIES); - } + mModel = new ViewModelProvider(requireActivity()).get(PdfViewerViewModel.class); + + List list = mModel.getDocumentProperties().getValue(); + mAdapter = new ArrayAdapter<>(requireActivity(), android.R.layout.simple_list_item_1, + list != null ? list : Collections.emptyList()); } @NonNull @@ -46,14 +43,23 @@ public Dialog onCreateDialog(Bundle savedInstanceState) { final Activity activity = requireActivity(); final AlertDialog.Builder dialog = new AlertDialog.Builder(activity) .setPositiveButton(android.R.string.ok, null); + dialog.setAdapter(mAdapter, null); + + final List list = mModel.getDocumentProperties().getValue(); + dialog.setTitle(getTitleStringIdForPropertiesState(list)); + + final AlertDialog alertDialog = dialog.create(); + mModel.getDocumentProperties().observe(requireActivity(), charSequences -> { + alertDialog.setTitle(getTitleStringIdForPropertiesState(charSequences)); + mAdapter.notifyDataSetChanged(); + }); + + return alertDialog; + } - if (mDocumentProperties != null) { - dialog.setTitle(getString(R.string.action_view_document_properties)); - dialog.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, - mDocumentProperties), null); - } else { - dialog.setTitle(R.string.document_properties_retrieval_failed); - } - return dialog.create(); + private int getTitleStringIdForPropertiesState(final List properties) { + return properties == null || properties.isEmpty() + ? R.string.document_properties_retrieval_failed + : R.string.action_view_document_properties; } } diff --git a/app/src/main/java/org/grapheneos/pdfviewer/fragment/JumpToPageFragment.java b/app/src/main/java/org/grapheneos/pdfviewer/fragment/JumpToPageFragment.java index 53c236f5f..4e0b8bab1 100644 --- a/app/src/main/java/org/grapheneos/pdfviewer/fragment/JumpToPageFragment.java +++ b/app/src/main/java/org/grapheneos/pdfviewer/fragment/JumpToPageFragment.java @@ -9,12 +9,16 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProvider; -import org.grapheneos.pdfviewer.PdfViewer; +import org.grapheneos.pdfviewer.viewmodel.PdfViewerViewModel; public class JumpToPageFragment extends DialogFragment { public static final String TAG = "JumpToPageFragment"; + public static final String REQUEST_KEY = "jumpToPage"; + public static final String BUNDLE_KEY = "jumpToPageBundle"; + private final static String STATE_PICKER_CUR = "picker_cur"; private final static String STATE_PICKER_MIN = "picker_min"; private final static String STATE_PICKER_MAX = "picker_max"; @@ -33,24 +37,30 @@ public void onActivityCreated(Bundle savedInstanceState) { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { + final PdfViewerViewModel model = + new ViewModelProvider(requireActivity()).get(PdfViewerViewModel.class); + mPicker = new NumberPicker(getActivity()); mPicker.setMinValue(1); - mPicker.setMaxValue(((PdfViewer)getActivity()).mNumPages); - mPicker.setValue(((PdfViewer)getActivity()).mPage); + mPicker.setMaxValue(model.getNumPages()); + mPicker.setValue(model.getPage()); - final FrameLayout layout = new FrameLayout(getActivity()); + final FrameLayout layout = new FrameLayout(requireActivity()); layout.addView(mPicker, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); - return new AlertDialog.Builder(getActivity()) + return new AlertDialog.Builder(requireActivity()) .setView(layout) .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { mPicker.clearFocus(); - ((PdfViewer)getActivity()).onJumpToPageInDocument(mPicker.getValue()); + + Bundle result = new Bundle(); + result.putInt(BUNDLE_KEY, mPicker.getValue()); + getParentFragmentManager().setFragmentResult(REQUEST_KEY, result); } }) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/org/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.java b/app/src/main/java/org/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.java deleted file mode 100644 index f271c055d..000000000 --- a/app/src/main/java/org/grapheneos/pdfviewer/loader/DocumentPropertiesLoader.java +++ /dev/null @@ -1,149 +0,0 @@ -package org.grapheneos.pdfviewer.loader; - -import android.content.Context; -import android.database.Cursor; -import android.graphics.Typeface; -import android.net.Uri; -import android.provider.OpenableColumns; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.style.StyleSpan; -import android.util.Log; - -import androidx.loader.content.AsyncTaskLoader; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.text.ParseException; -import java.util.ArrayList; -import java.util.List; - -import org.grapheneos.pdfviewer.R; -import org.grapheneos.pdfviewer.Utils; - -public class DocumentPropertiesLoader 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; - - private Cursor mCursor; - - public DocumentPropertiesLoader(Context context, String properties, int numPages, Uri uri) { - super(context); - - mProperties = properties; - mNumPages = numPages; - mUri = uri; - } - - @Override - public List loadInBackground() { - final Context context = getContext(); - - final String[] names = context.getResources().getStringArray(R.array.property_names); - final List properties = new ArrayList<>(names.length); - - mCursor = context.getContentResolver().query(mUri, null, null, null, null); - if (mCursor != null) { - mCursor.moveToFirst(); - - final int indexName = mCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - if (indexName >= 0) { - properties.add(getProperty(null, names[0], mCursor.getString(indexName))); - } - - final int indexSize = mCursor.getColumnIndex(OpenableColumns.SIZE); - if (indexSize >= 0) { - final long fileSize = Long.valueOf(mCursor.getString(indexSize)); - properties.add(getProperty(null, names[1], Utils.parseFileSize(fileSize))); - } - - mCursor.close(); - } - - try { - final JSONObject json = new JSONObject(mProperties); - - properties.add(getProperty(json, names[2], "Title")); - properties.add(getProperty(json, names[3], "Author")); - properties.add(getProperty(json, names[4], "Subject")); - properties.add(getProperty(json, names[5], "Keywords")); - properties.add(getProperty(json, names[6], "CreationDate")); - properties.add(getProperty(json, names[7], "ModDate")); - properties.add(getProperty(json, names[8], "Producer")); - properties.add(getProperty(json, names[9], "Creator")); - properties.add(getProperty(json, names[10], "PDFFormatVersion")); - properties.add(getProperty(null, names[11], String.valueOf(mNumPages))); - - return properties; - } catch (JSONException e) { - e.printStackTrace(); - } - return null; - } - - @Override - public void deliverResult(List properties) { - if (isReset()) { - onReleaseResources(); - } else if (isStarted()) { - super.deliverResult(properties); - } - } - - @Override - protected void onStartLoading() { - forceLoad(); - } - - @Override - protected void onStopLoading() { - cancelLoad(); - } - - @Override - public void onCanceled(List properties) { - super.onCanceled(properties); - - onReleaseResources(); - } - - @Override - protected void onReset() { - super.onReset(); - - onStopLoading(); - onReleaseResources(); - } - - private void onReleaseResources() { - if (mCursor != null) { - mCursor.close(); - mCursor = null; - } - } - - private CharSequence getProperty(final JSONObject json, String name, String specName) { - final SpannableStringBuilder property = new SpannableStringBuilder(name).append(":\n"); - final String value = json != null ? json.optString(specName, "-") : specName; - - if (specName.endsWith("Date")) { - final Context context = getContext(); - try { - property.append(value.equals("-") ? value : Utils.parseDate(value)); - } catch (ParseException e) { - Log.w(TAG, e.getMessage() + " for " + value + " at offset: " + e.getErrorOffset()); - property.append(context.getString(R.string.document_properties_invalid_date)); - } - } else { - property.append(value); - } - property.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - return property; - } -} diff --git a/app/src/main/java/org/grapheneos/pdfviewer/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/org/grapheneos/pdfviewer/viewmodel/PdfViewerViewModel.kt new file mode 100644 index 000000000..f4d9ca274 --- /dev/null +++ b/app/src/main/java/org/grapheneos/pdfviewer/viewmodel/PdfViewerViewModel.kt @@ -0,0 +1,119 @@ +package org.grapheneos.pdfviewer.viewmodel + +import android.app.Application +import android.database.Cursor +import android.graphics.Typeface +import android.net.Uri +import android.provider.OpenableColumns +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.StyleSpan +import android.util.Log +import androidx.annotation.NonNull +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.grapheneos.pdfviewer.R +import org.grapheneos.pdfviewer.Utils +import org.json.JSONException +import org.json.JSONObject +import java.text.ParseException + +private const val TAG = "PdfViewerViewModel" + +private const val MISSING_STRING = "-" + +class PdfViewerViewModel(application: Application) : AndroidViewModel(application) { + var uri: Uri? = null + var page: Int = 0 + var numPages: Int = 0 + var zoomRatio: Float = 1f + var documentOrientationDegrees: Int = 0 + + private val documentProperties: MutableLiveData> by lazy { + MutableLiveData>() + } + + @NonNull + fun getDocumentProperties(): LiveData> = documentProperties + + fun clearDocumentProperties() { + val list = documentProperties.value as? ArrayList ?: ArrayList() + list.clear() + documentProperties.postValue(list) + } + + fun loadProperties(propertiesString: String) { + if (uri == null) { + Log.w(TAG, "Failed to parse properties: Uri is null") + clearDocumentProperties() + return + } + + viewModelScope.launch(Dispatchers.Default) { + val context = getApplication() + val names = context.resources.getStringArray(R.array.property_names); + val properties = ArrayList() + + val cursor: Cursor? = context.contentResolver.query(uri!!, null, null, null, null); + cursor?.let { + it.moveToFirst(); + + val indexOfName = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (indexOfName >= 0) { + properties.add(getProperty(null, names[0], it.getString(indexOfName))) + } + + val indexOfSize = it.getColumnIndex(OpenableColumns.SIZE) + if (indexOfSize >= 0) { + val fileSize = it.getString(indexOfSize).toLong() + properties.add(getProperty(null, names[1], Utils.parseFileSize(fileSize))) + } + } + cursor?.close() + + val specNames = arrayOf("Title", "Author", "Subject", "Keywords", "CreationDate", + "ModDate", "Producer", "Creator", "PDFFormatVersion", numPages.toString()) + try { + val json = JSONObject(propertiesString) + for (i in 2 until names.size) { + properties.add(getProperty(json, names[i], specNames[i - 2])) + } + + Log.d(TAG, "Successfully parsed properties") + val list = documentProperties.value as ArrayList + list.run { + clear() + addAll(properties) + documentProperties.postValue(this) + } + } catch (e: JSONException) { + clearDocumentProperties() + Log.e(TAG, "Failed to parse properties: ${e.message}", e) + } + } + } + + private fun getProperty(json: JSONObject?, name: String, specName: String): CharSequence { + val property = SpannableStringBuilder(name).append(":\n") + val value = json?.optString(specName, MISSING_STRING) ?: specName + + val valueToAppend = if (specName.endsWith("Date") && value != MISSING_STRING) { + try { + Utils.parseDate(value) + } catch (e: ParseException) { + Log.w(TAG, "${e.message} for $value at offset: ${e.errorOffset}") + val context = getApplication() + context.getString(R.string.document_properties_invalid_date) + } + } else { + value + } + property.append(valueToAppend) + property.setSpan(StyleSpan(Typeface.BOLD), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + return property + } +} diff --git a/app/src/main/res/layout/activity_pdf_viewer.xml b/app/src/main/res/layout/activity_pdf_viewer.xml new file mode 100644 index 000000000..776c35ac8 --- /dev/null +++ b/app/src/main/res/layout/activity_pdf_viewer.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/webview.xml b/app/src/main/res/layout/fragment_webview.xml similarity index 100% rename from app/src/main/res/layout/webview.xml rename to app/src/main/res/layout/fragment_webview.xml diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/menu_pdf_viewer.xml similarity index 100% rename from app/src/main/res/menu/pdf_viewer.xml rename to app/src/main/res/menu/menu_pdf_viewer.xml diff --git a/build.gradle b/build.gradle index 4b7409b41..dd815d8b7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = '1.4.0' repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:4.0.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files