Преглед изворни кода

Fallback pdf viewer (#9806)

* Basic PDF preview

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Preview pdf: add dividers between pages

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Prefer third-party pdf viewers over fallback viewer

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Zoomable view for pdf pages

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

pdf preview: Show tip to indicate that zooming on images is possible

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: screenshot test

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Fix spotbugs and lint

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* PreviewPdfFragment: fix screenshot test

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* PreviewPdfFragment: fix toolbar when resuming

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* PreviewBitmapActivity: add screenshot test

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* PreviewPdfFragment: fix crash in screenshot tests

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: rename tests

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* pdf preview: Update copyright headers

Oops

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: use grey for background instead of black

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: add scrollbar

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: show zoom tip 3 times instead of only once

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>

* Pdf preview: fix lint and improve styling

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>
Álvaro Brey пре 3 година
родитељ
комит
f9febe24ad
25 измењених фајлова са 691 додато и 3 уклоњено
  1. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT_showBitmap.png
  2. BIN
      screenshots/gplay/debug/com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT_showPdf.png
  3. BIN
      src/androidTest/assets/test.pdf
  4. 58 0
      src/androidTest/java/com/owncloud/android/ui/preview/PreviewBitmapScreenshotIT.kt
  5. 68 0
      src/androidTest/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragmentScreenshotIT.kt
  6. 5 0
      src/main/AndroidManifest.xml
  7. 4 0
      src/main/java/com/nextcloud/client/di/ComponentsModule.java
  8. 6 0
      src/main/java/com/nextcloud/client/di/ViewModelModule.kt
  9. 4 0
      src/main/java/com/nextcloud/client/preferences/AppPreferences.java
  10. 12 0
      src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java
  11. 24 2
      src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
  12. 2 0
      src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
  13. 9 1
      src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
  14. 65 0
      src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt
  15. 83 0
      src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfAdapter.kt
  16. 130 0
      src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragment.kt
  17. 91 0
      src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfViewModel.kt
  18. 8 0
      src/main/java/com/owncloud/android/utils/MimeTypeUtil.java
  19. 41 0
      src/main/res/layout/activity_preview_bitmap.xml
  20. 36 0
      src/main/res/layout/preview_pdf_fragment.xml
  21. 28 0
      src/main/res/layout/preview_pdf_page_item.xml
  22. 8 0
      src/main/res/values-v27/styles.xml
  23. 1 0
      src/main/res/values/colors.xml
  24. 1 0
      src/main/res/values/strings.xml
  25. 7 0
      src/main/res/values/styles.xml

BIN
screenshots/gplay/debug/com.owncloud.android.ui.preview.PreviewBitmapScreenshotIT_showBitmap.png


BIN
screenshots/gplay/debug/com.owncloud.android.ui.preview.pdf.PreviewPdfFragmentScreenshotIT_showPdf.png


BIN
src/androidTest/assets/test.pdf


+ 58 - 0
src/androidTest/java/com/owncloud/android/ui/preview/PreviewBitmapScreenshotIT.kt

@@ -0,0 +1,58 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview
+
+import android.content.Intent
+import androidx.test.espresso.intent.rule.IntentsTestRule
+import com.owncloud.android.AbstractIT
+import com.owncloud.android.utils.ScreenshotTest
+import org.junit.Rule
+import org.junit.Test
+
+class PreviewBitmapScreenshotIT : AbstractIT() {
+
+    companion object {
+        private const val PNG_FILE_ASSET = "imageFile.png"
+    }
+
+    @get:Rule
+    val testActivityRule = IntentsTestRule(PreviewBitmapActivity::class.java, true, false)
+
+    @Test
+    @ScreenshotTest
+    fun showBitmap() {
+
+        val pngFile = getFile(PNG_FILE_ASSET)
+
+        val activity = testActivityRule.launchActivity(
+            Intent().putExtra(
+                PreviewBitmapActivity.EXTRA_BITMAP_PATH,
+                pngFile.absolutePath
+            )
+        )
+
+        shortSleep()
+        waitForIdleSync()
+
+        screenshot(activity)
+    }
+}

+ 68 - 0
src/androidTest/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragmentScreenshotIT.kt

@@ -0,0 +1,68 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview.pdf
+
+import androidx.lifecycle.Lifecycle
+import androidx.test.espresso.intent.rule.IntentsTestRule
+import com.nextcloud.client.TestActivity
+import com.owncloud.android.AbstractIT
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.utils.ScreenshotTest
+import org.junit.Rule
+import org.junit.Test
+
+class PreviewPdfFragmentScreenshotIT : AbstractIT() {
+
+    companion object {
+        private const val PDF_FILE_ASSET = "test.pdf"
+    }
+
+    @get:Rule
+    val testActivityRule = IntentsTestRule(TestActivity::class.java, true, false)
+
+    @Test
+    @ScreenshotTest
+    fun showPdf() {
+        val activity = testActivityRule.launchActivity(null)
+
+        val pdfFile = getFile(PDF_FILE_ASSET)
+        val ocFile = OCFile("/test.pdf").apply {
+            storagePath = pdfFile.absolutePath
+        }
+
+        val sut = PreviewPdfFragment.newInstance(ocFile)
+        activity.addFragment(sut)
+
+        while (!sut.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
+            shortSleep()
+        }
+
+        activity.runOnUiThread {
+            sut.dismissSnack()
+        }
+
+        shortSleep()
+        waitForIdleSync()
+
+        screenshot(activity)
+    }
+}

+ 5 - 0
src/main/AndroidManifest.xml

@@ -466,6 +466,11 @@
             android:name="com.nextcloud.client.etm.EtmActivity"
             android:exported="false"
             android:theme="@style/Theme.ownCloud.Toolbar" />
+
+        <activity
+            android:name=".ui.preview.PreviewBitmapActivity"
+            android:exported="false"
+            android:theme="@style/Theme.ownCloud.OverlayGrey" />
     </application>
 
     <queries>

+ 4 - 0
src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -85,6 +85,7 @@ import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment;
 import com.owncloud.android.ui.preview.PreviewImageActivity;
 import com.owncloud.android.ui.preview.PreviewImageFragment;
 import com.owncloud.android.ui.preview.PreviewMediaFragment;
+import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment;
 import com.owncloud.android.ui.preview.PreviewTextFileFragment;
 import com.owncloud.android.ui.preview.PreviewTextFragment;
 import com.owncloud.android.ui.preview.PreviewTextStringFragment;
@@ -211,4 +212,7 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector abstract PlayerService playerService();
     @ContributesAndroidInjector abstract FileTransferService fileDownloaderService();
     @ContributesAndroidInjector abstract FileSyncService fileSyncService();
+
+    @ContributesAndroidInjector
+    abstract PreviewPdfFragment previewPDFFragment();
 }

+ 6 - 0
src/main/java/com/nextcloud/client/di/ViewModelModule.kt

@@ -23,6 +23,7 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
 import com.nextcloud.client.etm.EtmViewModel
 import com.nextcloud.client.logger.ui.LogsViewModel
+import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel
 import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
 import dagger.Binds
 import dagger.Module
@@ -45,6 +46,11 @@ abstract class ViewModelModule {
     @ViewModelKey(UnifiedSearchViewModel::class)
     abstract fun unifiedSearchViewModel(vm: UnifiedSearchViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(PreviewPdfViewModel::class)
+    abstract fun previewPDFViewModel(vm: PreviewPdfViewModel): ViewModel
+
     @Binds
     abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
 }

+ 4 - 0
src/main/java/com/nextcloud/client/preferences/AppPreferences.java

@@ -369,4 +369,8 @@ public interface AppPreferences {
     long getCalendarLastBackup();
 
     void setCalendarLastBackup(long timestamp);
+
+    void setPdfZoomTipShownCount(int count);
+
+    int getPdfZoomTipShownCount();
 }

+ 12 - 0
src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java

@@ -95,6 +95,8 @@ public final class AppPreferencesImpl implements AppPreferences {
     private static final String PREF__CALENDAR_AUTOMATIC_BACKUP = "calendar_automatic_backup";
     private static final String PREF__CALENDAR_LAST_BACKUP = "calendar_last_backup";
 
+    private static final String PREF__PDF_ZOOM_TIP_SHOWN = "pdf_zoom_tip_shown";
+
     private final Context context;
     private final SharedPreferences preferences;
     private final CurrentAccountProvider currentAccountProvider;
@@ -684,6 +686,16 @@ public final class AppPreferencesImpl implements AppPreferences {
         preferences.edit().putLong(PREF__CALENDAR_LAST_BACKUP, timestamp).apply();
     }
 
+    @Override
+    public void setPdfZoomTipShownCount(int count) {
+        preferences.edit().putInt(PREF__PDF_ZOOM_TIP_SHOWN, count).apply();
+    }
+
+    @Override
+    public int getPdfZoomTipShownCount() {
+        return preferences.getInt(PREF__PDF_ZOOM_TIP_SHOWN, 0);
+    }
+
     @VisibleForTesting
     public int computeBruteForceDelay(int count) {
         return (int) Math.min(count / 3d, 10);

+ 24 - 2
src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

@@ -107,6 +107,7 @@ import com.owncloud.android.ui.helpers.UriUploader;
 import com.owncloud.android.ui.preview.PreviewImageActivity;
 import com.owncloud.android.ui.preview.PreviewImageFragment;
 import com.owncloud.android.ui.preview.PreviewMediaFragment;
+import com.owncloud.android.ui.preview.pdf.PreviewPdfFragment;
 import com.owncloud.android.ui.preview.PreviewTextFileFragment;
 import com.owncloud.android.ui.preview.PreviewTextFragment;
 import com.owncloud.android.ui.preview.PreviewTextStringFragment;
@@ -731,6 +732,9 @@ public class FileDisplayActivity extends FileActivity
                         } else if (PreviewTextFileFragment.canBePreviewed(mWaitingToPreview)) {
                             startTextPreview(mWaitingToPreview, true);
                             detailsFragmentChanged = true;
+                        } else if (MimeTypeUtil.isPDF(mWaitingToPreview)) {
+                            startPdfPreview(mWaitingToPreview);
+                            detailsFragmentChanged = true;
                         } else {
                             getFileOperationsHelper().openFile(mWaitingToPreview);
                         }
@@ -846,7 +850,7 @@ public class FileDisplayActivity extends FileActivity
                 onBackPressed();
             } else if (getLeftFragment() instanceof FileDetailFragment ||
                 getLeftFragment() instanceof PreviewMediaFragment ||
-                getLeftFragment() instanceof UnifiedSearchFragment) {
+                getLeftFragment() instanceof UnifiedSearchFragment || getLeftFragment() instanceof PreviewPdfFragment) {
                 onBackPressed();
             } else {
                 openDrawer();
@@ -2257,6 +2261,24 @@ public class FileDisplayActivity extends FileActivity
         ContactsPreferenceActivity.startActivityWithContactsFile(this, user, file);
     }
 
+    public void startPdfPreview(OCFile file) {
+        if (getFileOperationsHelper().canOpenFile(file)) {
+            // prefer third party PDF apps
+            getFileOperationsHelper().openFile(file);
+        } else {
+            final Fragment pdfFragment = PreviewPdfFragment.newInstance(file);
+
+            setLeftFragment(pdfFragment);
+            updateFragmentsVisibility(false);
+
+            updateActionBarTitleAndHomeButton(file);
+            showSortListGroup(false);
+            mDrawerToggle.setDrawerIndicatorEnabled(false);
+            setMainFabVisible(false);
+        }
+    }
+
+
     /**
      * Requests the download of the received {@link OCFile} , updates the UI to monitor the download progress and
      * prepares the activity to preview or open the file when the download finishes.
@@ -2518,7 +2540,7 @@ public class FileDisplayActivity extends FileActivity
         setLeftFragment(unifiedSearchFragment);
     }
 
-    public void setMainFabVisible(final Boolean visible) {
+    public void setMainFabVisible(final boolean visible) {
         final int visibility = visible ? View.VISIBLE : View.GONE;
         binding.fabMain.setVisibility(visibility);
     }

+ 2 - 0
src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

@@ -986,6 +986,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
                         }
                     } else if (file.isDown() && MimeTypeUtil.isVCard(file)) {
                         ((FileDisplayActivity) mContainerActivity).startContactListFragment(file);
+                    } else if (file.isDown() && MimeTypeUtil.isPDF(file)) {
+                        ((FileDisplayActivity) mContainerActivity).startPdfPreview(file);
                     } else if (PreviewTextFileFragment.canBePreviewed(file)) {
                         setFabVisible(false);
                         resetHeaderScrollingState();

+ 9 - 1
src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java

@@ -282,12 +282,20 @@ public class FileOperationsHelper {
         fileActivity.dismissLoadingDialog();
     }
 
+    public boolean canOpenFile(OCFile file) {
+        final Intent openFileWithIntent = createOpenFileIntent(file);
+
+        List<ResolveInfo> launchables = fileActivity.getPackageManager().
+            queryIntentActivities(openFileWithIntent, PackageManager.GET_RESOLVED_FILTER);
+        return !launchables.isEmpty();
+    }
+
     public void openFile(OCFile file) {
         if (file != null) {
             final Intent openFileWithIntent = createOpenFileIntent(file);
 
             List<ResolveInfo> launchables = fileActivity.getPackageManager().
-                    queryIntentActivities(openFileWithIntent, PackageManager.GET_RESOLVED_FILTER);
+                queryIntentActivities(openFileWithIntent, PackageManager.GET_RESOLVED_FILTER);
 
             if (launchables.isEmpty()) {
                 Optional<User> optionalUser = fileActivity.getUser();

+ 65 - 0
src/main/java/com/owncloud/android/ui/preview/PreviewBitmapActivity.kt

@@ -0,0 +1,65 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview
+
+import android.graphics.BitmapFactory
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.owncloud.android.databinding.ActivityPreviewBitmapBinding
+
+/**
+ * Zoomable preview of a single bitmap
+ */
+class PreviewBitmapActivity : AppCompatActivity() {
+
+    companion object {
+        const val EXTRA_BITMAP_PATH = "EXTRA_BITMAP_PATH"
+    }
+
+    private lateinit var binding: ActivityPreviewBitmapBinding
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        binding = ActivityPreviewBitmapBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        setupImage()
+
+        supportActionBar?.let {
+            it.setDisplayHomeAsUpEnabled(true)
+            it.setDisplayShowHomeEnabled(true)
+        }
+    }
+
+    private fun setupImage() {
+        val path = intent.getStringExtra(EXTRA_BITMAP_PATH)
+        require(path != null)
+
+        val bitmap = BitmapFactory.decodeFile(path)
+        binding.image.setImageBitmap(bitmap)
+    }
+
+    override fun onSupportNavigateUp(): Boolean {
+        onBackPressed()
+        return true
+    }
+}

+ 83 - 0
src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfAdapter.kt

@@ -0,0 +1,83 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview.pdf
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.pdf.PdfRenderer
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.owncloud.android.databinding.PreviewPdfPageItemBinding
+
+/**
+ * @param renderer an **open** [PdfRenderer]
+ */
+class PreviewPdfAdapter(
+    private val renderer: PdfRenderer,
+    private val screenWidth: Int,
+    private val onClickListener: (Bitmap) -> Unit
+) :
+    RecyclerView.Adapter<PreviewPdfAdapter.ViewHolder>() {
+
+    class ViewHolder(val binding: PreviewPdfPageItemBinding, val onClickListener: (Bitmap) -> Unit) :
+        RecyclerView.ViewHolder(binding.root) {
+        fun bind(bitmap: Bitmap) {
+            binding.page.setImageBitmap(bitmap)
+            binding.root.setOnClickListener {
+                onClickListener(bitmap)
+            }
+        }
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val binding = PreviewPdfPageItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        return ViewHolder(binding, onClickListener)
+    }
+
+    override fun getItemCount() = renderer.pageCount
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val page = renderer.openPage(position)
+        val bitmap = renderPage(page)
+        holder.bind(bitmap)
+    }
+
+    private fun renderPage(page: PdfRenderer.Page) = page.use {
+        val bitmap = createBitmapForPage(it)
+        page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+        bitmap
+    }
+
+    private fun createBitmapForPage(page: PdfRenderer.Page): Bitmap {
+        val bitmap = Bitmap.createBitmap(
+            screenWidth, (screenWidth.toFloat() / page.width * page.height).toInt(), Bitmap.Config.ARGB_8888
+        )
+
+        val canvas = Canvas(bitmap)
+        canvas.drawColor(Color.WHITE)
+        canvas.drawBitmap(bitmap, 0f, 0f, null)
+
+        return bitmap
+    }
+}

+ 130 - 0
src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfFragment.kt

@@ -0,0 +1,130 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview.pdf
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.snackbar.Snackbar
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.di.ViewModelFactory
+import com.owncloud.android.R
+import com.owncloud.android.databinding.PreviewPdfFragmentBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.FileMenuFilter
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.preview.PreviewBitmapActivity
+import com.owncloud.android.utils.DisplayUtils
+import javax.inject.Inject
+
+class PreviewPdfFragment : Fragment(), Injectable {
+
+    @Inject
+    lateinit var vmFactory: ViewModelFactory
+
+    private lateinit var binding: PreviewPdfFragmentBinding
+    private lateinit var viewModel: PreviewPdfViewModel
+    private lateinit var file: OCFile
+
+    private var snack: Snackbar? = null
+
+    companion object {
+        private const val ARG_FILE = "FILE"
+
+        @JvmStatic
+        fun newInstance(file: OCFile): PreviewPdfFragment = PreviewPdfFragment().apply {
+            arguments = Bundle().apply {
+                putParcelable(ARG_FILE, file)
+            }
+        }
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+        binding = PreviewPdfFragmentBinding.inflate(inflater)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        setupObservers()
+
+        file = requireArguments().getParcelable(ARG_FILE)!!
+        viewModel.process(file)
+    }
+
+    private fun setupObservers() {
+        viewModel.pdfRenderer.observe(viewLifecycleOwner) { renderer ->
+            binding.pdfRecycler.adapter = PreviewPdfAdapter(renderer, getScreenWidth()) { page ->
+                viewModel.onClickPage(page)
+            }
+        }
+        viewModel.previewImagePath.observe(viewLifecycleOwner) {
+            it?.let { path ->
+                val intent = Intent(context, PreviewBitmapActivity::class.java).apply {
+                    putExtra(PreviewBitmapActivity.EXTRA_BITMAP_PATH, path)
+                }
+                requireContext().startActivity(intent)
+            }
+        }
+        viewModel.shouldShowZoomTip.observe(viewLifecycleOwner) { shouldShow ->
+            if (shouldShow) {
+                snack = DisplayUtils.showSnackMessage(binding.root, R.string.pdf_zoom_tip)
+                viewModel.onZoomTipShown()
+            }
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        viewModel = ViewModelProvider(this, vmFactory)[PreviewPdfViewModel::class.java]
+        setHasOptionsMenu(true)
+    }
+
+    private fun getScreenWidth(): Int =
+        requireContext().resources.displayMetrics.widthPixels
+
+    override fun onPrepareOptionsMenu(menu: Menu) {
+        super.onPrepareOptionsMenu(menu)
+        FileMenuFilter.hideAll(menu)
+    }
+
+    override fun onResume() {
+        super.onResume()
+        val parent = activity
+        if (parent is FileDisplayActivity) {
+            parent.showSortListGroup(false)
+            parent.updateActionBarTitleAndHomeButton(file)
+        }
+    }
+
+    @VisibleForTesting
+    fun dismissSnack() {
+        snack?.dismiss()
+    }
+}

+ 91 - 0
src/main/java/com/owncloud/android/ui/preview/pdf/PreviewPdfViewModel.kt

@@ -0,0 +1,91 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.owncloud.android.ui.preview.pdf
+
+import android.graphics.Bitmap
+import android.graphics.pdf.PdfRenderer
+import android.os.ParcelFileDescriptor
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.MainApp
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.utils.Log_OC
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+
+class PreviewPdfViewModel @Inject constructor(val appPreferences: AppPreferences) : ViewModel() {
+
+    companion object {
+        private const val SHOW_ZOOM_TIP_TIMES = 3
+    }
+
+    private var _pdfRenderer = MutableLiveData<PdfRenderer>()
+    val pdfRenderer: LiveData<PdfRenderer>
+        get() = _pdfRenderer
+
+    private var _previewImagePath = MutableLiveData<String>()
+    val previewImagePath: LiveData<String>
+        get() = _previewImagePath
+
+    private var _showZoomTip = MutableLiveData<Boolean>()
+    val shouldShowZoomTip: LiveData<Boolean>
+        get() = _showZoomTip
+
+    override fun onCleared() {
+        super.onCleared()
+        closeRenderer()
+    }
+
+    private fun closeRenderer() {
+        try {
+            _pdfRenderer.value?.close()
+        } catch (e: IllegalStateException) {
+            Log_OC.e(this, "closeRenderer: trying to close already closed renderer", e)
+        }
+    }
+
+    fun process(file: OCFile) {
+        closeRenderer()
+        _pdfRenderer.value =
+            PdfRenderer(ParcelFileDescriptor.open(File(file.storagePath), ParcelFileDescriptor.MODE_READ_ONLY))
+        if (appPreferences.pdfZoomTipShownCount < SHOW_ZOOM_TIP_TIMES) {
+            _showZoomTip.value = true
+        }
+    }
+
+    fun onClickPage(page: Bitmap) {
+        val file = File.createTempFile("pdf_page", ".bmp", MainApp.getAppContext().cacheDir)
+        val outStream = FileOutputStream(file)
+        page.compress(Bitmap.CompressFormat.PNG, 0, outStream)
+        outStream.close()
+
+        _previewImagePath.value = file.path
+    }
+
+    fun onZoomTipShown() {
+        appPreferences.pdfZoomTipShownCount++
+        _showZoomTip.value = false
+    }
+}

+ 8 - 0
src/main/java/com/owncloud/android/utils/MimeTypeUtil.java

@@ -340,6 +340,14 @@ public final class MimeTypeUtil {
         return MimeType.DIRECTORY.equalsIgnoreCase(mimeType);
     }
 
+    public static boolean isPDF(String mimeType){
+        return "application/pdf".equalsIgnoreCase(mimeType);
+    }
+
+    public static boolean isPDF(OCFile file){
+        return isPDF(file.getMimeType()) || isPDF(getMimeTypeFromPath(file.getRemotePath()));
+    }
+
     /**
      * Extracts the mime type for the given file.
      *

+ 41 - 0
src/main/res/layout/activity_preview_bitmap.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:paddingTop="?attr/actionBarSize"
+    tools:context=".ui.preview.PreviewBitmapActivity">
+
+    <com.github.chrisbanes.photoview.PhotoView
+        android:id="@+id/image"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:src="@drawable/all_files"
+        android:layout_margin="@dimen/zero" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 36 - 0
src/main/res/layout/preview_pdf_fragment.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    tools:context=".ui.preview.pdf.PreviewPdfFragment">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/pdf_recycler"
+        android:scrollbars="vertical"
+        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+        android:background="@color/grey_200"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+</FrameLayout>

+ 28 - 0
src/main/res/layout/preview_pdf_page_item.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program. If not, see <https://www.gnu.org/licenses/>.
+  -->
+
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/page"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginVertical="1dp"
+    android:importantForAccessibility="no"
+    android:scaleType="fitCenter" />

+ 8 - 0
src/main/res/values-v27/styles.xml

@@ -40,4 +40,12 @@
         <item name="android:windowLightNavigationBar">false</item>
     </style>
 
+    <style name="Theme.ownCloud.OverlayGrey" parent="Theme.ownCloud.OverlayBase">
+        <item name="android:navigationBarColor">@color/grey_400</item>
+        <item name="android:background">@color/grey_400</item>
+        <item name="android:windowBackground">@color/grey_400</item>
+        <item name="android:colorBackground">@color/grey_400</item>
+        <item name="android:windowLightNavigationBar">false</item>
+    </style>
+
 </resources>

+ 1 - 0
src/main/res/values/colors.xml

@@ -40,6 +40,7 @@
     <color name="standard_grey">#757575</color>
     <color name="actionbar_shadow">#222222</color>
     <color name="grey_200">#EEEEEE</color>
+    <color name="grey_400">#BDBDBD</color>
     <color name="grey_600">#666666</color>
 
     <!-- standard material color definitions -->

+ 1 - 0
src/main/res/values/strings.xml

@@ -1002,4 +1002,5 @@
     <string name="file_list_empty_gallery">Found no images or videos</string>
     <string name="error_creating_file_from_template">Error creating file from template</string>
     <string name="no_send_app">No app available for sending the selected files</string>
+    <string name="pdf_zoom_tip">Tap on a page to zoom in</string>
 </resources>

+ 7 - 0
src/main/res/values/styles.xml

@@ -254,6 +254,13 @@
         <item name="android:navigationBarColor">@color/black</item>
     </style>
 
+    <style name="Theme.ownCloud.OverlayGrey" parent="Theme.ownCloud.OverlayBase">
+        <item name="android:navigationBarColor">@color/grey_400</item>
+        <item name="android:background">@color/grey_400</item>
+        <item name="android:windowBackground">@color/grey_400</item>
+        <item name="android:colorBackground">@color/grey_400</item>
+    </style>
+
     <!-- ACTION BAR STYLES -->
     <style name="Theme.ownCloud.Overlay.ActionBar" parent="@style/Widget.MaterialComponents.Toolbar">
         <item name="android:background">@color/color_transparent</item>