Browse Source

Allow multi-page PDF scans

Signed-off-by: Álvaro Brey <alvaro.brey@nextcloud.com>
Álvaro Brey 2 years ago
parent
commit
41ef0614e0
28 changed files with 1046 additions and 92 deletions
  1. 1 0
      app/build.gradle
  2. 28 0
      app/src/androidTest/java/com/nextcloud/client/documentscan/PDFGeneratorTest.kt
  3. 3 1
      app/src/gplay/java/com/owncloud/android/ui/activity/AppScanActivity.kt
  4. 4 0
      app/src/main/AndroidManifest.xml
  5. 2 1
      app/src/main/java/com/nextcloud/client/di/AppComponent.java
  6. 4 0
      app/src/main/java/com/nextcloud/client/di/ComponentsModule.java
  7. 56 0
      app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt
  8. 6 0
      app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt
  9. 63 0
      app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt
  10. 172 0
      app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt
  11. 153 0
      app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt
  12. 86 0
      app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt
  13. 148 0
      app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt
  14. 42 0
      app/src/main/java/com/nextcloud/client/documentscan/ScanPageContract.kt
  15. 19 1
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt
  16. 2 0
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
  17. 21 0
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
  18. 2 0
      app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
  19. 1 1
      app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java
  20. 44 37
      app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
  21. 39 34
      app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
  22. 1 0
      app/src/main/java/com/owncloud/android/utils/MimeType.java
  23. 23 17
      app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java
  24. 8 0
      app/src/main/res/drawable/ic_save.xml
  25. 55 0
      app/src/main/res/layout/activity_document_scan.xml
  26. 28 0
      app/src/main/res/layout/document_page_item.xml
  27. 30 0
      app/src/main/res/menu/activity_document_scan.xml
  28. 5 0
      app/src/main/res/values/strings.xml

+ 1 - 0
app/build.gradle

@@ -373,6 +373,7 @@ dependencies {
     kapt "androidx.room:room-compiler:$roomVersion"
     androidTestImplementation "androidx.room:room-testing:$roomVersion"
 
+    implementation "io.coil-kt:coil:2.2.2"
 }
 
 configurations.all {

+ 28 - 0
app/src/androidTest/java/com/nextcloud/client/documentscan/PDFGeneratorTest.kt

@@ -0,0 +1,28 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2023 Álvaro Brey
+ *  Copyright (C) 2023 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+internal class PDFGeneratorTest {
+
+    // TODO
+}

+ 3 - 1
app/src/gplay/java/com/owncloud/android/ui/activity/AppScanActivity.kt

@@ -48,7 +48,7 @@ class AppScanActivity : ScanActivity() {
         val intent = Intent()
 
         intent.putExtra(
-            "file",
+            EXTRA_FILE,
             scannerResults.transformedImageFile?.absolutePath ?: scannerResults.croppedImageFile?.absolutePath
         )
 
@@ -64,6 +64,8 @@ class AppScanActivity : ScanActivity() {
         @JvmStatic
         val enabled: Boolean = true
 
+        const val EXTRA_FILE = "file"
+
         @JvmStatic
         fun scanFromCamera(activity: Activity, requestCode: Int) {
             DocumentScanner.init(activity)

+ 4 - 0
app/src/main/AndroidManifest.xml

@@ -491,6 +491,10 @@
             android:name=".ui.preview.PreviewBitmapActivity"
             android:exported="false"
             android:theme="@style/Theme.ownCloud.OverlayGrey" />
+        <activity
+            android:name="com.nextcloud.client.documentscan.DocumentScanActivity"
+            android:exported="false"
+            android:theme="@style/Theme.ownCloud.Toolbar" />
     </application>
 
     <queries>

+ 2 - 1
app/src/main/java/com/nextcloud/client/di/AppComponent.java

@@ -53,7 +53,8 @@ import dagger.android.support.AndroidSupportInjectionModule;
     JobsModule.class,
     IntegrationsModule.class,
     ThemeModule.class,
-    DatabaseModule.class
+    DatabaseModule.class,
+    DispatcherModule.class,
 })
 @Singleton
 public interface AppComponent {

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

@@ -20,6 +20,7 @@
 
 package com.nextcloud.client.di;
 
+import com.nextcloud.client.documentscan.DocumentScanActivity;
 import com.nextcloud.client.etm.EtmActivity;
 import com.nextcloud.client.files.downloader.FileTransferService;
 import com.nextcloud.client.jobs.NotificationWork;
@@ -462,4 +463,7 @@ abstract class ComponentsModule {
 
     @ContributesAndroidInjector
     abstract FileActionsBottomSheet fileActionsBottomSheet();
+
+    @ContributesAndroidInjector
+    abstract DocumentScanActivity documentScanActivity();
 }

+ 56 - 0
app/src/main/java/com/nextcloud/client/di/DispatcherModule.kt

@@ -0,0 +1,56 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.di
+
+import dagger.Module
+import dagger.Provides
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Qualifier
+
+@Retention(AnnotationRetention.BINARY)
+@Qualifier
+annotation class DefaultDispatcher
+
+@Retention(AnnotationRetention.BINARY)
+@Qualifier
+annotation class IoDispatcher
+
+@Retention(AnnotationRetention.BINARY)
+@Qualifier
+annotation class MainDispatcher
+
+@Module
+object DispatcherModule {
+    @DefaultDispatcher
+    @Provides
+    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+    @IoDispatcher
+    @Provides
+    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+    @MainDispatcher
+    @Provides
+    fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
+}

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

@@ -21,6 +21,7 @@ package com.nextcloud.client.di
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
+import com.nextcloud.client.documentscan.DocumentScanViewModel
 import com.nextcloud.client.etm.EtmViewModel
 import com.nextcloud.client.logger.ui.LogsViewModel
 import com.nextcloud.ui.fileactions.FileActionsViewModel
@@ -57,6 +58,11 @@ abstract class ViewModelModule {
     @ViewModelKey(FileActionsViewModel::class)
     abstract fun fileActionsViewModel(vm: FileActionsViewModel): ViewModel
 
+    @Binds
+    @IntoMap
+    @ViewModelKey(DocumentScanViewModel::class)
+    abstract fun documentScanViewModel(vm: DocumentScanViewModel): ViewModel
+
     @Binds
     abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
 }

+ 63 - 0
app/src/main/java/com/nextcloud/client/documentscan/DocumentPageListAdapter.kt

@@ -0,0 +1,63 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import com.owncloud.android.databinding.DocumentPageItemBinding
+
+class DocumentPageListAdapter :
+    ListAdapter<String, DocumentPageListAdapter.DocumentPageViewHolder>(DiffItemCallback()) {
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DocumentPageViewHolder {
+        val inflater = LayoutInflater.from(parent.context)
+        val binding = DocumentPageItemBinding.inflate(inflater, parent, false)
+        return DocumentPageViewHolder(binding)
+    }
+
+    override fun onBindViewHolder(holder: DocumentPageViewHolder, position: Int) {
+        holder.bind(currentList[position])
+    }
+
+    override fun getItemCount(): Int {
+        return currentList.size
+    }
+
+    class DocumentPageViewHolder(val binding: DocumentPageItemBinding) : RecyclerView.ViewHolder(binding.root) {
+        fun bind(imagePath: String) {
+            binding.root.load(imagePath)
+        }
+    }
+
+    private class DiffItemCallback : DiffUtil.ItemCallback<String>() {
+        override fun areItemsTheSame(oldItem: String, newItem: String) =
+            oldItem == newItem
+
+        override fun areContentsTheSame(oldItem: String, newItem: String) =
+            oldItem == newItem
+    }
+}

+ 172 - 0
app/src/main/java/com/nextcloud/client/documentscan/DocumentScanActivity.kt

@@ -0,0 +1,172 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.GridLayoutManager
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.di.ViewModelFactory
+import com.nextcloud.client.logger.Logger
+import com.owncloud.android.R
+import com.owncloud.android.databinding.ActivityDocumentScanBinding
+import com.owncloud.android.ui.activity.ToolbarActivity
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import com.zynksoftware.documentscanner.ui.DocumentScanner
+import javax.inject.Inject
+
+class DocumentScanActivity : ToolbarActivity(), Injectable {
+
+    @Inject
+    lateinit var vmFactory: ViewModelFactory
+
+    @Inject
+    lateinit var logger: Logger
+
+    @Inject
+    lateinit var viewThemeUtils: ViewThemeUtils
+
+    lateinit var binding: ActivityDocumentScanBinding
+
+    lateinit var viewModel: DocumentScanViewModel
+
+    private val scanPage = registerForActivityResult(ScanPageContract()) { result ->
+        viewModel.onScanPageResult(result)
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        val folder = intent.extras?.getString(EXTRA_FOLDER)
+        require(folder != null) { "Folder must be provided for upload" }
+
+        viewModel = ViewModelProvider(this, vmFactory)[DocumentScanViewModel::class.java]
+        viewModel.setUploadFolder(folder)
+
+        setupViews()
+
+        DocumentScanner.init(this) // TODO this should go back to AppScanActivity, it needs the lib!
+
+        observeState()
+    }
+
+    private fun setupViews() {
+        binding = ActivityDocumentScanBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        setupToolbar()
+        supportActionBar?.let {
+            it.setDisplayHomeAsUpEnabled(true)
+            it.setDisplayShowHomeEnabled(true)
+            viewThemeUtils.files.themeActionBar(this, it)
+        }
+
+        viewThemeUtils.material.themeFAB(binding.fab)
+        binding.fab.setOnClickListener {
+            viewModel.onAddPageClicked()
+        }
+
+        binding.pagesRecycler.layoutManager = GridLayoutManager(this, PAGE_COLUMNS)
+
+        setupMenu()
+    }
+
+    private fun setupMenu() {
+        addMenuProvider(
+            object : MenuProvider {
+                override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+                    menuInflater.inflate(R.menu.activity_document_scan, menu)
+                    menu.findItem(R.id.action_save)?.let {
+                        viewThemeUtils.platform.colorToolbarMenuIcon(this@DocumentScanActivity, it)
+                    }
+                }
+
+                override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+                    return when (menuItem.itemId) {
+                        R.id.action_save -> {
+                            viewModel.onClickDone()
+                            true
+                        }
+                        android.R.id.home -> {
+                            onBackPressed()
+                            true
+                        }
+                        else -> false
+                    }
+                }
+            }
+        )
+    }
+
+    private fun observeState() {
+        viewModel.uiState.observe(this, ::handleState)
+    }
+
+    private fun handleState(state: DocumentScanViewModel.UIState) {
+        when (state) {
+            is DocumentScanViewModel.UIState.NormalState -> {
+                val pageList = state.pageList
+                Log.d(
+                    TAG,
+                    "handleState: NormalState with ${pageList.size} pages, isProcessing: ${state.isProcessing}"
+                )
+                updateRecycler(pageList)
+                updateButtonsEnabled(state.isProcessing)
+                if (state.shouldRequestScan) {
+                    startPageScan()
+                }
+            }
+            DocumentScanViewModel.UIState.DoneState -> {
+                finish()
+            }
+        }
+    }
+
+    private fun updateRecycler(pageList: List<String>) {
+        if (binding.pagesRecycler.adapter == null) {
+            binding.pagesRecycler.adapter = DocumentPageListAdapter()
+        }
+        (binding.pagesRecycler.adapter as? DocumentPageListAdapter)?.submitList(pageList)
+    }
+
+    private fun updateButtonsEnabled(processing: Boolean) {
+        binding.fab.isEnabled = !processing
+    }
+
+    private fun startPageScan() {
+        logger.d(TAG, "startPageScan() called")
+        viewModel.onScanRequestHandled()
+        scanPage.launch(Unit)
+    }
+
+    companion object {
+        private const val TAG = "DocumentScanActivity"
+        private const val PAGE_COLUMNS = 2
+        const val EXTRA_FOLDER = "extra_folder"
+    }
+}

+ 153 - 0
app/src/main/java/com/nextcloud/client/documentscan/DocumentScanViewModel.kt

@@ -0,0 +1,153 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.di.IoDispatcher
+import com.nextcloud.client.jobs.BackgroundJobManager
+import com.nextcloud.client.logger.Logger
+import com.owncloud.android.ui.helpers.FileOperationsHelper
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+@Suppress("Detekt.LongParameterList") // satisfied by DI
+class DocumentScanViewModel @Inject constructor(
+    @IoDispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+    app: Application,
+    private val logger: Logger,
+    private val backgroundJobManager: BackgroundJobManager,
+    private val currentAccountProvider: CurrentAccountProvider
+) : AndroidViewModel(app) {
+    init {
+        logger.d(TAG, "DocumentScanViewModel created")
+    }
+
+    sealed interface UIState {
+        data class NormalState(
+            val pageList: List<String> = emptyList(),
+            val isProcessing: Boolean = false,
+            val shouldRequestScan: Boolean = false
+        ) : UIState {
+            val isEmpty: Boolean
+                get() = pageList.isEmpty()
+        }
+
+        object DoneState : UIState
+    }
+
+    private var uploadFolder: String? = null
+    private val initialState = UIState.NormalState(shouldRequestScan = true)
+    private val _uiState = MutableLiveData<UIState>(initialState)
+    val uiState: LiveData<UIState>
+        get() = _uiState
+
+    /**
+     * @param result should be the path to the scanned page on the disk
+     */
+    fun onScanPageResult(result: String?) {
+        logger.d(TAG, "onScanPageResult() called with: result = $result")
+
+        val state = _uiState.value
+        require(state is UIState.NormalState)
+
+        viewModelScope.launch(ioDispatcher) {
+            if (result != null) {
+                _uiState.postValue(state.copy(pageList = state.pageList, isProcessing = true))
+                val newPath = renameCapturedImage(result)
+                val pageList = state.pageList.toMutableList()
+                pageList.add(newPath)
+                _uiState.postValue(UIState.NormalState(pageList, isProcessing = false))
+            } else {
+                // TODO
+            }
+        }
+
+        if (result != null) {
+            val pageList = (uiState.value as UIState.NormalState).pageList.toMutableList()
+            pageList.add(result)
+            _uiState.value = UIState.NormalState(pageList)
+        }
+    }
+
+    // TODO extract to usecase
+    private fun renameCapturedImage(originalPath: String): String {
+        val file = File(originalPath)
+        val renamedFile =
+            File(
+                getApplication<Application>().cacheDir.path +
+                    File.separator + FileOperationsHelper.getCapturedImageName()
+            )
+        file.renameTo(renamedFile)
+        return renamedFile.absolutePath
+    }
+
+    fun onScanRequestHandled() {
+        val state = uiState.value
+        require(state is UIState.NormalState)
+
+        _uiState.postValue(state.copy(shouldRequestScan = false))
+    }
+
+    fun onAddPageClicked() {
+        val state = uiState.value
+        require(state is UIState.NormalState)
+        if (!state.shouldRequestScan) {
+            _uiState.postValue(state.copy(shouldRequestScan = true))
+        }
+    }
+
+    fun onClickDone() {
+        // TODO dialog to choose pictures or PDF
+        val genPath =
+            getApplication<Application>().cacheDir.path + File.separator + FileOperationsHelper.getTimestampedFileName(
+                ".pdf"
+            )
+        val state = _uiState.value
+        if (state is UIState.NormalState && !state.isEmpty && !state.isProcessing) {
+            backgroundJobManager.startPdfGenerateAndUploadWork(
+                currentAccountProvider.user,
+                uploadFolder!!,
+                state.pageList,
+                genPath
+            )
+            // after job is started, finish the application.
+            _uiState.postValue(UIState.DoneState)
+        }
+    }
+
+    fun setUploadFolder(folder: String) {
+        this.uploadFolder = folder
+    }
+
+    companion object {
+        private const val TAG = "DocumentScanViewModel"
+    }
+}

+ 86 - 0
app/src/main/java/com/nextcloud/client/documentscan/GeneratePDFUseCase.kt

@@ -0,0 +1,86 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2023 Álvaro Brey
+ *  Copyright (C) 2023 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.graphics.BitmapFactory
+import android.graphics.pdf.PdfDocument
+import com.nextcloud.client.logger.Logger
+import java.io.FileOutputStream
+import java.io.IOException
+import javax.inject.Inject
+
+/**
+ * This class takes a list of images and generates a PDF file.
+ */
+class GeneratePDFUseCase @Inject constructor(private val logger: Logger) {
+    /**
+     * @param imagePaths list of image paths
+     * @return `true` if the PDF was generated successfully, `false` otherwise
+     */
+    fun execute(imagePaths: List<String>, filePath: String): Boolean {
+        return if (imagePaths.isEmpty() || filePath.isBlank()) {
+            logger.w(TAG, "Invalid parameters: imagePaths: $imagePaths, filePath: $filePath")
+            false
+        } else {
+            val document = PdfDocument()
+            fillDocumentPages(document, imagePaths)
+            writePdfToFile(filePath, document)
+        }
+    }
+
+    /**
+     * @return `true` if the PDF was generated successfully, `false` otherwise
+     */
+    private fun writePdfToFile(
+        filePath: String,
+        document: PdfDocument
+    ): Boolean {
+        return try {
+            val fileOutputStream = FileOutputStream(filePath)
+            document.writeTo(fileOutputStream)
+            fileOutputStream.close()
+            document.close()
+            true
+        } catch (ex: IOException) {
+            logger.e(TAG, "Error generating PDF", ex)
+            false
+        }
+    }
+
+    private fun fillDocumentPages(
+        document: PdfDocument,
+        imagePaths: List<String>
+    ) {
+        imagePaths.forEach { path ->
+            val bitmap = BitmapFactory.decodeFile(path)
+            val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create()
+            val page = document.startPage(pageInfo)
+            page.canvas.drawBitmap(bitmap, 0f, 0f, null)
+            document.finishPage(page)
+        }
+    }
+
+    companion object {
+        private const val TAG = "GeneratePDFUseCase"
+    }
+}

+ 148 - 0
app/src/main/java/com/nextcloud/client/documentscan/GeneratePdfFromImagesWork.kt

@@ -0,0 +1,148 @@
+/*
+ *
+ * Nextcloud Android client application
+ *
+ * @author Tobias Kaminsky
+ * Copyright (C) 2022 Tobias Kaminsky
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.app.NotificationManager
+import android.content.Context
+import android.graphics.BitmapFactory
+import androidx.annotation.StringRes
+import androidx.core.app.NotificationCompat
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.nextcloud.client.account.AnonymousUser
+import com.nextcloud.client.account.User
+import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.logger.Logger
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.services.FileUploader
+import com.owncloud.android.files.services.NameCollisionPolicy
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.ui.notifications.NotificationUtils
+import com.owncloud.android.utils.MimeType
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import java.io.File
+import java.security.SecureRandom
+
+@Suppress("Detekt.LongParameterList") // constructed only from factory method and tests
+class GeneratePdfFromImagesWork(
+    private val appContext: Context,
+    private val generatePdfUseCase: GeneratePDFUseCase,
+    private val viewThemeUtils: ViewThemeUtils,
+    private val notificationManager: NotificationManager,
+    private val userAccountManager: UserAccountManager,
+    private val logger: Logger,
+    params: WorkerParameters
+) : Worker(appContext, params) {
+
+    override fun doWork(): Result {
+        val inputPaths = inputData.getStringArray(INPUT_IMAGE_FILE_PATHS)?.toList()
+        val outputFilePath = inputData.getString(INPUT_OUTPUT_FILE_PATH)
+        val uploadFolder = inputData.getString(INPUT_UPLOAD_FOLDER)
+        val accountName = inputData.getString(INPUT_UPLOAD_ACCOUNT)
+
+        @Suppress("Detekt.ComplexCondition") // not that complex
+        require(!inputPaths.isNullOrEmpty() && outputFilePath != null && uploadFolder != null && accountName != null) {
+            "PDF generation work started with missing parameters:" +
+                " inputPaths: $inputPaths, outputFilePath: $outputFilePath," +
+                " uploadFolder: $uploadFolder, accountName: $accountName"
+        }
+
+        val user = userAccountManager.getUser(accountName)
+        require(user.isPresent && user.get() !is AnonymousUser) { "Invalid or not found user" }
+
+        logger.d(
+            TAG,
+            "PDF generation work started with parameters: inputPaths=$inputPaths," +
+                "outputFilePath=$outputFilePath, uploadFolder=$uploadFolder, accountName=$accountName"
+        )
+
+        val notificationId = showNotification(R.string.document_scan_pdf_generation_in_progress)
+        val result = generatePdfUseCase.execute(inputPaths, outputFilePath)
+        notificationManager.cancel(notificationId)
+        if (result) {
+            uploadFile(user.get(), uploadFolder, outputFilePath)
+            cleanupImages(inputPaths)
+        } else {
+            logger.w(TAG, "PDF generation failed")
+            showNotification(R.string.document_scan_pdf_generation_failed)
+            return Result.failure()
+        }
+
+        logger.d(TAG, "PDF generation work finished")
+        return Result.success()
+    }
+
+    private fun cleanupImages(inputPaths: List<String>) {
+        inputPaths.forEach {
+            val deleted = File(it).delete()
+            logger.d(TAG, "Deleted $it: success = $deleted")
+        }
+    }
+
+    private fun showNotification(@StringRes messageRes: Int): Int {
+        val notificationId = SecureRandom().nextInt()
+        val message = appContext.getString(messageRes)
+
+        val notificationBuilder = NotificationCompat.Builder(
+            appContext,
+            NotificationUtils.NOTIFICATION_CHANNEL_GENERAL
+        )
+            .setSmallIcon(R.drawable.notification_icon)
+            .setLargeIcon(BitmapFactory.decodeResource(appContext.resources, R.drawable.notification_icon))
+            .setContentText(message)
+            .setAutoCancel(true)
+
+        viewThemeUtils.androidx.themeNotificationCompatBuilder(appContext, notificationBuilder)
+
+        notificationManager.notify(notificationId, notificationBuilder.build())
+
+        return notificationId
+    }
+
+    private fun uploadFile(user: User, uploadFolder: String, pdfPath: String) {
+        val uploadPath = uploadFolder + OCFile.PATH_SEPARATOR + File(pdfPath).name
+
+        FileUploader.uploadNewFile(
+            appContext,
+            user,
+            pdfPath,
+            uploadPath,
+            FileUploader.LOCAL_BEHAVIOUR_DELETE, // MIME type will be detected from file name
+            MimeType.PDF,
+            true,
+            UploadFileOperation.CREATED_BY_USER,
+            false,
+            false,
+            NameCollisionPolicy.ASK_USER
+        )
+    }
+
+    companion object {
+        const val INPUT_IMAGE_FILE_PATHS = "input_image_file_paths"
+        const val INPUT_OUTPUT_FILE_PATH = "input_output_file_path"
+        const val INPUT_UPLOAD_FOLDER = "input_upload_folder"
+        const val INPUT_UPLOAD_ACCOUNT = "input_upload_account"
+        private const val TAG = "GeneratePdfFromImagesWo"
+    }
+}

+ 42 - 0
app/src/main/java/com/nextcloud/client/documentscan/ScanPageContract.kt

@@ -0,0 +1,42 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.client.documentscan
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.contract.ActivityResultContract
+import com.owncloud.android.ui.activity.AppScanActivity
+
+class ScanPageContract : ActivityResultContract<Unit, String?>() {
+    override fun createIntent(context: Context, input: Unit): Intent {
+        return Intent(context, AppScanActivity::class.java)
+    }
+
+    override fun parseResult(resultCode: Int, intent: Intent?): String? {
+        if (resultCode != Activity.RESULT_OK) {
+            return null
+        }
+        return intent?.getStringExtra(AppScanActivity.EXTRA_FILE)
+    }
+}

+ 19 - 1
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

@@ -34,6 +34,8 @@ import com.nextcloud.client.account.UserAccountManager
 import com.nextcloud.client.core.Clock
 import com.nextcloud.client.device.DeviceInfo
 import com.nextcloud.client.device.PowerManagementService
+import com.nextcloud.client.documentscan.GeneratePDFUseCase
+import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
 import com.nextcloud.client.integrations.deck.DeckApi
 import com.nextcloud.client.logger.Logger
 import com.nextcloud.client.network.ConnectivityService
@@ -48,6 +50,8 @@ import javax.inject.Provider
 
 /**
  * This factory is responsible for creating all background jobs and for injecting worker dependencies.
+ *
+ * This class is doing too many things and should be split up into smaller factories.
  */
 @Suppress("LongParameterList") // satisfied by DI
 class BackgroundJobFactory @Inject constructor(
@@ -67,7 +71,8 @@ class BackgroundJobFactory @Inject constructor(
     private val eventBus: EventBus,
     private val deckApi: DeckApi,
     private val viewThemeUtils: Provider<ViewThemeUtils>,
-    private val localBroadcastManager: Provider<LocalBroadcastManager>
+    private val localBroadcastManager: Provider<LocalBroadcastManager>,
+    private val generatePdfUseCase: GeneratePDFUseCase
 ) : WorkerFactory() {
 
     @SuppressLint("NewApi")
@@ -99,6 +104,7 @@ class BackgroundJobFactory @Inject constructor(
                 CalendarImportWork::class -> createCalendarImportWork(context, workerParameters)
                 FilesExportWork::class -> createFilesExportWork(context, workerParameters)
                 FilesUploadWorker::class -> createFilesUploadWorker(context, workerParameters)
+                GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
                 else -> null // caller falls back to default factory
             }
         }
@@ -251,4 +257,16 @@ class BackgroundJobFactory @Inject constructor(
             params
         )
     }
+
+    private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork {
+        return GeneratePdfFromImagesWork(
+            appContext = context,
+            generatePdfUseCase = generatePdfUseCase,
+            viewThemeUtils = viewThemeUtils.get(),
+            notificationManager = notificationManager,
+            userAccountManager = accountManager,
+            logger = logger,
+            params = params
+        )
+    }
 }

+ 2 - 0
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt

@@ -141,6 +141,8 @@ interface BackgroundJobManager {
     fun startFilesUploadJob(user: User)
     fun getFileUploads(user: User): LiveData<List<JobInfo>>
 
+    fun startPdfGenerateAndUploadWork(user: User, uploadFolder: String, imagePaths: List<String>, pdfPath: String)
+
     fun scheduleTestJob()
     fun startImmediateTestJob()
     fun cancelTestJob()

+ 21 - 0
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt

@@ -38,6 +38,7 @@ import androidx.work.WorkManager
 import androidx.work.workDataOf
 import com.nextcloud.client.account.User
 import com.nextcloud.client.core.Clock
+import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
 import com.owncloud.android.datamodel.OCFile
 import java.util.Date
 import java.util.UUID
@@ -80,6 +81,7 @@ internal class BackgroundJobManagerImpl(
         const val JOB_NOTIFICATION = "notification"
         const val JOB_ACCOUNT_REMOVAL = "account_removal"
         const val JOB_FILES_UPLOAD = "files_upload"
+        const val JOB_PDF_GENERATION = "pdf_generation"
         const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
         const val JOB_IMMEDIATE_FILES_EXPORT = "immediate_files_export"
 
@@ -103,6 +105,7 @@ internal class BackgroundJobManagerImpl(
                 "$TAG_PREFIX_NAME:$name ${user.accountName}"
             }
         }
+
         fun formatUserTag(user: User): String = "$TAG_PREFIX_USER:${user.accountName}"
         fun formatTimeTag(startTimestamp: Long): String = "$TAG_PREFIX_START_TIMESTAMP:$startTimestamp"
 
@@ -465,6 +468,24 @@ internal class BackgroundJobManagerImpl(
         }
     }
 
+    override fun startPdfGenerateAndUploadWork(
+        user: User,
+        uploadFolder: String,
+        imagePaths: List<String>,
+        pdfPath: String
+    ) {
+        val data = workDataOf(
+            GeneratePdfFromImagesWork.INPUT_IMAGE_FILE_PATHS to imagePaths.toTypedArray(),
+            GeneratePdfFromImagesWork.INPUT_OUTPUT_FILE_PATH to pdfPath,
+            GeneratePdfFromImagesWork.INPUT_UPLOAD_ACCOUNT to user.accountName,
+            GeneratePdfFromImagesWork.INPUT_UPLOAD_FOLDER to uploadFolder
+        )
+        val request = oneTimeRequestBuilder(GeneratePdfFromImagesWork::class, JOB_PDF_GENERATION)
+            .setInputData(data)
+            .build()
+        workManager.enqueue(request)
+    }
+
     override fun scheduleTestJob() {
         val request = periodicRequestBuilder(TestJob::class, JOB_TEST)
             .setInitialDelay(DEFAULT_IMMEDIATE_JOB_DELAY_SEC, TimeUnit.SECONDS)

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

@@ -856,6 +856,8 @@ public class FileDisplayActivity extends FileActivity
             }, new String[]{FileOperationsHelper.createImageFile(getActivity()).getAbsolutePath()}).execute();
         } else if (requestCode == REQUEST_CODE__UPLOAD_SCAN_DOC_FROM_CAMERA &&
             (resultCode == RESULT_OK || resultCode == UploadFilesActivity.RESULT_OK_AND_DELETE)) {
+            // TODO replace with upload PDF from DocumentScanActivity
+
             Uri fileUri = Uri.parse(data.getStringExtra("file"));
 
             new CheckAvailableSpaceTask(new CheckAvailableSpaceTask.CheckAvailableSpaceListener() {

+ 1 - 1
app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java

@@ -280,7 +280,7 @@ public class SettingsActivity extends PreferenceActivity
                         String mimeType = MimeTypeUtil.getBestMimeTypeByFilename(privacyUrl.getLastPathSegment());
 
                         Intent intent;
-                        if ("application/pdf".equals(mimeType)) {
+                        if (MimeTypeUtil.isPDF(mimeType)) {
                             intent = new Intent(Intent.ACTION_VIEW, privacyUrl);
                             DisplayUtils.startIntentIfAppAvailable(intent, this, R.string.no_pdf_app_available);
                         } else {

+ 44 - 37
app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

@@ -32,6 +32,7 @@ import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.text.TextUtils;
+import android.util.Log;
 import android.view.ActionMode;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -51,6 +52,7 @@ import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.device.DeviceInfo;
 import com.nextcloud.client.di.Injectable;
+import com.nextcloud.client.documentscan.DocumentScanActivity;
 import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.client.preferences.AppPreferences;
@@ -153,14 +155,14 @@ import static com.owncloud.android.utils.DisplayUtils.openSortingOrderDialogFrag
  * TODO refactor to get rid of direct dependency on FileDisplayActivity
  */
 public class OCFileListFragment extends ExtendedListFragment implements
-        OCFileListFragmentInterface,
-        OCFileListBottomSheetActions,
-        Injectable {
+    OCFileListFragmentInterface,
+    OCFileListBottomSheetActions,
+    Injectable {
 
     protected static final String TAG = OCFileListFragment.class.getSimpleName();
 
     private static final String MY_PACKAGE = OCFileListFragment.class.getPackage() != null ?
-            OCFileListFragment.class.getPackage().getName() : "com.owncloud.android.ui.fragment";
+        OCFileListFragment.class.getPackage().getName() : "com.owncloud.android.ui.fragment";
 
     public final static String ARG_ONLY_FOLDERS_CLICKABLE = MY_PACKAGE + ".ONLY_FOLDERS_CLICKABLE";
     public final static String ARG_FILE_SELECTABLE = MY_PACKAGE + ".FILE_SELECTABLE";
@@ -172,7 +174,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
     public static final String DOWNLOAD_BEHAVIOUR = "DOWNLOAD_BEHAVIOUR";
     public static final String DOWNLOAD_SEND = "DOWNLOAD_SEND";
-    
+
 
     public static final String FOLDER_LAYOUT_LIST = "LIST";
     public static final String FOLDER_LAYOUT_GRID = "GRID";
@@ -282,7 +284,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
         } catch (ClassCastException e) {
             throw new IllegalArgumentException(context.toString() + " must implement " +
-                    FileFragment.ContainerActivity.class.getSimpleName(), e);
+                                                   FileFragment.ContainerActivity.class.getSimpleName(), e);
         }
         try {
             setOnRefreshListener((OnEnforceableRefreshListener) context);
@@ -302,8 +304,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
         View v = super.onCreateView(inflater, container, savedInstanceState);
 
         if (savedInstanceState != null
-                && savedInstanceState.getParcelable(KEY_CURRENT_SEARCH_TYPE) != null &&
-                savedInstanceState.getParcelable(OCFileListFragment.SEARCH_EVENT) != null) {
+            && savedInstanceState.getParcelable(KEY_CURRENT_SEARCH_TYPE) != null &&
+            savedInstanceState.getParcelable(OCFileListFragment.SEARCH_EVENT) != null) {
             searchFragment = true;
             currentSearchType = savedInstanceState.getParcelable(KEY_CURRENT_SEARCH_TYPE);
             searchEvent = savedInstanceState.getParcelable(OCFileListFragment.SEARCH_EVENT);
@@ -485,7 +487,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     @Override
     public void createFolder() {
         CreateFolderDialogFragment.newInstance(mFile)
-                .show(getActivity().getSupportFragmentManager(), DIALOG_CREATE_FOLDER);
+            .show(getActivity().getSupportFragmentManager(), DIALOG_CREATE_FOLDER);
     }
 
     @Override
@@ -495,9 +497,9 @@ public class OCFileListFragment extends ExtendedListFragment implements
         action.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
 
         getActivity().startActivityForResult(
-                Intent.createChooser(action, getString(R.string.upload_chooser_title)),
-                FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS
-        );
+            Intent.createChooser(action, getString(R.string.upload_chooser_title)),
+            FileDisplayActivity.REQUEST_CODE__SELECT_CONTENT_FROM_APPS
+                                            );
     }
 
     @Override
@@ -516,12 +518,17 @@ public class OCFileListFragment extends ExtendedListFragment implements
     public void scanDocUpload() {
         FileDisplayActivity fileDisplayActivity = (FileDisplayActivity) getActivity();
 
-        if (fileDisplayActivity != null) {
-            AppScanActivity
-                .scanFromCamera(fileDisplayActivity, FileDisplayActivity.REQUEST_CODE__UPLOAD_SCAN_DOC_FROM_CAMERA);
+        final OCFile currentFile = getCurrentFile();
+        if (fileDisplayActivity != null && currentFile != null && currentFile.isFolder()) {
+
+            Intent intent = new Intent(requireContext(), DocumentScanActivity.class);
+            intent.putExtra(DocumentScanActivity.EXTRA_FOLDER, currentFile.getRemotePath());
+            startActivity(intent);
         } else {
+            Log.w(TAG, "scanDocUpload: Failed to start doc scanning, fileDisplayActivity=" + fileDisplayActivity +
+                ", currentFile=" + currentFile);
             Toast.makeText(getContext(),
-                           getString(R.string.error_starting_direct_camera_upload),
+                           getString(R.string.error_starting_doc_scan),
                            Toast.LENGTH_SHORT)
                 .show();
         }
@@ -533,7 +540,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             getActivity(),
             ((FileActivity) getActivity()).getUser().orElseThrow(RuntimeException::new),
             FileDisplayActivity.REQUEST_CODE__SELECT_FILES_FROM_FILE_SYSTEM,
-            getCurrentFile().isEncrypted()
+                                                        getCurrentFile().isEncrypted()
                                                         );
     }
 
@@ -605,7 +612,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     public void newSpreadsheet() {
         ChooseRichDocumentsTemplateDialogFragment.newInstance(mFile,
                                                               ChooseRichDocumentsTemplateDialogFragment.Type.SPREADSHEET)
-                .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
+            .show(requireActivity().getSupportFragmentManager(), DIALOG_CREATE_DOCUMENT);
     }
 
     @Override
@@ -633,8 +640,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
      * <p>
      * Manages input from the user when one or more files or folders are selected in the list.
      * <p>
-     * Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened
-     * and closed.
+     * Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened and closed.
      */
     private class MultiChoiceModeListener implements AbsListView.MultiChoiceModeListener, DrawerLayout.DrawerListener {
 
@@ -661,8 +667,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
 
         /**
-         * When the navigation drawer is closed, action mode is recovered in the same state as was
-         * when the drawer was (started to be) opened.
+         * When the navigation drawer is closed, action mode is recovered in the same state as was when the drawer was
+         * (started to be) opened.
          *
          * @param drawerView Navigation drawer just closed.
          */
@@ -681,8 +687,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
 
         /**
-         * If the action mode is active when the navigation drawer starts to move, the action
-         * mode is closed and the selection stored to be recovered when the drawer is closed.
+         * If the action mode is active when the navigation drawer starts to move, the action mode is closed and the
+         * selection stored to be recovered when the drawer is closed.
          *
          * @param newState One of STATE_IDLE, STATE_DRAGGING or STATE_SETTLING.
          */
@@ -690,7 +696,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
         public void onDrawerStateChanged(int newState) {
             if (DrawerLayout.STATE_DRAGGING == newState && mActiveActionMode != null) {
                 mSelectionWhenActionModeClosedByDrawer.addAll(((OCFileListAdapter) getRecyclerView().getAdapter())
-                        .getCheckedItems());
+                                                                  .getCheckedItems());
                 mActiveActionMode.finish();
                 mActionModeClosedByDrawer = true;
             }
@@ -790,7 +796,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
         public void loadStateFrom(Bundle savedInstanceState) {
             mActionModeClosedByDrawer = savedInstanceState.getBoolean(KEY_ACTION_MODE_CLOSED_BY_DRAWER,
-                    mActionModeClosedByDrawer);
+                                                                      mActionModeClosedByDrawer);
         }
     }
 
@@ -827,7 +833,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             mOriginalMenuItems.add(menu.findItem(R.id.action_search));
         }
 
-        if(menuItemAddRemoveValue == MenuItemAddRemove.REMOVE_GRID_AND_SORT){
+        if (menuItemAddRemoveValue == MenuItemAddRemove.REMOVE_GRID_AND_SORT) {
             menu.removeItem(R.id.action_search);
         }
 
@@ -876,7 +882,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             if (mFile.getParentId() != FileDataStorageManager.ROOT_PARENT_ID) {
                 parentPath = new File(mFile.getRemotePath()).getParent();
                 parentPath = parentPath.endsWith(OCFile.PATH_SEPARATOR) ? parentPath :
-                        parentPath + OCFile.PATH_SEPARATOR;
+                    parentPath + OCFile.PATH_SEPARATOR;
                 parentDir = storageManager.getFileByPath(parentPath);
                 moveCount++;
             } else {
@@ -885,7 +891,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             while (parentDir == null) {
                 parentPath = new File(parentPath).getParent();
                 parentPath = parentPath.endsWith(OCFile.PATH_SEPARATOR) ? parentPath :
-                        parentPath + OCFile.PATH_SEPARATOR;
+                    parentPath + OCFile.PATH_SEPARATOR;
                 parentDir = storageManager.getFileByPath(parentPath);
                 moveCount++;
             }   // exit is granted because storageManager.getFileByPath("/") never returns null
@@ -905,6 +911,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
     /**
      * Will toggle a file selection status from the action mode
+     *
      * @param file The concerned OCFile by the selection/deselection
      */
     private void toggleItemToCheckedList(OCFile file) {
@@ -918,6 +925,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
     /**
      * Will update (invalidate) the action mode adapter/mode to refresh an item selection change
+     *
      * @param file The concerned OCFile to refresh in adapter
      */
     private void updateActionModeFile(OCFile file) {
@@ -1276,9 +1284,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
     }
 
     /**
-     * Lists the given directory on the view. When the input parameter is null,
-     * it will either refresh the last known directory. list the root
-     * if there never was a directory.
+     * Lists the given directory on the view. When the input parameter is null, it will either refresh the last known
+     * directory. list the root if there never was a directory.
      *
      * @param directory File to be listed
      */
@@ -1400,8 +1407,8 @@ public class OCFileListFragment extends ExtendedListFragment implements
     }
 
     /**
-     * Determines if user set folder to grid or list view. If folder is not set itself,
-     * it finds a parent that is set (at least root is set).
+     * Determines if user set folder to grid or list view. If folder is not set itself, it finds a parent that is set
+     * (at least root is set).
      *
      * @param folder Folder to check or null for root folder
      * @return 'true' is folder should be shown in grid mode, 'false' if list mode is preferred.
@@ -1437,7 +1444,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
 
         if (getRecyclerView().getLayoutManager() != null) {
             position = ((LinearLayoutManager) getRecyclerView().getLayoutManager())
-                    .findFirstCompletelyVisibleItemPosition();
+                .findFirstCompletelyVisibleItemPosition();
         }
 
         RecyclerView.LayoutManager layoutManager;
@@ -1947,7 +1954,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
     }
 
-    public boolean isEmpty(){
-        return  mAdapter == null || mAdapter.isEmpty();
+    public boolean isEmpty() {
+        return mAdapter == null || mAdapter.isEmpty();
     }
 }

+ 39 - 34
app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java

@@ -253,7 +253,7 @@ public class FileOperationsHelper {
 
     private void syncFile(OCFile file, User user, FileDataStorageManager storageManager) {
         fileActivity.runOnUiThread(() -> fileActivity.showLoadingDialog(fileActivity.getResources()
-                .getString(R.string.sync_in_progress)));
+                                                                            .getString(R.string.sync_in_progress)));
 
         SynchronizeFileOperation sfo = new SynchronizeFileOperation(file,
                                                                     null,
@@ -363,7 +363,7 @@ public class FileOperationsHelper {
                                 }
 
                                 openFileWithIntent.setFlags(openFileWithIntent.getFlags() |
-                                        Intent.FLAG_ACTIVITY_NEW_TASK);
+                                                                Intent.FLAG_ACTIVITY_NEW_TASK);
                                 fileActivity.startActivity(openFileWithIntent);
                             } catch (ActivityNotFoundException exception) {
                                 DisplayUtils.showSnackMessage(fileActivity, R.string.file_list_no_app_for_file_type);
@@ -410,7 +410,7 @@ public class FileOperationsHelper {
     private Intent createOpenFileIntent(OCFile file) {
         String storagePath = file.getStoragePath();
         Uri fileUri = getFileUri(file, MainApp.getAppContext().getResources().getStringArray(R.array
-                .ms_office_extensions));
+                                                                                                 .ms_office_extensions));
         Intent openFileWithIntent = null;
         int lastIndexOfDot = storagePath.lastIndexOf('.');
         if (lastIndexOfDot >= 0) {
@@ -419,9 +419,9 @@ public class FileOperationsHelper {
             if (guessedMimeType != null) {
                 openFileWithIntent = new Intent(Intent.ACTION_VIEW);
                 openFileWithIntent.setDataAndType(
-                        fileUri,
-                        guessedMimeType
-                );
+                    fileUri,
+                    guessedMimeType
+                                                 );
             }
         }
 
@@ -432,9 +432,9 @@ public class FileOperationsHelper {
         if (openFileWithIntent == null) {
             openFileWithIntent = new Intent(Intent.ACTION_VIEW);
             openFileWithIntent.setDataAndType(
-                    fileUri,
-                    file.getMimeType()
-            );
+                fileUri,
+                file.getMimeType()
+                                             );
         }
 
         openFileWithIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
@@ -443,9 +443,9 @@ public class FileOperationsHelper {
 
     private Uri getFileUri(OCFile file, String... officeExtensions) {
         if (file.getFileName().contains(".") &&
-                Arrays.asList(officeExtensions).contains(file.getFileName().substring(file.getFileName().
-                        lastIndexOf(".") + 1)) &&
-                !file.getStoragePath().startsWith(MainApp.getAppContext().getFilesDir().getAbsolutePath())) {
+            Arrays.asList(officeExtensions).contains(file.getFileName().substring(file.getFileName().
+                                                                                      lastIndexOf(".") + 1)) &&
+            !file.getStoragePath().startsWith(MainApp.getAppContext().getFilesDir().getAbsolutePath())) {
             return file.getLegacyExposedFileUri();
         } else {
             return file.getExposedFileUri(fileActivity);
@@ -528,7 +528,7 @@ public class FileOperationsHelper {
         if (file != null) {
             // TODO check capability?
             fileActivity.showLoadingDialog(fileActivity.getApplicationContext().
-                    getString(R.string.wait_a_moment));
+                                               getString(R.string.wait_a_moment));
 
             Intent service = new Intent(fileActivity, OperationsService.class);
             service.setAction(OperationsService.ACTION_CREATE_SHARE_WITH_SHAREE);
@@ -566,7 +566,7 @@ public class FileOperationsHelper {
         if (file != null) {
             // TODO check capability?
             fileActivity.showLoadingDialog(fileActivity.getApplicationContext().
-                getString(R.string.wait_a_moment));
+                                               getString(R.string.wait_a_moment));
 
             Intent service = new Intent(fileActivity, OperationsService.class);
             service.setAction(OperationsService.ACTION_CREATE_SHARE_WITH_SHAREE);
@@ -596,7 +596,7 @@ public class FileOperationsHelper {
     public void restoreFileVersion(FileVersion fileVersion) {
         if (fileVersion != null) {
             fileActivity.showLoadingDialog(fileActivity.getApplicationContext().
-                    getString(R.string.wait_a_moment));
+                                               getString(R.string.wait_a_moment));
 
             Intent service = new Intent(fileActivity, OperationsService.class);
             service.setAction(OperationsService.ACTION_RESTORE_VERSION);
@@ -661,12 +661,10 @@ public class FileOperationsHelper {
     }
 
     /**
-     * Updates a share on a file to set its password.
-     * Starts a request to do it in {@link OperationsService}
+     * Updates a share on a file to set its password. Starts a request to do it in {@link OperationsService}
      *
      * @param share    File which share will be protected with a password.
-     * @param password Password to set for the public link; null or empty string to clear
-     *                 the current password
+     * @param password Password to set for the public link; null or empty string to clear the current password
      */
     public void setPasswordToShare(OCShare share, String password) {
         Intent updateShareIntent = new Intent(fileActivity, OperationsService.class);
@@ -683,12 +681,12 @@ public class FileOperationsHelper {
 
 
     /**
-     * Updates a public share on a file to set its expiration date.
-     * Starts a request to do it in {@link OperationsService}
+     * Updates a public share on a file to set its expiration date. Starts a request to do it in
+     * {@link OperationsService}
      *
      * @param share                  {@link OCShare} instance which permissions will be updated.
-     * @param expirationTimeInMillis Expiration date to set. A negative value clears the current expiration
-     *                               date, leaving the link unrestricted. Zero makes no change.
+     * @param expirationTimeInMillis Expiration date to set. A negative value clears the current expiration date,
+     *                               leaving the link unrestricted. Zero makes no change.
      */
     public void setExpirationDateToShare(OCShare share, long expirationTimeInMillis) {
         Intent updateShareIntent = new Intent(fileActivity, OperationsService.class);
@@ -701,8 +699,7 @@ public class FileOperationsHelper {
     }
 
     /**
-     * Updates a share on a file to set its access permissions.
-     * Starts a request to do it in {@link OperationsService}
+     * Updates a share on a file to set its access permissions. Starts a request to do it in {@link OperationsService}
      *
      * @param share       {@link OCShare} instance which permissions will be updated.
      * @param permissions New permissions to set. A value <= 0 makes no update.
@@ -717,8 +714,8 @@ public class FileOperationsHelper {
     }
 
     /**
-     * Updates a public share on a folder to set its editing permission. Starts a request to do it in {@link
-     * OperationsService}
+     * Updates a public share on a folder to set its editing permission. Starts a request to do it in
+     * {@link OperationsService}
      *
      * @param share            {@link OCShare} instance which permissions will be updated.
      * @param uploadPermission New state of the permission for editing the folder shared via link.
@@ -819,7 +816,7 @@ public class FileOperationsHelper {
             sendIntent.setType(file.getMimeType());
             sendIntent.setComponent(new ComponentName(packageName, activityName));
             sendIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse("content://" +
-                    context.getResources().getString(R.string.image_cache_provider_authority) +
+                                                                   context.getResources().getString(R.string.image_cache_provider_authority) +
                                                                    file.getRemotePath()));
             sendIntent.putExtra(Intent.ACTION_SEND, true);      // Send Action
             sendIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -844,13 +841,13 @@ public class FileOperationsHelper {
                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                         intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                         uri = FileProvider.getUriForFile(context,
-                                context.getResources().getString(R.string.file_provider_authority), externalFile);
+                                                         context.getResources().getString(R.string.file_provider_authority), externalFile);
                     } else {
                         uri = Uri.fromFile(externalFile);
                     }
                 } else {
                     uri = Uri.parse(UriUtils.URI_CONTENT_SCHEME +
-                            context.getResources().getString(R.string.image_cache_provider_authority) +
+                                        context.getResources().getString(R.string.image_cache_provider_authority) +
                                         file.getRemotePath());
                 }
 
@@ -945,8 +942,8 @@ public class FileOperationsHelper {
      * Start operations to delete one or several files
      *
      * @param files         Files to delete
-     * @param onlyLocalCopy When 'true' only local copy of the files is removed; otherwise files are also deleted
-     *                      in the server.
+     * @param onlyLocalCopy When 'true' only local copy of the files is removed; otherwise files are also deleted in the
+     *                      server.
      * @param inBackground  When 'true', do not show any loading dialog
      */
     public void removeFiles(Collection<OCFile> files, boolean onlyLocalCopy, boolean inBackground) {
@@ -987,7 +984,7 @@ public class FileOperationsHelper {
         User currentUser = fileActivity.getUser().orElseThrow(IllegalStateException::new);
         if (file.isFolder()) {
             OperationsService.OperationsServiceBinder opsBinder =
-                    fileActivity.getOperationsServiceBinder();
+                fileActivity.getOperationsServiceBinder();
             if (opsBinder != null) {
                 opsBinder.cancel(currentUser.toPlatformAccount(), file);
             }
@@ -1103,7 +1100,15 @@ public class FileOperationsHelper {
     }
 
     public static String getCapturedImageName() {
-        return new SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(new Date()) + ".jpg";
+        return getTimestampedFileName(".jpg");
+    }
+
+    /**
+     * @param extension a file extension, including the dot
+     * @return a filename with the given extension, based on the current date and time.
+     */
+    public static String getTimestampedFileName(final String extension) {
+        return new SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(new Date()) + extension;
     }
 
     /**

+ 1 - 0
app/src/main/java/com/owncloud/android/utils/MimeType.java

@@ -27,6 +27,7 @@ public final class MimeType {
     public static final String TIFF = "image/tiff";
     public static final String TEXT_PLAIN = "text/plain";
     public static final String FILE = "application/octet-stream";
+    public static final String PDF = "application/pdf";
 
     private MimeType() {
         // No instance

+ 23 - 17
app/src/main/java/com/owncloud/android/utils/MimeTypeUtil.java

@@ -43,9 +43,9 @@ import androidx.core.content.ContextCompat;
 /**
  * <p>Helper class for detecting the right icon for a file or folder,
  * based on its mime type and file extension.</p>
- *
- * This class maintains all the necessary mappings fot these detections.<br/>
- * In order to add further mappings, there are up to three look up maps that need further values:
+ * <p>
+ * This class maintains all the necessary mappings fot these detections.<br/> In order to add further mappings, there
+ * are up to three look up maps that need further values:
  * <ol>
  *     <li>
  *         {@link MimeTypeUtil#FILE_EXTENSION_TO_MIMETYPE_MAPPING}<br/>
@@ -64,11 +64,17 @@ import androidx.core.content.ContextCompat;
  */
 @SuppressWarnings("PMD.AvoidDuplicateLiterals")
 public final class MimeTypeUtil {
-    /** Mapping: icon for mime type */
+    /**
+     * Mapping: icon for mime type
+     */
     private static final Map<String, Integer> MIMETYPE_TO_ICON_MAPPING = new HashMap<>();
-    /** Mapping: icon for main mime type (first part of a mime type declaration). */
+    /**
+     * Mapping: icon for main mime type (first part of a mime type declaration).
+     */
     private static final Map<String, Integer> MAIN_MIMETYPE_TO_ICON_MAPPING = new HashMap<>();
-    /** Mapping: mime type for file extension. */
+    /**
+     * Mapping: mime type for file extension.
+     */
     private static final Map<String, List<String>> FILE_EXTENSION_TO_MIMETYPE_MAPPING = new HashMap<>();
     public static final String MIMETYPE_TEXT_MARKDOWN = "text/markdown";
 
@@ -129,8 +135,8 @@ public final class MimeTypeUtil {
      * Returns the resource identifier of an image to use as icon associated to a type of folder.
      *
      * @param isSharedViaUsers flag if the folder is shared via the users system
-     * @param isSharedViaLink flag if the folder is publicly shared via link
-     * @param isEncrypted flag if the folder is encrypted
+     * @param isSharedViaLink  flag if the folder is publicly shared via link
+     * @param isEncrypted      flag if the folder is encrypted
      * @return Identifier of an image resource.
      */
     public static Drawable getFolderTypeIcon(boolean isSharedViaUsers,
@@ -173,10 +179,10 @@ public final class MimeTypeUtil {
     }
 
     /**
-     * Returns a single MIME type of all the possible, by inspection of the file extension, and taking
-     * into account the MIME types known by ownCloud first.
+     * Returns a single MIME type of all the possible, by inspection of the file extension, and taking into account the
+     * MIME types known by ownCloud first.
      *
-     * @param filename      Name of file
+     * @param filename Name of file
      * @return A single MIME type, "application/octet-stream" for unknown file extensions.
      */
     public static String getBestMimeTypeByFilename(String filename) {
@@ -211,7 +217,7 @@ public final class MimeTypeUtil {
      */
     public static boolean isImage(String mimeType) {
         return mimeType != null && mimeType.toLowerCase(Locale.ROOT).startsWith("image/") &&
-                !mimeType.toLowerCase(Locale.ROOT).contains("djvu");
+            !mimeType.toLowerCase(Locale.ROOT).contains("djvu");
     }
 
     /**
@@ -320,11 +326,11 @@ public final class MimeTypeUtil {
         return MimeType.DIRECTORY.equalsIgnoreCase(mimeType);
     }
 
-    public static boolean isPDF(String mimeType){
-        return "application/pdf".equalsIgnoreCase(mimeType);
+    public static boolean isPDF(String mimeType) {
+        return MimeType.PDF.equalsIgnoreCase(mimeType);
     }
 
-    public static boolean isPDF(OCFile file){
+    public static boolean isPDF(OCFile file) {
         return isPDF(file.getMimeType()) || isPDF(getMimeTypeFromPath(file.getRemotePath()));
     }
 
@@ -457,7 +463,7 @@ public final class MimeTypeUtil {
         MIMETYPE_TO_ICON_MAPPING.put("application/msword", R.drawable.file_doc);
         MIMETYPE_TO_ICON_MAPPING.put("application/octet-stream", R.drawable.file);
         MIMETYPE_TO_ICON_MAPPING.put("application/postscript", R.drawable.file_image);
-        MIMETYPE_TO_ICON_MAPPING.put("application/pdf", R.drawable.file_pdf);
+        MIMETYPE_TO_ICON_MAPPING.put(MimeType.PDF, R.drawable.file_pdf);
         MIMETYPE_TO_ICON_MAPPING.put("application/rss+xml", R.drawable.file_code);
         MIMETYPE_TO_ICON_MAPPING.put("application/rtf", R.drawable.file);
         MIMETYPE_TO_ICON_MAPPING.put("application/vnd.android.package-archive", R.drawable.file_zip);
@@ -690,7 +696,7 @@ public final class MimeTypeUtil {
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("orf", Collections.singletonList("image/x-dcraw"));
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("otf", Collections.singletonList("application/font-sfnt"));
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("pages", Collections.singletonList("application/x-iwork-pages-sffpages"));
-        FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("pdf", Collections.singletonList("application/pdf"));
+        FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("pdf", Collections.singletonList(MimeType.PDF));
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("pfb", Collections.singletonList("application/x-font"));
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("pef", Collections.singletonList("image/x-dcraw"));
         FILE_EXTENSION_TO_MIMETYPE_MAPPING.put("php", Collections.singletonList("application/x-php"));

+ 8 - 0
app/src/main/res/drawable/ic_save.xml

@@ -0,0 +1,8 @@
+<!-- drawable/content_save.xml -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:height="24dp"
+    android:width="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path android:fillColor="#000" android:pathData="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
+</vector>

+ 55 - 0
app/src/main/res/layout/activity_document_scan.xml

@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~  @author Álvaro Brey
+  ~  Copyright (C) 2022 Álvaro Brey
+  ~  Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software; you can redistribute it and/or
+  ~ modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  ~ License as published by the Free Software Foundation; either
+  ~ version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public
+  ~ License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.nextcloud.client.documentscan.DocumentScanActivity">
+
+    <include
+        android:id="@+id/toolbar_standard_include"
+        layout="@layout/toolbar_standard" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/pages_recycler"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/toolbar_standard_include" />
+
+
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="@dimen/standard_margin"
+        android:layout_marginBottom="@dimen/standard_margin"
+        android:contentDescription="@string/scan_page"
+        app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:srcCompat="@drawable/ic_plus" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 28 - 0
app/src/main/res/layout/document_page_item.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~  @author Álvaro Brey
+  ~  Copyright (C) 2022 Álvaro Brey
+  ~  Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software; you can redistribute it and/or
+  ~ modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  ~ License as published by the Free Software Foundation; either
+  ~ version 3 of the License, or 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 AFFERO GENERAL PUBLIC LICENSE for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public
+  ~ License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+
+<androidx.appcompat.widget.AppCompatImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="@dimen/standard_padding"
+    tools:src="@drawable/iconclose" />

+ 30 - 0
app/src/main/res/menu/activity_document_scan.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Nextcloud Android client application
+
+ @author  Infomaniak Network SA (Kilian P.)
+ Copyright (C) 2020 Infomaniak
+ Copyright (C) 2020 Nextcloud GmbH
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_save"
+        android:icon="@drawable/ic_save"
+        android:title="@string/done"
+        app:showAsAction="ifRoom" />
+
+</menu>

+ 5 - 0
app/src/main/res/values/strings.xml

@@ -1066,4 +1066,9 @@
     <string name="setup_e2e">During setup of end-to-end encryption, you will receive a random 12 word mnemonic, which you will need to open your files on other devices. This will only be stored on this device, and can be shown again in this screen. Please note it down in a secure place!</string>
     <string name="error_showing_encryption_dialog">Error showing setup encryption dialog!</string>
     <string name="prefs_keys_exist">Add end-to-end encryption to this client</string>
+    <string name="scan_page">Scan page</string>
+    <string name="done">Done</string>
+    <string name="document_scan_pdf_generation_in_progress">Generating PDF...</string>
+    <string name="error_starting_doc_scan">Error starting document scan</string>
+    <string name="document_scan_pdf_generation_failed">PDF generation failed</string>
 </resources>