Browse Source

File export aka "download"

Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
tobiasKaminsky 3 years ago
parent
commit
1d69dcfcaa
20 changed files with 718 additions and 162 deletions
  1. 4 7
      app/src/androidTest/java/com/owncloud/android/UploadIT.java
  2. 78 0
      app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt
  3. 37 63
      app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt
  4. 0 42
      app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt
  5. 3 0
      app/src/main/AndroidManifest.xml
  6. 14 0
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt
  7. 3 0
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
  8. 18 0
      app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
  9. 203 0
      app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt
  10. 9 0
      app/src/main/java/com/owncloud/android/files/FileMenuFilter.java
  11. 11 3
      app/src/main/java/com/owncloud/android/files/services/FileDownloader.java
  12. 49 18
      app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java
  13. 28 0
      app/src/main/java/com/owncloud/android/operations/DownloadType.kt
  14. 0 1
      app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java
  15. 22 27
      app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
  16. 1 1
      app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
  17. 181 0
      app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt
  18. 40 0
      app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
  19. 6 0
      app/src/main/res/menu/item_file.xml
  20. 11 0
      app/src/main/res/values/strings.xml

+ 4 - 7
app/src/androidTest/java/com/owncloud/android/UploadIT.java

@@ -426,13 +426,6 @@ public class UploadIT extends AbstractOnServerIT {
         String remotePath = "/testFile.txt";
         OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name);
 
-        long creationTimestamp = Files.readAttributes(file.toPath(), BasicFileAttributes.class)
-            .creationTime()
-            .to(TimeUnit.SECONDS);
-
-        // wait a bit to simulate a later upload, so we can verify if creation date is set correct
-        shortSleep();
-
         assertTrue(
             new UploadFileOperation(
                 uploadsStorageManager,
@@ -453,6 +446,10 @@ public class UploadIT extends AbstractOnServerIT {
                 .isSuccess()
                   );
 
+        long creationTimestamp = Files.readAttributes(file.toPath(), BasicFileAttributes.class)
+            .creationTime()
+            .to(TimeUnit.SECONDS);
+
         long uploadTimestamp = System.currentTimeMillis() / 1000;
 
         // RefreshFolderOperation

+ 78 - 0
app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt

@@ -0,0 +1,78 @@
+/*
+ *
+ * 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.owncloud.android.utils
+
+import com.owncloud.android.AbstractIT
+import com.owncloud.android.datamodel.OCFile
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class FileExportUtilsIT : AbstractIT() {
+    @Test
+    fun exportFile() {
+        val file = createFile("export.txt", 10)
+
+        val sut = FileExportUtils()
+
+        val expectedFile = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+            File("/sdcard/Downloads/export.txt")
+        } else {
+            File("/storage/emulated/0/Download/export.txt")
+        }
+
+        assertFalse(expectedFile.exists())
+
+        sut.exportFile("export.txt", "/text/plain", targetContext.contentResolver, null, file)
+
+        assertTrue(expectedFile.exists())
+        assertEquals(file.length(), expectedFile.length())
+        assertTrue(expectedFile.delete())
+    }
+
+    @Test
+    fun exportOCFile() {
+        val file = createFile("export.txt", 10)
+        val ocFile = OCFile("/export.txt").apply {
+            storagePath = file.absolutePath
+        }
+
+        val sut = FileExportUtils()
+
+        val expectedFile = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+            File("/sdcard/Downloads/export.txt")
+        } else {
+            File("/storage/emulated/0/Download/export.txt")
+        }
+
+        assertFalse(expectedFile.exists())
+
+        sut.exportFile("export.txt", "/text/plain", targetContext.contentResolver, ocFile, null)
+
+        assertTrue(expectedFile.exists())
+        assertEquals(file.length(), expectedFile.length())
+        assertTrue(expectedFile.delete())
+    }
+}

+ 37 - 63
app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentIT.kt → app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt

@@ -5,7 +5,6 @@
  * @author Tobias Kaminsky
  * Copyright (C) 2020 Tobias Kaminsky
  * Copyright (C) 2020 Nextcloud GmbH
- * Copyright (C) 2020 Chris Narkiewicz <hello@ezaquarii.com>
  *
  * 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
@@ -20,54 +19,21 @@
  * 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.owncloud.android.ui.fragment
+package com.owncloud.android.utils
 
 import android.content.Context
 import androidx.test.core.app.ApplicationProvider
-import androidx.test.espresso.intent.rule.IntentsTestRule
-import com.nextcloud.client.GrantStoragePermissionRule
-import com.nextcloud.client.device.BatteryStatus
-import com.nextcloud.client.device.PowerManagementService
-import com.nextcloud.client.network.Connectivity
-import com.nextcloud.client.network.ConnectivityService
-import com.owncloud.android.AbstractOnServerIT
+import com.owncloud.android.AbstractIT
 import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.utils.FileStorageUtils.checkIfEnoughSpace
+import com.owncloud.android.utils.FileStorageUtils.pathToUserFriendlyDisplay
+import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
-import org.junit.Rule
 import org.junit.Test
-import org.junit.rules.TestRule
 import java.io.File
 
-class OCFileListFragmentIT : AbstractOnServerIT() {
-    @get:Rule
-    val activityRule = IntentsTestRule(FileDisplayActivity::class.java, true, false)
-
-    @get:Rule
-    val permissionRule: TestRule = GrantStoragePermissionRule.grant()
-
-    private val connectivityServiceMock: ConnectivityService = object : ConnectivityService {
-        override fun isInternetWalled(): Boolean {
-            return false
-        }
-
-        override fun getConnectivity(): Connectivity {
-            return Connectivity.CONNECTED_WIFI
-        }
-    }
-
-    private val powerManagementServiceMock: PowerManagementService = object : PowerManagementService {
-        override val isPowerSavingEnabled: Boolean
-            get() = false
-
-        override val isPowerSavingExclusionAvailable: Boolean
-            get() = false
-
-        override val battery: BatteryStatus
-            get() = BatteryStatus()
-    }
-
+class FileStorageUtilsIT : AbstractIT() {
     private fun openFile(name: String): File {
         val ctx: Context = ApplicationProvider.getApplicationContext()
         val externalFilesDir = ctx.getExternalFilesDir(null)
@@ -77,7 +43,6 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
     @Test
     @SuppressWarnings("MagicNumber")
     fun testEnoughSpaceWithoutLocalFile() {
-        val sut = OCFileListFragment()
         val ocFile = OCFile("/test.txt")
         val file = openFile("test.txt")
         file.createNewFile()
@@ -85,22 +50,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
         ocFile.storagePath = file.absolutePath
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 0
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(50L, ocFile))
+        assertFalse(checkIfEnoughSpace(50L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(100L, ocFile))
+        assertFalse(checkIfEnoughSpace(100L, ocFile))
     }
 
     @Test
     @SuppressWarnings("MagicNumber")
     fun testEnoughSpaceWithLocalFile() {
-        val sut = OCFileListFragment()
         val ocFile = OCFile("/test.txt")
         val file = openFile("test.txt")
         file.writeText("123123")
@@ -108,22 +72,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
         ocFile.storagePath = file.absolutePath
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 0
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(50L, ocFile))
+        assertFalse(checkIfEnoughSpace(50L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(100L, ocFile))
+        assertFalse(checkIfEnoughSpace(100L, ocFile))
     }
 
     @Test
     @SuppressWarnings("MagicNumber")
     fun testEnoughSpaceWithoutLocalFolder() {
-        val sut = OCFileListFragment()
         val ocFile = OCFile("/test/")
         val file = openFile("test")
         File(file, "1.txt").writeText("123123")
@@ -131,22 +94,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
         ocFile.storagePath = file.absolutePath
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 0
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(50L, ocFile))
+        assertFalse(checkIfEnoughSpace(50L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(100L, ocFile))
+        assertFalse(checkIfEnoughSpace(100L, ocFile))
     }
 
     @Test
     @SuppressWarnings("MagicNumber")
     fun testEnoughSpaceWithLocalFolder() {
-        val sut = OCFileListFragment()
         val ocFile = OCFile("/test/")
         val folder = openFile("test")
         folder.mkdirs()
@@ -158,30 +120,42 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
         ocFile.mimeType = "DIR"
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 0
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
 
         ocFile.fileLength = 100
-        assertFalse(sut.checkIfEnoughSpace(50L, ocFile))
+        assertFalse(checkIfEnoughSpace(50L, ocFile))
 
         ocFile.fileLength = 44
-        assertTrue(sut.checkIfEnoughSpace(50L, ocFile))
+        assertTrue(checkIfEnoughSpace(50L, ocFile))
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(100L, ocFile))
+        assertTrue(checkIfEnoughSpace(100L, ocFile))
     }
 
     @Test
     @SuppressWarnings("MagicNumber")
     fun testEnoughSpaceWithNoLocalFolder() {
-        val sut = OCFileListFragment()
         val ocFile = OCFile("/test/")
 
         ocFile.mimeType = "DIR"
 
         ocFile.fileLength = 100
-        assertTrue(sut.checkIfEnoughSpace(200L, ocFile))
+        assertTrue(checkIfEnoughSpace(200L, ocFile))
+    }
+
+    @Test
+    fun testPathToUserFriendlyDisplay() {
+        assertEquals("/", pathToUserFriendlyDisplay("/"))
+        assertEquals("/sdcard/", pathToUserFriendlyDisplay("/sdcard/"))
+        assertEquals("/sdcard/test/1/", pathToUserFriendlyDisplay("/sdcard/test/1/"))
+        assertEquals("Internal storage/Movies/", pathToUserFriendlyDisplay("/storage/emulated/0/Movies/"))
+        assertEquals("Internal storage/", pathToUserFriendlyDisplay("/storage/emulated/0/"))
+    }
+
+    private fun pathToUserFriendlyDisplay(path: String): String {
+        return pathToUserFriendlyDisplay(path, targetContext, targetContext.resources)
     }
 }

+ 0 - 42
app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt

@@ -1,42 +0,0 @@
-/*
- *
- * Nextcloud Android client application
- *
- * @author Tobias Kaminsky
- * Copyright (C) 2020 Tobias Kaminsky
- * 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/>.
- */
-package com.owncloud.android.utils
-
-import com.owncloud.android.AbstractIT
-import com.owncloud.android.utils.FileStorageUtils.pathToUserFriendlyDisplay
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-class FileStorageUtilsTest : AbstractIT() {
-    @Test
-    fun testPathToUserFriendlyDisplay() {
-        assertEquals("/", pathToUserFriendlyDisplay("/"))
-        assertEquals("/sdcard/", pathToUserFriendlyDisplay("/sdcard/"))
-        assertEquals("/sdcard/test/1/", pathToUserFriendlyDisplay("/sdcard/test/1/"))
-        assertEquals("Internal storage/Movies/", pathToUserFriendlyDisplay("/storage/emulated/0/Movies/"))
-        assertEquals("Internal storage/", pathToUserFriendlyDisplay("/storage/emulated/0/"))
-    }
-
-    private fun pathToUserFriendlyDisplay(path: String): String {
-        return pathToUserFriendlyDisplay(path, targetContext, targetContext.resources)
-    }
-}

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

@@ -156,6 +156,9 @@
         <receiver
             android:name="com.nextcloud.client.jobs.NotificationWork$NotificationReceiver"
             android:exported="false" />
+        <receiver
+            android:name="com.nextcloud.client.jobs.FilesExportWork$NotificationReceiver"
+            android:exported="false" />
 
         <activity
             android:name=".ui.activity.UploadFilesActivity"

+ 14 - 0
app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

@@ -100,11 +100,25 @@ class BackgroundJobFactory @Inject constructor(
                 AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters)
                 CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters)
                 CalendarImportWork::class -> createCalendarImportWork(context, workerParameters)
+                FilesExportWork::class -> createFilesDownloadWork(context, workerParameters)
                 else -> null // caller falls back to default factory
             }
         }
     }
 
+    private fun createFilesDownloadWork(
+        context: Context,
+        params: WorkerParameters
+    ): ListenableWorker {
+        return FilesExportWork(
+            context,
+            accountManager.user,
+            contentResolver,
+            themeColorUtils,
+            params
+        )
+    }
+
     private fun createContentObserverJob(
         context: Context,
         workerParameters: WorkerParameters,

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

@@ -23,6 +23,7 @@ import android.os.Build
 import androidx.annotation.RequiresApi
 import androidx.lifecycle.LiveData
 import com.nextcloud.client.account.User
+import com.owncloud.android.datamodel.OCFile
 
 /**
  * This interface allows to control, schedule and monitor all application
@@ -126,6 +127,8 @@ interface BackgroundJobManager {
      */
     fun startImmediateCalendarImport(calendarPaths: Map<String, Int>): LiveData<JobInfo?>
 
+    fun startImmediateFilesDownloadJob(files: Collection<OCFile>): LiveData<JobInfo?>
+
     fun schedulePeriodicFilesSyncJob()
     fun startImmediateFilesSyncJob(skipCustomFolders: Boolean = false, overridePowerSaving: Boolean = false)
     fun scheduleOfflineSync()

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

@@ -37,6 +37,7 @@ import androidx.work.WorkInfo
 import androidx.work.WorkManager
 import com.nextcloud.client.account.User
 import com.nextcloud.client.core.Clock
+import com.owncloud.android.datamodel.OCFile
 import java.util.Date
 import java.util.UUID
 import java.util.concurrent.TimeUnit
@@ -78,6 +79,7 @@ internal class BackgroundJobManagerImpl(
         const val JOB_NOTIFICATION = "notification"
         const val JOB_ACCOUNT_REMOVAL = "account_removal"
         const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
+        const val JOB_IMMEDIATE_FILES_DOWNLOAD = "immediate_files_download"
 
         const val JOB_TEST = "test_job"
 
@@ -298,6 +300,22 @@ internal class BackgroundJobManagerImpl(
         return workManager.getJobInfo(request.id)
     }
 
+    override fun startImmediateFilesDownloadJob(files: Collection<OCFile>): LiveData<JobInfo?> {
+        val ids = files.map { it.fileId }.toLongArray()
+
+        val data = Data.Builder()
+            .putLongArray(FilesExportWork.FILES_TO_DOWNLOAD, ids)
+            .build()
+
+        val request = oneTimeRequestBuilder(FilesExportWork::class, JOB_IMMEDIATE_FILES_DOWNLOAD)
+            .setInputData(data)
+            .build()
+
+        workManager.enqueueUniqueWork(JOB_IMMEDIATE_FILES_DOWNLOAD, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
+
+        return workManager.getJobInfo(request.id)
+    }
+
     override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
         val data = Data.Builder()
             .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName)

+ 203 - 0
app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt

@@ -0,0 +1,203 @@
+/*
+ *
+ * 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.jobs
+
+import android.app.Activity
+import android.app.DownloadManager
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.widget.Toast
+import androidx.core.app.NotificationCompat
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.nextcloud.client.account.User
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.services.FileDownloader
+import com.owncloud.android.operations.DownloadType
+import com.owncloud.android.ui.dialog.SendShareDialog
+import com.owncloud.android.ui.notifications.NotificationUtils
+import com.owncloud.android.utils.FileExportUtils
+import com.owncloud.android.utils.FileStorageUtils
+import com.owncloud.android.utils.theme.ThemeColorUtils
+import java.security.SecureRandom
+
+class FilesExportWork(
+    private val appContext: Context,
+    private val user: User,
+    private val contentResolver: ContentResolver,
+    private val themeColorUtils: ThemeColorUtils,
+    params: WorkerParameters
+) : Worker(appContext, params) {
+    companion object {
+        const val FILES_TO_DOWNLOAD = "files_to_download"
+        private const val NUMERIC_NOTIFICATION_ID = "NUMERIC_NOTIFICATION_ID"
+    }
+
+    override fun doWork(): Result {
+        val fileIDs = inputData.getLongArray(FILES_TO_DOWNLOAD) ?: LongArray(0)
+
+        val storageManager = FileDataStorageManager(user, contentResolver)
+
+        var successfulExports = 0
+        for (fileID in fileIDs) {
+            val ocFile = storageManager.getFileById(fileID) ?: continue
+
+            // check if storage is left
+            if (!FileStorageUtils.checkIfEnoughSpace(ocFile)) {
+                showErrorNotification(successfulExports, fileIDs.size)
+                break
+            }
+
+            if (ocFile.isDown) {
+                try {
+                    exportFile(ocFile)
+                } catch (e: java.lang.RuntimeException) {
+                    showErrorNotification(successfulExports, fileIDs.size)
+                }
+            } else {
+                downloadFile(ocFile)
+            }
+
+            successfulExports++
+        }
+
+        // show notification
+        showSuccessNotification(successfulExports)
+
+        return Result.success()
+    }
+
+    @Throws(IllegalStateException::class)
+    private fun exportFile(ocFile: OCFile) {
+        FileExportUtils().exportFile(ocFile.fileName, ocFile.mimeType, contentResolver, ocFile, null)
+    }
+
+    private fun downloadFile(ocFile: OCFile) {
+        val i = Intent(appContext, FileDownloader::class.java)
+        i.putExtra(FileDownloader.EXTRA_USER, user)
+        i.putExtra(FileDownloader.EXTRA_FILE, ocFile)
+        i.putExtra(SendShareDialog.PACKAGE_NAME, "")
+        i.putExtra(SendShareDialog.ACTIVITY_NAME, "")
+        i.putExtra(FileDownloader.DOWNLOAD_TYPE, DownloadType.EXPORT)
+        appContext.startService(i)
+    }
+
+    private fun showErrorNotification(successfulExports: Int, size: Int) {
+        if (successfulExports == 0) {
+            showNotification(
+                appContext.getString(
+                    R.string.export_failed,
+                    appContext.resources.getQuantityString(R.plurals.files, size)
+                )
+            )
+        } else {
+            showNotification(
+                appContext.getString(
+                    R.string.export_partially_failed,
+                    appContext.resources.getQuantityString(R.plurals.files, successfulExports),
+                    appContext.resources.getQuantityString(R.plurals.files, size)
+                )
+            )
+        }
+    }
+
+    private fun showSuccessNotification(successfulExports: Int) {
+        val files = appContext.resources.getQuantityString(R.plurals.files, successfulExports, successfulExports)
+        showNotification(
+            appContext.getString(
+                R.string.export_successful,
+                files
+            )
+        )
+    }
+
+    private fun showNotification(message: String) {
+        val notificationId = SecureRandom().nextInt()
+
+        val notificationBuilder = NotificationCompat.Builder(
+            appContext,
+            NotificationUtils.NOTIFICATION_CHANNEL_DOWNLOAD
+        )
+            .setSmallIcon(R.drawable.notification_icon)
+            .setLargeIcon(BitmapFactory.decodeResource(appContext.resources, R.drawable.notification_icon))
+            .setColor(themeColorUtils.primaryColor(appContext))
+            .setSubText(user.accountName)
+            .setContentText(message)
+            .setAutoCancel(true)
+
+        val actionIntent = Intent(appContext, NotificationReceiver::class.java).apply {
+            putExtra(NUMERIC_NOTIFICATION_ID, notificationId)
+        }
+        val actionPendingIntent = PendingIntent.getBroadcast(
+            appContext,
+            notificationId,
+            actionIntent,
+            PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+        notificationBuilder.addAction(
+            NotificationCompat.Action(
+                null,
+                appContext.getString(R.string.locate_folder),
+                actionPendingIntent
+            )
+        )
+
+        val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        notificationManager.notify(notificationId, notificationBuilder.build())
+    }
+
+    class NotificationReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            // open file chooser
+            val openIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
+                flags = FLAG_ACTIVITY_NEW_TASK
+            }
+
+            // check if intent can be resolved
+            if (context.packageManager.queryIntentActivities(openIntent, PackageManager.GET_RESOLVED_FILTER)
+                    .isNotEmpty()
+            ) {
+                context.startActivity(openIntent)
+            } else {
+                Toast.makeText(context, R.string.open_download_folder, Toast.LENGTH_LONG).show()
+            }
+
+            // remove notification
+            val numericNotificationId = intent.getIntExtra(NUMERIC_NOTIFICATION_ID, 0)
+
+            if (numericNotificationId != 0) {
+                val notificationManager = context.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
+                notificationManager.cancel(numericNotificationId)
+            }
+        }
+    }
+}

+ 9 - 0
app/src/main/java/com/owncloud/android/files/FileMenuFilter.java

@@ -198,6 +198,7 @@ public class FileMenuFilter {
 
         filterEdit(toShow, toHide, capability);
         filterDownload(toShow, toHide, synchronizing);
+        filterExport(toShow, toHide);
         filterRename(toShow, toHide, synchronizing);
         filterCopy(toShow, toHide, synchronizing);
         filterMove(toShow, toHide, synchronizing);
@@ -460,6 +461,14 @@ public class FileMenuFilter {
         }
     }
 
+    private void filterExport(List<Integer> toShow, List<Integer> toHide) {
+        if (files.isEmpty() || containsFolder()) {
+            toHide.add(R.id.action_export_file);
+        } else {
+            toShow.add(R.id.action_export_file);
+        }
+    }
+
     private void filterStream(List<Integer> toShow, List<Integer> toHide) {
         if (files.isEmpty() || !isSingleFile() || !isSingleMedia()) {
             toHide.add(R.id.action_stream_media);

+ 11 - 3
app/src/main/java/com/owncloud/android/files/services/FileDownloader.java

@@ -56,6 +56,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCo
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.FileUtils;
 import com.owncloud.android.operations.DownloadFileOperation;
+import com.owncloud.android.operations.DownloadType;
 import com.owncloud.android.providers.DocumentsStorageProvider;
 import com.owncloud.android.ui.activity.ConflictsResolveActivity;
 import com.owncloud.android.ui.activity.FileActivity;
@@ -96,6 +97,7 @@ public class FileDownloader extends Service
     public static final String EXTRA_REMOTE_PATH = "REMOTE_PATH";
     public static final String EXTRA_LINKED_TO_PATH = "LINKED_TO";
     public static final String ACCOUNT_NAME = "ACCOUNT_NAME";
+    public static final String DOWNLOAD_TYPE = "DOWNLOAD_TYPE";
 
     private static final int FOREGROUND_SERVICE_ID = 412;
 
@@ -207,6 +209,11 @@ public class FileDownloader extends Service
             final User user = intent.getParcelableExtra(EXTRA_USER);
             final OCFile file = intent.getParcelableExtra(EXTRA_FILE);
             final String behaviour = intent.getStringExtra(OCFileListFragment.DOWNLOAD_BEHAVIOUR);
+
+            DownloadType downloadType = DownloadType.DOWNLOAD;
+            if (intent.hasExtra(DOWNLOAD_TYPE)) {
+                downloadType = (DownloadType) intent.getSerializableExtra(DOWNLOAD_TYPE);
+            }
             String activityName = intent.getStringExtra(SendShareDialog.ACTIVITY_NAME);
             String packageName = intent.getStringExtra(SendShareDialog.PACKAGE_NAME);
             conflictUploadId = intent.getLongExtra(ConflictsResolveActivity.EXTRA_CONFLICT_UPLOAD_ID, -1);
@@ -217,7 +224,8 @@ public class FileDownloader extends Service
                                                                               behaviour,
                                                                               activityName,
                                                                               packageName,
-                                                                              getBaseContext());
+                                                                              getBaseContext(),
+                                                                              downloadType);
                 newDownload.addDatatransferProgressListener(this);
                 newDownload.addDatatransferProgressListener((FileDownloaderBinder) mBinder);
                 Pair<String, String> putResult = mPendingDownloads.putIfAbsent(user.getAccountName(),
@@ -381,7 +389,7 @@ public class FileDownloader extends Service
                     mBoundListeners.get(mCurrentDownload.getFile().getFileId());
             if (boundListener != null) {
                 boundListener.onTransferProgress(progressRate, totalTransferredSoFar,
-                        totalToTransfer, fileName);
+                                                 totalToTransfer, fileName);
             }
         }
 
@@ -464,7 +472,7 @@ public class FileDownloader extends Service
 
                     /// perform the download
                     downloadResult = mCurrentDownload.execute(mDownloadClient);
-                    if (downloadResult.isSuccess()) {
+                    if (downloadResult.isSuccess() && mCurrentDownload.getDownloadType() == DownloadType.DOWNLOAD) {
                         saveDownloadedFile();
                     }
 

+ 49 - 18
app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java

@@ -21,7 +21,6 @@
 
 package com.owncloud.android.operations;
 
-import android.accounts.Account;
 import android.content.Context;
 import android.text.TextUtils;
 import android.webkit.MimeTypeMap;
@@ -38,6 +37,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation;
 import com.owncloud.android.utils.EncryptionUtils;
+import com.owncloud.android.utils.FileExportUtils;
 import com.owncloud.android.utils.FileStorageUtils;
 
 import java.io.File;
@@ -59,6 +59,7 @@ public class DownloadFileOperation extends RemoteOperation {
     private String etag = "";
     private String activityName;
     private String packageName;
+    private DownloadType downloadType;
 
     private Context context;
     private Set<OnDatatransferProgressListener> dataTransferListeners = new HashSet<>();
@@ -67,15 +68,20 @@ public class DownloadFileOperation extends RemoteOperation {
 
     private final AtomicBoolean cancellationRequested = new AtomicBoolean(false);
 
-    public DownloadFileOperation(User user, OCFile file, String behaviour, String activityName,
-                                 String packageName, Context context) {
+    public DownloadFileOperation(User user,
+                                 OCFile file,
+                                 String behaviour,
+                                 String activityName,
+                                 String packageName,
+                                 Context context,
+                                 DownloadType downloadType) {
         if (user == null) {
             throw new IllegalArgumentException("Illegal null user in DownloadFileOperation " +
-                    "creation");
+                                                   "creation");
         }
         if (file == null) {
             throw new IllegalArgumentException("Illegal null file in DownloadFileOperation " +
-                    "creation");
+                                                   "creation");
         }
 
         this.user = user;
@@ -84,10 +90,11 @@ public class DownloadFileOperation extends RemoteOperation {
         this.activityName = activityName;
         this.packageName = packageName;
         this.context = context;
+        this.downloadType = downloadType;
     }
 
     public DownloadFileOperation(User user, OCFile file, Context context) {
-        this(user, file, null, null, null, context);
+        this(user, file, null, null, null, context, DownloadType.DOWNLOAD);
     }
 
     public String getSavePath() {
@@ -153,27 +160,35 @@ public class DownloadFileOperation extends RemoteOperation {
         }
 
         RemoteOperationResult result;
-        File newFile;
+        File newFile = null;
         boolean moved;
 
         /// download will be performed to a temporal file, then moved to the final location
         File tmpFile = new File(getTmpPath());
 
-        String tmpFolder =  getTmpFolder();
+        String tmpFolder = getTmpFolder();
 
         downloadOperation = new DownloadFileRemoteOperation(file.getRemotePath(), tmpFolder);
-        Iterator<OnDatatransferProgressListener> listener = dataTransferListeners.iterator();
-        while (listener.hasNext()) {
-            downloadOperation.addDatatransferProgressListener(listener.next());
+
+        if (downloadType == DownloadType.DOWNLOAD) {
+            Iterator<OnDatatransferProgressListener> listener = dataTransferListeners.iterator();
+            while (listener.hasNext()) {
+                downloadOperation.addDatatransferProgressListener(listener.next());
+            }
         }
+
         result = downloadOperation.execute(client);
 
         if (result.isSuccess()) {
             modificationTimestamp = downloadOperation.getModificationTimestamp();
             etag = downloadOperation.getEtag();
-            newFile = new File(getSavePath());
-            if (!newFile.getParentFile().exists() && !newFile.getParentFile().mkdirs()) {
-                Log_OC.e(TAG, "Unable to create parent folder " + newFile.getParentFile().getAbsolutePath());
+
+            if (downloadType == DownloadType.DOWNLOAD) {
+                newFile = new File(getSavePath());
+
+                if (!newFile.getParentFile().exists() && !newFile.getParentFile().mkdirs()) {
+                    Log_OC.e(TAG, "Unable to create parent folder " + newFile.getParentFile().getAbsolutePath());
+                }
             }
 
             // decrypt file
@@ -207,10 +222,22 @@ public class DownloadFileOperation extends RemoteOperation {
                     return new RemoteOperationResult(e);
                 }
             }
-            moved = tmpFile.renameTo(newFile);
-            newFile.setLastModified(file.getModificationTimestamp());
-            if (!moved) {
-                result = new RemoteOperationResult(RemoteOperationResult.ResultCode.LOCAL_STORAGE_NOT_MOVED);
+
+            if (downloadType == DownloadType.DOWNLOAD) {
+                moved = tmpFile.renameTo(newFile);
+                newFile.setLastModified(file.getModificationTimestamp());
+                if (!moved) {
+                    result = new RemoteOperationResult(RemoteOperationResult.ResultCode.LOCAL_STORAGE_NOT_MOVED);
+                }
+            } else if (downloadType == DownloadType.EXPORT) {
+                new FileExportUtils().exportFile(file.getFileName(),
+                                                 file.getMimeType(),
+                                                 context.getContentResolver(),
+                                                 null,
+                                                 tmpFile);
+                if (!tmpFile.delete()) {
+                    Log_OC.e(TAG, "Deletion of " + tmpFile.getAbsolutePath() + " failed!");
+                }
             }
         }
         Log_OC.i(TAG, "Download of " + file.getRemotePath() + " to " + getSavePath() + ": " +
@@ -262,4 +289,8 @@ public class DownloadFileOperation extends RemoteOperation {
     public String getPackageName() {
         return this.packageName;
     }
+
+    public DownloadType getDownloadType() {
+        return downloadType;
+    }
 }

+ 28 - 0
app/src/main/java/com/owncloud/android/operations/DownloadType.kt

@@ -0,0 +1,28 @@
+/*
+ *
+ * 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.owncloud.android.operations
+
+enum class DownloadType {
+    DOWNLOAD,
+    EXPORT
+}

+ 0 - 1
app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java

@@ -21,7 +21,6 @@
 
 package com.owncloud.android.operations;
 
-import android.accounts.Account;
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.net.Uri;

+ 22 - 27
app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

@@ -55,6 +55,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.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.utils.Throttler;
@@ -133,7 +134,6 @@ import javax.inject.Inject;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
-import androidx.annotation.VisibleForTesting;
 import androidx.appcompat.app.ActionBar;
 import androidx.coordinatorlayout.widget.CoordinatorLayout;
 import androidx.drawerlayout.widget.DrawerLayout;
@@ -174,6 +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";
@@ -187,7 +188,6 @@ public class OCFileListFragment extends ExtendedListFragment implements
     private static final String DIALOG_CREATE_FOLDER = "DIALOG_CREATE_FOLDER";
     private static final String DIALOG_CREATE_DOCUMENT = "DIALOG_CREATE_DOCUMENT";
     private static final String DIALOG_BOTTOM_SHEET = "DIALOG_BOTTOM_SHEET";
-    private static final String DIALOG_LOCK_DETAILS = "DIALOG_LOCK_DETAILS";
 
     private static final int SINGLE_SELECTION = 1;
     private static final int NOT_ENOUGH_SPACE_FRAG_REQUEST_CODE = 2;
@@ -202,6 +202,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     @Inject ThemeUtils themeUtils;
     @Inject ThemeAvatarUtils themeAvatarUtils;
     @Inject ArbitraryDataProvider arbitraryDataProvider;
+    @Inject BackgroundJobManager backgroundJobManager;
 
     protected FileFragment.ContainerActivity mContainerActivity;
 
@@ -222,7 +223,6 @@ public class OCFileListFragment extends ExtendedListFragment implements
     protected String mLimitToMimeType;
     private FloatingActionButton mFabMain;
 
-
     @Inject DeviceInfo deviceInfo;
 
     protected enum MenuItemAddRemove {
@@ -1089,7 +1089,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
     public void onActivityResult(int requestCode, int resultCode, Intent data) {
         if (requestCode == SetupEncryptionDialogFragment.SETUP_ENCRYPTION_REQUEST_CODE &&
                 resultCode == SetupEncryptionDialogFragment.SETUP_ENCRYPTION_RESULT_CODE &&
-                data.getBooleanExtra(SetupEncryptionDialogFragment.SUCCESS, false)) {
+            data.getBooleanExtra(SetupEncryptionDialogFragment.SUCCESS, false)) {
 
             int position = data.getIntExtra(SetupEncryptionDialogFragment.ARG_POSITION, -1);
             OCFile file = mAdapter.getItem(position);
@@ -1186,6 +1186,10 @@ public class OCFileListFragment extends ExtendedListFragment implements
             syncAndCheckFiles(checkedFiles);
             exitSelectionMode();
             return true;
+        } else if (itemId == R.id.action_export_file) {
+            exportFiles(checkedFiles);
+            exitSelectionMode();
+            return true;
         } else if (itemId == R.id.action_cancel_sync) {
             ((FileDisplayActivity) mContainerActivity).cancelTransference(checkedFiles);
             return true;
@@ -1818,13 +1822,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             // Get the remaining space on device
             long availableSpaceOnDevice = FileOperationsHelper.getAvailableSpaceOnDevice();
 
-            // Determine if space is enough to download the file, -1 available space if there in error while computing
-            boolean isSpaceEnough = true;
-            if (availableSpaceOnDevice >= 0) {
-                isSpaceEnough = checkIfEnoughSpace(availableSpaceOnDevice, file);
-            }
-
-            if (isSpaceEnough) {
+            if (FileStorageUtils.checkIfEnoughSpace(file)) {
                 mContainerActivity.getFileOperationsHelper().syncFile(file);
             } else {
                 showSpaceErrorDialog(file, availableSpaceOnDevice);
@@ -1832,24 +1830,21 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
     }
 
-    @VisibleForTesting
-    public boolean checkIfEnoughSpace(long availableSpaceOnDevice, OCFile file) {
-        if (file.isFolder()) {
-            // on folders we assume that we only need difference
-            return availableSpaceOnDevice > (file.getFileLength() - localFolderSize(file));
-        } else {
-            // on files complete file must first be stored, then target gets overwritten
-            return availableSpaceOnDevice > file.getFileLength();
-        }
-    }
+    private void exportFiles(Collection<OCFile> files) {
+        Context context = getContext();
+        View view = getView();
 
-    private long localFolderSize(OCFile file) {
-        if (file.getStoragePath() == null) {
-            // not yet downloaded anything
-            return 0;
-        } else {
-            return FileStorageUtils.getFolderSize(new File(file.getStoragePath()));
+        if (context != null && view != null) {
+            DisplayUtils.showSnackMessage(view,
+                                          context.getString(
+                                              R.string.export_start,
+                                              context.getResources().getQuantityString(R.plurals.files,
+                                                                                       files.size(),
+                                                                                       files.size())
+                                                           ));
         }
+
+        backgroundJobManager.startImmediateFilesDownloadJob(files);
     }
 
     private void showSpaceErrorDialog(OCFile file, long availableSpaceOnDevice) {

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

@@ -880,7 +880,7 @@ public class FileOperationsHelper {
             intent.putExtra(OperationsService.EXTRA_SYNC_FILE_CONTENTS, true);
             mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(intent);
             fileActivity.showLoadingDialog(fileActivity.getApplicationContext().
-                    getString(R.string.wait_a_moment));
+                                               getString(R.string.wait_a_moment));
 
         } else {
             Intent intent = new Intent(fileActivity, OperationsService.class);

+ 181 - 0
app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt

@@ -0,0 +1,181 @@
+/*
+ *
+ * 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.owncloud.android.utils
+
+import android.content.ContentResolver
+import android.content.ContentValues
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.utils.Log_OC
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+
+class FileExportUtils {
+    companion object {
+        const val INITIAL_RENAME_COUNT = 2
+    }
+
+    @Throws(IllegalStateException::class)
+    fun exportFile(
+        fileName: String,
+        mimeType: String,
+        contentResolver: ContentResolver,
+        ocFile: OCFile?,
+        file: File?
+    ) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            exportFileAndroid10AndAbove(
+                fileName,
+                mimeType,
+                contentResolver,
+                ocFile,
+                file
+            )
+        } else {
+            exportFilesBelowAndroid10(
+                fileName,
+                contentResolver,
+                ocFile,
+                file
+            )
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun exportFileAndroid10AndAbove(
+        fileName: String,
+        mimeType: String,
+        contentResolver: ContentResolver,
+        ocFile: OCFile?,
+        file: File?
+    ) {
+        val cv = ContentValues().apply {
+            put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+            put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
+            put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
+        }
+
+        var uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv)
+
+        if (uri == null) {
+            var count = INITIAL_RENAME_COUNT
+            do {
+                val name = generateNewName(fileName, count)
+                cv.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
+                uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv)
+
+                count++
+            } while (uri == null)
+        }
+
+        copy(
+            ocFile,
+            file,
+            contentResolver,
+            FileOutputStream(contentResolver.openFileDescriptor(uri, "w")?.fileDescriptor)
+        )
+    }
+
+    private fun exportFilesBelowAndroid10(
+        fileName: String,
+        contentResolver: ContentResolver,
+        ocFile: OCFile?,
+        file: File?
+    ) {
+        try {
+            var target = File(
+                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+                fileName
+            )
+
+            if (target.exists()) {
+                var count = INITIAL_RENAME_COUNT
+                do {
+                    val name = generateNewName(fileName, count)
+                    target = File(
+                        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+                        name
+                    )
+
+                    count++
+                } while (target.exists())
+            }
+
+            copy(
+                ocFile,
+                file,
+                contentResolver,
+                FileOutputStream(target)
+            )
+        } catch (e: FileNotFoundException) {
+            Log_OC.e(this, "File not found", e)
+        } catch (e: IOException) {
+            Log_OC.e(this, "Cannot write file", e)
+        }
+    }
+
+    @Throws(IllegalStateException::class)
+    private fun copy(ocFile: OCFile?, file: File?, contentResolver: ContentResolver, outputStream: FileOutputStream) {
+        try {
+            val inputStream = if (ocFile != null) {
+                contentResolver.openInputStream(ocFile.storageUri)
+            } else if (file != null) {
+                FileInputStream(file)
+            } else {
+                throw IllegalStateException("ocFile and file both may not be null")
+            }
+
+            inputStream.use { fis ->
+                outputStream.use { os ->
+                    val buffer = ByteArray(1024)
+                    var len: Int
+                    while (fis!!.read(buffer).also { len = it } != -1) {
+                        os.write(buffer, 0, len)
+                    }
+                }
+            }
+        } catch (e: IOException) {
+            Log_OC.e(this, "Cannot write file", e)
+        }
+    }
+
+    private fun generateNewName(name: String, count: Int): String {
+        val extPos = name.lastIndexOf('.')
+        val suffix = " ($count)"
+
+        return if (extPos >= 0) {
+            val extension = name.substring(extPos + 1)
+            val nameWithoutExtension = name.substring(0, extPos)
+
+            "$nameWithoutExtension$suffix.$extension"
+        } else {
+            name + suffix
+        }
+    }
+}

+ 40 - 0
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java

@@ -35,6 +35,7 @@ import com.owncloud.android.datamodel.FileDataStorageManager;
 import com.owncloud.android.datamodel.OCFile;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.model.RemoteFile;
+import com.owncloud.android.ui.helpers.FileOperationsHelper;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -56,6 +57,7 @@ import java.util.TimeZone;
 
 import javax.annotation.Nullable;
 
+import androidx.annotation.VisibleForTesting;
 import androidx.core.app.ActivityCompat;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 
@@ -639,6 +641,44 @@ public final class FileStorageUtils {
         return f.canRead() && f.isDirectory();
     }
 
+    /**
+     * // Determine if space is enough to download the file
+     *
+     * @param file @link{OCFile}
+     * @return boolean: true if there is enough space left
+     * @throws RuntimeException
+     */
+    public static boolean checkIfEnoughSpace(OCFile file) {
+        // Get the remaining space on device
+        long availableSpaceOnDevice = FileOperationsHelper.getAvailableSpaceOnDevice();
+
+        if (availableSpaceOnDevice == -1) {
+            throw new RuntimeException("Error while computing available space");
+        }
+
+        return checkIfEnoughSpace(availableSpaceOnDevice, file);
+    }
+
+    @VisibleForTesting
+    public static boolean checkIfEnoughSpace(long availableSpaceOnDevice, OCFile file) {
+        if (file.isFolder()) {
+            // on folders we assume that we only need difference
+            return availableSpaceOnDevice > (file.getFileLength() - localFolderSize(file));
+        } else {
+            // on files complete file must first be stored, then target gets overwritten
+            return availableSpaceOnDevice > file.getFileLength();
+        }
+    }
+
+    private static long localFolderSize(OCFile file) {
+        if (file.getStoragePath() == null) {
+            // not yet downloaded anything
+            return 0;
+        } else {
+            return FileStorageUtils.getFolderSize(new File(file.getStoragePath()));
+        }
+    }
+
     /**
      * Should be converted to an enum when we only support min SDK version for Environment.DIRECTORY_DOCUMENTS
      */

+ 6 - 0
app/src/main/res/menu/item_file.xml

@@ -107,6 +107,12 @@
         app:showAsAction="never"
         android:showAsAction="never" />
 
+    <item
+        android:id="@+id/action_export_file"
+        android:title="@string/filedetails_export"
+        app:showAsAction="never"
+        android:showAsAction="never" />
+
     <item
         android:id="@+id/action_stream_media"
         android:title="@string/stream"

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

@@ -200,6 +200,10 @@
         <item quantity="one">Found %d duplicate entry.</item>
         <item quantity="other">Found %d duplicate entries.</item>
     </plurals>
+    <plurals name="files">
+        <item quantity="one">%d file</item>
+        <item quantity="other">%d files</item>
+    </plurals>
     <string name="sync_foreign_files_forgotten_explanation">As of version 1.3.16, files uploaded from this device are copied into the local %1$s folder to prevent data loss when a single file is synced with multiple accounts.\n\nDue to this change, all files uploaded with earlier versions of this app were copied into the %2$s folder. However, an error prevented the completion of this operation during account synchronization. You may either leave the file(s) as is and delete the link to %3$s, or move the file(s) into the %1$s folder and retain the link to %4$s.\n\nListed below are the local file(s), and the remote file(s) in %5$s they were linked to.</string>
     <string name="sync_current_folder_was_removed">The folder %1$s does not exist anymore</string>
     <string name="foreign_files_move">Move all</string>
@@ -1014,4 +1018,11 @@
     <string name="locked_by">Locked by %1$s</string>
     <string name="locked_by_app">Locked by %1$s app</string>
     <string name="lock_expiration_info">Expires: %1$s</string>
+    <string name="filedetails_export">Export</string>
+    <string name="locate_folder">Locate folder</string>
+    <string name="export_successful">Exported %1s</string>
+    <string name="export_failed">Failed to export %1s</string>
+    <string name="export_partially_failed">Exported %1s, skipped %2s due to error</string>
+    <string name="export_start">%1s will be exported. See notification for details.</string>
+    <string name="open_download_folder">Cannot open folder. Please manually browse to Download folder</string>
 </resources>