File export aka "download"

Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
This commit is contained in:
tobiasKaminsky 2022-02-21 09:03:57 +01:00
parent 6441e5d189
commit 1d69dcfcaa
No known key found for this signature in database
GPG key ID: 0E00D4D47D0C5AF7
20 changed files with 718 additions and 162 deletions

View file

@ -426,13 +426,6 @@ public class UploadIT extends AbstractOnServerIT {
String remotePath = "/testFile.txt"; String remotePath = "/testFile.txt";
OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name); 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( assertTrue(
new UploadFileOperation( new UploadFileOperation(
uploadsStorageManager, uploadsStorageManager,
@ -453,6 +446,10 @@ public class UploadIT extends AbstractOnServerIT {
.isSuccess() .isSuccess()
); );
long creationTimestamp = Files.readAttributes(file.toPath(), BasicFileAttributes.class)
.creationTime()
.to(TimeUnit.SECONDS);
long uploadTimestamp = System.currentTimeMillis() / 1000; long uploadTimestamp = System.currentTimeMillis() / 1000;
// RefreshFolderOperation // RefreshFolderOperation

View file

@ -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())
}
}

View file

@ -5,7 +5,6 @@
* @author Tobias Kaminsky * @author Tobias Kaminsky
* Copyright (C) 2020 Tobias Kaminsky * Copyright (C) 2020 Tobias Kaminsky
* Copyright (C) 2020 Nextcloud GmbH * 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 * 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 * 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 * 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/>. * 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 android.content.Context
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.intent.rule.IntentsTestRule import com.owncloud.android.AbstractIT
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.datamodel.OCFile 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.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import java.io.File import java.io.File
class OCFileListFragmentIT : AbstractOnServerIT() { class FileStorageUtilsIT : AbstractIT() {
@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()
}
private fun openFile(name: String): File { private fun openFile(name: String): File {
val ctx: Context = ApplicationProvider.getApplicationContext() val ctx: Context = ApplicationProvider.getApplicationContext()
val externalFilesDir = ctx.getExternalFilesDir(null) val externalFilesDir = ctx.getExternalFilesDir(null)
@ -77,7 +43,6 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
@Test @Test
@SuppressWarnings("MagicNumber") @SuppressWarnings("MagicNumber")
fun testEnoughSpaceWithoutLocalFile() { fun testEnoughSpaceWithoutLocalFile() {
val sut = OCFileListFragment()
val ocFile = OCFile("/test.txt") val ocFile = OCFile("/test.txt")
val file = openFile("test.txt") val file = openFile("test.txt")
file.createNewFile() file.createNewFile()
@ -85,22 +50,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
ocFile.storagePath = file.absolutePath ocFile.storagePath = file.absolutePath
ocFile.fileLength = 100 ocFile.fileLength = 100
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 0 ocFile.fileLength = 0
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(50L, ocFile)) assertFalse(checkIfEnoughSpace(50L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(100L, ocFile)) assertFalse(checkIfEnoughSpace(100L, ocFile))
} }
@Test @Test
@SuppressWarnings("MagicNumber") @SuppressWarnings("MagicNumber")
fun testEnoughSpaceWithLocalFile() { fun testEnoughSpaceWithLocalFile() {
val sut = OCFileListFragment()
val ocFile = OCFile("/test.txt") val ocFile = OCFile("/test.txt")
val file = openFile("test.txt") val file = openFile("test.txt")
file.writeText("123123") file.writeText("123123")
@ -108,22 +72,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
ocFile.storagePath = file.absolutePath ocFile.storagePath = file.absolutePath
ocFile.fileLength = 100 ocFile.fileLength = 100
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 0 ocFile.fileLength = 0
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(50L, ocFile)) assertFalse(checkIfEnoughSpace(50L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(100L, ocFile)) assertFalse(checkIfEnoughSpace(100L, ocFile))
} }
@Test @Test
@SuppressWarnings("MagicNumber") @SuppressWarnings("MagicNumber")
fun testEnoughSpaceWithoutLocalFolder() { fun testEnoughSpaceWithoutLocalFolder() {
val sut = OCFileListFragment()
val ocFile = OCFile("/test/") val ocFile = OCFile("/test/")
val file = openFile("test") val file = openFile("test")
File(file, "1.txt").writeText("123123") File(file, "1.txt").writeText("123123")
@ -131,22 +94,21 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
ocFile.storagePath = file.absolutePath ocFile.storagePath = file.absolutePath
ocFile.fileLength = 100 ocFile.fileLength = 100
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 0 ocFile.fileLength = 0
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(50L, ocFile)) assertFalse(checkIfEnoughSpace(50L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(100L, ocFile)) assertFalse(checkIfEnoughSpace(100L, ocFile))
} }
@Test @Test
@SuppressWarnings("MagicNumber") @SuppressWarnings("MagicNumber")
fun testEnoughSpaceWithLocalFolder() { fun testEnoughSpaceWithLocalFolder() {
val sut = OCFileListFragment()
val ocFile = OCFile("/test/") val ocFile = OCFile("/test/")
val folder = openFile("test") val folder = openFile("test")
folder.mkdirs() folder.mkdirs()
@ -158,30 +120,42 @@ class OCFileListFragmentIT : AbstractOnServerIT() {
ocFile.mimeType = "DIR" ocFile.mimeType = "DIR"
ocFile.fileLength = 100 ocFile.fileLength = 100
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 0 ocFile.fileLength = 0
assertTrue(sut.checkIfEnoughSpace(200L, ocFile)) assertTrue(checkIfEnoughSpace(200L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertFalse(sut.checkIfEnoughSpace(50L, ocFile)) assertFalse(checkIfEnoughSpace(50L, ocFile))
ocFile.fileLength = 44 ocFile.fileLength = 44
assertTrue(sut.checkIfEnoughSpace(50L, ocFile)) assertTrue(checkIfEnoughSpace(50L, ocFile))
ocFile.fileLength = 100 ocFile.fileLength = 100
assertTrue(sut.checkIfEnoughSpace(100L, ocFile)) assertTrue(checkIfEnoughSpace(100L, ocFile))
} }
@Test @Test
@SuppressWarnings("MagicNumber") @SuppressWarnings("MagicNumber")
fun testEnoughSpaceWithNoLocalFolder() { fun testEnoughSpaceWithNoLocalFolder() {
val sut = OCFileListFragment()
val ocFile = OCFile("/test/") val ocFile = OCFile("/test/")
ocFile.mimeType = "DIR" ocFile.mimeType = "DIR"
ocFile.fileLength = 100 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)
} }
} }

View file

@ -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)
}
}

View file

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

View file

@ -100,11 +100,25 @@ class BackgroundJobFactory @Inject constructor(
AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters) AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters)
CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters) CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters)
CalendarImportWork::class -> createCalendarImportWork(context, workerParameters) CalendarImportWork::class -> createCalendarImportWork(context, workerParameters)
FilesExportWork::class -> createFilesDownloadWork(context, workerParameters)
else -> null // caller falls back to default factory 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( private fun createContentObserverJob(
context: Context, context: Context,
workerParameters: WorkerParameters, workerParameters: WorkerParameters,

View file

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

View file

@ -37,6 +37,7 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import com.nextcloud.client.account.User import com.nextcloud.client.account.User
import com.nextcloud.client.core.Clock import com.nextcloud.client.core.Clock
import com.owncloud.android.datamodel.OCFile
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -78,6 +79,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_NOTIFICATION = "notification" const val JOB_NOTIFICATION = "notification"
const val JOB_ACCOUNT_REMOVAL = "account_removal" const val JOB_ACCOUNT_REMOVAL = "account_removal"
const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup" const val JOB_IMMEDIATE_CALENDAR_BACKUP = "immediate_calendar_backup"
const val JOB_IMMEDIATE_FILES_DOWNLOAD = "immediate_files_download"
const val JOB_TEST = "test_job" const val JOB_TEST = "test_job"
@ -298,6 +300,22 @@ internal class BackgroundJobManagerImpl(
return workManager.getJobInfo(request.id) 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?> { override fun startImmediateContactsBackup(user: User): LiveData<JobInfo?> {
val data = Data.Builder() val data = Data.Builder()
.putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName) .putString(ContactsBackupWork.KEY_ACCOUNT, user.accountName)

View file

@ -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)
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -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
}

View file

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

View file

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

View file

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

View file

@ -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
}
}
}

View file

@ -35,6 +35,7 @@ import com.owncloud.android.datamodel.FileDataStorageManager;
import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.OCFile;
import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.lib.resources.files.model.RemoteFile; import com.owncloud.android.lib.resources.files.model.RemoteFile;
import com.owncloud.android.ui.helpers.FileOperationsHelper;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@ -56,6 +57,7 @@ import java.util.TimeZone;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@ -639,6 +641,44 @@ public final class FileStorageUtils {
return f.canRead() && f.isDirectory(); 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 * Should be converted to an enum when we only support min SDK version for Environment.DIRECTORY_DOCUMENTS
*/ */

View file

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

View file

@ -200,6 +200,10 @@
<item quantity="one">Found %d duplicate entry.</item> <item quantity="one">Found %d duplicate entry.</item>
<item quantity="other">Found %d duplicate entries.</item> <item quantity="other">Found %d duplicate entries.</item>
</plurals> </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_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="sync_current_folder_was_removed">The folder %1$s does not exist anymore</string>
<string name="foreign_files_move">Move all</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">Locked by %1$s</string>
<string name="locked_by_app">Locked by %1$s app</string> <string name="locked_by_app">Locked by %1$s app</string>
<string name="lock_expiration_info">Expires: %1$s</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> </resources>