From 1d69dcfcaa8638569527e10d446517a76c26b4e5 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Mon, 21 Feb 2022 09:03:57 +0100 Subject: [PATCH] File export aka "download" Signed-off-by: tobiasKaminsky --- .../java/com/owncloud/android/UploadIT.java | 11 +- .../android/utils/FileExportUtilsIT.kt | 78 +++++++ .../FileStorageUtilsIT.kt} | 100 ++++----- .../android/utils/FileStorageUtilsTest.kt | 42 ---- app/src/main/AndroidManifest.xml | 3 + .../client/jobs/BackgroundJobFactory.kt | 14 ++ .../client/jobs/BackgroundJobManager.kt | 3 + .../client/jobs/BackgroundJobManagerImpl.kt | 18 ++ .../nextcloud/client/jobs/FilesExportWork.kt | 203 ++++++++++++++++++ .../android/files/FileMenuFilter.java | 9 + .../files/services/FileDownloader.java | 14 +- .../operations/DownloadFileOperation.java | 67 ++++-- .../android/operations/DownloadType.kt | 28 +++ .../operations/UploadFileOperation.java | 1 - .../ui/fragment/OCFileListFragment.java | 49 ++--- .../ui/helpers/FileOperationsHelper.java | 2 +- .../owncloud/android/utils/FileExportUtils.kt | 181 ++++++++++++++++ .../android/utils/FileStorageUtils.java | 40 ++++ app/src/main/res/menu/item_file.xml | 6 + app/src/main/res/values/strings.xml | 11 + 20 files changed, 718 insertions(+), 162 deletions(-) create mode 100644 app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt rename app/src/androidTest/java/com/owncloud/android/{ui/fragment/OCFileListFragmentIT.kt => utils/FileStorageUtilsIT.kt} (54%) delete mode 100644 app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt create mode 100644 app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/DownloadType.kt create mode 100644 app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt diff --git a/app/src/androidTest/java/com/owncloud/android/UploadIT.java b/app/src/androidTest/java/com/owncloud/android/UploadIT.java index 607ba05abb..74463ad7e2 100644 --- a/app/src/androidTest/java/com/owncloud/android/UploadIT.java +++ b/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 diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileExportUtilsIT.kt new file mode 100644 index 0000000000..255117b5d4 --- /dev/null +++ b/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 . + */ + +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()) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt similarity index 54% rename from app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentIT.kt rename to app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt index 4908437834..e65e2eee2b 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/OCFileListFragmentIT.kt +++ b/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 * * 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 . */ -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) } } diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt deleted file mode 100644 index 1377b39176..0000000000 --- a/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsTest.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 013bf80210..a096afff6b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,6 +156,9 @@ + 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, diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 5c50815243..d1f74663b7 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/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): LiveData + fun startImmediateFilesDownloadJob(files: Collection): LiveData + fun schedulePeriodicFilesSyncJob() fun startImmediateFilesSyncJob(skipCustomFolders: Boolean = false, overridePowerSaving: Boolean = false) fun scheduleOfflineSync() diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index 054acd0529..158989ce3e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/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): LiveData { + 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 { val data = Data.Builder() .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName) diff --git a/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt b/app/src/main/java/com/nextcloud/client/jobs/FilesExportWork.kt new file mode 100644 index 0000000000..91559d6a93 --- /dev/null +++ b/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 . + */ + +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) + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index e214c66c2f..39f3512f6d 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/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 toShow, List toHide) { + if (files.isEmpty() || containsFolder()) { + toHide.add(R.id.action_export_file); + } else { + toShow.add(R.id.action_export_file); + } + } + private void filterStream(List toShow, List toHide) { if (files.isEmpty() || !isSingleFile() || !isSingleMedia()) { toHide.add(R.id.action_stream_media); diff --git a/app/src/main/java/com/owncloud/android/files/services/FileDownloader.java b/app/src/main/java/com/owncloud/android/files/services/FileDownloader.java index 3f893610ce..d4ea859c58 100644 --- a/app/src/main/java/com/owncloud/android/files/services/FileDownloader.java +++ b/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 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(); } diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java index 2fd71221f4..ff1a682b5d 100644 --- a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.java +++ b/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 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 listener = dataTransferListeners.iterator(); - while (listener.hasNext()) { - downloadOperation.addDatatransferProgressListener(listener.next()); + + if (downloadType == DownloadType.DOWNLOAD) { + Iterator 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; + } } diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadType.kt b/app/src/main/java/com/owncloud/android/operations/DownloadType.kt new file mode 100644 index 0000000000..c1a28d7ba3 --- /dev/null +++ b/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 . + */ + +package com.owncloud.android.operations + +enum class DownloadType { + DOWNLOAD, + EXPORT +} diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index c3dbf2f067..0639bc73f3 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/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; diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 4bf9b5e463..8c101e9b84 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/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 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) { diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index 10c96ccc14..3c8244a7e7 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/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); diff --git a/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt b/app/src/main/java/com/owncloud/android/utils/FileExportUtils.kt new file mode 100644 index 0000000000..351f20e580 --- /dev/null +++ b/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 . + */ + +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 + } + } +} diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index b8b013f806..f56daf4b95 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/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 */ diff --git a/app/src/main/res/menu/item_file.xml b/app/src/main/res/menu/item_file.xml index 6b3da5c029..658e880e7e 100644 --- a/app/src/main/res/menu/item_file.xml +++ b/app/src/main/res/menu/item_file.xml @@ -107,6 +107,12 @@ app:showAsAction="never" android:showAsAction="never" /> + + Found %d duplicate entry. Found %d duplicate entries. + + %d file + %d files + 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. The folder %1$s does not exist anymore Move all @@ -1014,4 +1018,11 @@ Locked by %1$s Locked by %1$s app Expires: %1$s + Export + Locate folder + Exported %1s + Failed to export %1s + Exported %1s, skipped %2s due to error + %1s will be exported. See notification for details. + Cannot open folder. Please manually browse to Download folder