From e00b7c3224fd991cbafba6bd99d154ae1289abf6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 9 Oct 2026 08:47:16 +0200 Subject: [PATCH 1/3] Rename .java to .kt Signed-off-by: alperozturk96 --- .../android/utils/{FileStorageUtils.java => FileStorageUtils.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/owncloud/android/utils/{FileStorageUtils.java => FileStorageUtils.kt} (100%) diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt similarity index 100% rename from app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java rename to app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt From 8033e7ea63c6c52b45c5e2859897721f9e021f33 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 9 Oct 2026 08:47:17 +0200 Subject: [PATCH 2/3] refactor(file-storage-utils): kotlin Signed-off-by: alperozturk96 --- .../LocalStoragePathPickerDialogFragment.kt | 2 +- .../android/utils/FileStorageUtils.kt | 1278 ++++++----------- .../android/utils/StandardDirectory.kt | 70 + .../client/utils/FileStorageUtilsTest.kt | 391 ++++- 4 files changed, 891 insertions(+), 850 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/utils/StandardDirectory.kt diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/LocalStoragePathPickerDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/LocalStoragePathPickerDialogFragment.kt index a186f0c3c435..bbf5109d0fe0 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/LocalStoragePathPickerDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/LocalStoragePathPickerDialogFragment.kt @@ -22,7 +22,7 @@ import com.owncloud.android.ui.adapter.StoragePathAdapter.StoragePathAdapterList import com.owncloud.android.ui.adapter.StoragePathItem import com.owncloud.android.ui.dialog.extensions.themeButtons import com.owncloud.android.utils.FileStorageUtils -import com.owncloud.android.utils.FileStorageUtils.StandardDirectory +import com.owncloud.android.utils.StandardDirectory import com.owncloud.android.utils.theme.ViewThemeUtils import java.io.File import javax.inject.Inject diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt index a067f9f1f6bc..322c41e86026 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2019 Tobias Kaminsky * SPDX-FileCopyrightText: 2016-2018 Andy Scherzinger @@ -8,933 +9,514 @@ * SPDX-FileCopyrightText: 2014 David A. Velasco * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ -package com.owncloud.android.utils; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.net.Uri; -import android.os.Environment; -import android.text.TextUtils; -import android.webkit.MimeTypeMap; - -import com.nextcloud.client.preferences.SubFolderRule; -import com.nextcloud.utils.extensions.RemoteFileExtensionsKt; -import com.nextcloud.utils.extensions.StringConstants; -import com.owncloud.android.MainApp; -import com.owncloud.android.R; -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.lib.resources.shares.ShareType; -import com.owncloud.android.lib.resources.shares.ShareeUser; -import com.owncloud.android.ui.helpers.FileOperationsHelper; - -import org.apache.commons.io.FilenameUtils; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; - -import javax.annotation.Nullable; - -import androidx.annotation.VisibleForTesting; -import androidx.core.app.ActivityCompat; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import kotlin.Pair; - -/** - * Static methods to help in access to local file system. - */ -public final class FileStorageUtils { - private static final String TAG = FileStorageUtils.class.getSimpleName(); +package com.owncloud.android.utils + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Resources +import android.net.Uri +import android.os.Environment +import android.webkit.MimeTypeMap +import androidx.annotation.VisibleForTesting +import androidx.core.app.ActivityCompat +import com.nextcloud.client.preferences.SubFolderRule +import com.nextcloud.utils.extensions.StringConstants +import com.nextcloud.utils.extensions.getShareeList +import com.nextcloud.utils.extensions.sharedViaLink +import com.nextcloud.utils.extensions.sharedWithSharee +import com.nextcloud.utils.extensions.tags +import com.owncloud.android.MainApp +import com.owncloud.android.R +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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import org.apache.commons.io.FilenameUtils +import java.io.File +import java.io.IOException +import java.net.URLDecoder +import java.nio.file.Files +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +@Suppress("TooManyFunctions") +object FileStorageUtils { + private val TAG: String = FileStorageUtils::class.java.simpleName + private const val AUTO_UPLOAD_TAG = "AutoUpload" + + private const val PATTERN_YYYY_MM = "yyyy/MM/" + private const val PATTERN_YYYY = "yyyy/" + private const val PATTERN_YYYY_MM_DD = "yyyy/MM/dd/" + private const val DEFAULT_FALLBACK_STORAGE_PATH = "/storage/sdcard0" + private const val DEFAULT_EXT_SD_CARD_PATH = "/storage/sdcard1" + private const val ANDROID_DATA_FOLDER = "/Android/data" + private const val EXTERNAL_FILES_TYPE = "external" + private const val TEMP_ENCRYPTED_FOLDER = "temp_encrypted_folder" + private const val ACCOUNT_NAME_ALLOWED_CHARS = "@" + + private const val ENV_EXTERNAL_STORAGE = "EXTERNAL_STORAGE" + private const val ENV_SECONDARY_STORAGE = "SECONDARY_STORAGE" + private const val ENV_EMULATED_STORAGE_TARGET = "EMULATED_STORAGE_TARGET" + + private const val FILE_SAVE_POLL_INTERVAL_MS = 1000L + private const val UNKNOWN_AVAILABLE_SPACE = -1L + + private const val FIRST_PRINTABLE_CHAR = ' ' + private const val DELETE_CHAR = '\u007F' + + private val BIDI_CONTROL_CHARACTERS = setOf( + '\u202A', '\u202B', '\u202C', '\u202D', '\u202E', + '\u200E', '\u200F', '\u2066', '\u2067', '\u2068', + '\u2069', '\u061C' + ) + + private val INVALID_EXT_FILENAME_CHARS = setOf('"', '*', ':', '/', '<', '>', '?', '\\', '|', DELETE_CHAR) + + private val REPEATED_PATH_SEPARATORS = Regex("${OCFile.PATH_SEPARATOR}+") + + private val MODIFICATION_TIMESTAMP_DESCENDING = compareByDescending { it.modificationTimestamp } + + @JvmStatic + fun containsBidiControlCharacters(filename: String?): Boolean { + if (filename == null) return false + + val decoded = try { + URLDecoder.decode(filename, Charsets.UTF_8.name()) + } catch (_: IllegalArgumentException) { + filename + } + + return decoded.any { it < FIRST_PRINTABLE_CHAR || it in BIDI_CONTROL_CHARACTERS } + } + + @JvmStatic + fun getFilenameAndExtension(filename: String?, isFolder: Boolean, isRTL: Boolean): Pair { + if (isFolder) return filename to "" + + val base = FilenameUtils.getBaseName(filename) + val rawExtension: String = FilenameUtils.getExtension(filename) + val extension = if (rawExtension.isEmpty()) rawExtension else StringConstants.DOT + rawExtension + + return if (isRTL) extension to base else base to extension + } + + @JvmStatic + fun isValidExtFilename(name: String): Boolean = name.all(::isValidExtFilenameChar) + + private fun isValidExtFilenameChar(c: Char): Boolean = c >= FIRST_PRINTABLE_CHAR && c !in INVALID_EXT_FILENAME_CHARS + + @JvmStatic + fun getSavePath(accountName: String?): String = + joinPath(MainApp.getStoragePath(), MainApp.getDataFolder(), encodeAccountName(accountName)) + + @JvmStatic + fun getDefaultSavePathFor(accountName: String?, file: OCFile): String = + getSavePath(accountName) + file.decryptedRemotePath - private static final String PATTERN_YYYY_MM = "yyyy/MM/"; - private static final String PATTERN_YYYY = "yyyy/"; - private static final String PATTERN_YYYY_MM_DD = "yyyy/MM/dd/"; - private static final String DEFAULT_FALLBACK_STORAGE_PATH = "/storage/sdcard0"; + @JvmStatic + fun getTemporalPath(accountName: String?): String = joinPath( + MainApp.getStoragePath(), + MainApp.getDataFolder(), + StringConstants.TEMP, + encodeAccountName(accountName) + ) - private FileStorageUtils() { - // utility class -> private constructor - } + @JvmStatic + fun getTemporalEncryptedFolderPath(accountName: String?): String = + joinPath(MainApp.getAppContext().filesDir.absolutePath, accountName, TEMP_ENCRYPTED_FOLDER) - public static boolean containsBidiControlCharacters(String filename) { - if (filename == null) return false; + @JvmStatic + fun getInternalTemporalPath(accountName: String?, context: Context): String = + getAppTempDirectoryPath(context) + encodeAccountName(accountName) - String decoded; - try { - decoded = URLDecoder.decode(filename, StandardCharsets.UTF_8.toString()); - } catch (Exception e) { - decoded = filename; - } + @JvmStatic + fun getAppTempDirectoryPath(context: Context): String = + joinPath(context.filesDir, MainApp.getDataFolder(), StringConstants.TEMP) + File.separator - int[] bidiControlCharacters = { - 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, - 0x200E, 0x200F, 0x2066, 0x2067, 0x2068, - 0x2069, 0x061C - }; - - for (int i = 0; i < decoded.length(); i++) { - int codePoint = decoded.codePointAt(i); - for (int chars : bidiControlCharacters) { - if (codePoint == chars) { - return true; - } - } - } + private fun encodeAccountName(accountName: String?): String? = Uri.encode(accountName, ACCOUNT_NAME_ALLOWED_CHARS) - for (char c : decoded.toCharArray()) { - if (c < 32) return true; - } + private fun joinPath(vararg segments: Any?): String = segments.joinToString(File.separator) - return false; - } + @JvmStatic + @get:SuppressLint("UsableSpace") + val usableSpace: Long + get() = File(MainApp.getStoragePath()).usableSpace - public static Pair getFilenameAndExtension(String filename, boolean isFolder, boolean isRTL) { - if (isFolder) { - return new Pair<>(filename, ""); + private fun getSubPathFromDate(date: Long, currentLocale: Locale?, subFolderRule: SubFolderRule?): String { + if (date == 0L) { + Log_OC.w(TAG, "FileStorageUtils:getSubPathFromDate date is zero") + return "" } - final String base = FilenameUtils.getBaseName(filename); - String extension = FilenameUtils.getExtension(filename); - if (!extension.isEmpty()) { - extension = StringConstants.DOT + extension; + val datePattern = when (subFolderRule) { + SubFolderRule.YEAR -> PATTERN_YYYY + SubFolderRule.YEAR_MONTH -> PATTERN_YYYY_MM + SubFolderRule.YEAR_MONTH_DAY -> PATTERN_YYYY_MM_DD + null -> "" } - if (isRTL) { - return new Pair<>(extension, base); - } else { - return new Pair<>(base, extension); - } - } - - public static boolean isValidExtFilename(String name) { - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (!isValidExtFilenameChar(c)) { - return false; - } - } - return true; - } - - /** - * Checks whether the given character is valid in an extended file name. - *

- * Reference: - * android.os.FileUtils#isValidExtFilenameChar(char) - * from the Android Open Source Project. - * - * @param c the character to validate - * @return true if the character is valid in a filename, false otherwise - */ - private static boolean isValidExtFilenameChar(char c) { - if ((int) c <= 0x1F) { - return false; - } - - return switch (c) { - case '"', '*', ':', '/', '<', '>', '?', '\\', '|', 0x7F -> false; - default -> true; - }; - } - - /** - * Get local owncloud storage path for accountName. - */ - public static String getSavePath(String accountName) { - return MainApp.getStoragePath() - + File.separator - + MainApp.getDataFolder() - + File.separator - + Uri.encode(accountName, "@"); - // URL encoding is an 'easy fix' to overcome that NTFS and FAT32 don't allow ":" in file names, - // that can be in the accountName since 0.1.190B - } - - /** - * Get local path where OCFile file is to be stored after upload. That is, - * corresponding local path (in local owncloud storage) to remote uploaded - * file. - *

- * e.g. /storage/emulated/0/Android/media/com.nextcloud.client/nextcloud/admin@example.cloud/folder/file.txt - */ - public static String getDefaultSavePathFor(String accountName, OCFile file) { - return getSavePath(accountName) + file.getDecryptedRemotePath(); - } - - /** - * Get absolute path to tmp folder inside datafolder in sd-card for given accountName. - */ - public static String getTemporalPath(String accountName) { - // FIXME broken in SDK 30 - return MainApp.getStoragePath() - + File.separator - + MainApp.getDataFolder() - + File.separator - + StringConstants.TEMP - + File.separator - + Uri.encode(accountName, "@"); - // URL encoding is an 'easy fix' to overcome that NTFS and FAT32 don't allow ":" in file names, - // that can be in the accountName since 0.1.190B + return SimpleDateFormat(datePattern, currentLocale) + .apply { timeZone = TimeZone.getTimeZone(TimeZone.getDefault().id) } + .format(Date(date)) } - public static String getTemporalEncryptedFolderPath(String accountName) { - return MainApp - .getAppContext() - .getFilesDir() - .getAbsolutePath() - + File.separator - + accountName - + File.separator - + "temp_encrypted_folder"; - } - - /** - * Get absolute path to tmp folder inside app folder for given accountName. - */ - public static String getInternalTemporalPath(String accountName, Context context) { - return getAppTempDirectoryPath(context) - + Uri.encode(accountName, "@"); - // URL encoding is an 'easy fix' to overcome that NTFS and FAT32 don't allow ":" in file names, - // that can be in the accountName since 0.1.190B - } - - /** - * @return /data/user/0/com.nextcloud.client/files/nextcloud/tmp/ - */ - public static String getAppTempDirectoryPath(Context context) { - return context.getFilesDir() - + File.separator - + MainApp.getDataFolder() - + File.separator - + StringConstants.TEMP - + File.separator; - } - - /** - * Optimistic number of bytes available on sd-card. accountName is ignored. - * - * @return Optimistic number of available bytes (can be less) - */ - @SuppressLint("UsableSpace") - public static long getUsableSpace() { - File savePath = new File(MainApp.getStoragePath()); - return savePath.getUsableSpace(); - } - - /** - * Returns the a string like 2016/08/ for the passed date. If date is 0 an empty - * string is returned - * - * @param date: date in microseconds since 1st January 1970 - * @return string: yyyy/mm/ - */ - private static String getSubPathFromDate(long date, Locale currentLocale, SubFolderRule subFolderRule) { - if (date == 0) { - Log_OC.w(TAG, "FileStorageUtils:getSubPathFromDate date is zero"); - return ""; - } - String datePattern = ""; - if (subFolderRule == SubFolderRule.YEAR) { - datePattern = PATTERN_YYYY; - } else if (subFolderRule == SubFolderRule.YEAR_MONTH) { - datePattern = PATTERN_YYYY_MM; - } else if (subFolderRule == SubFolderRule.YEAR_MONTH_DAY) { - datePattern = PATTERN_YYYY_MM_DD; - } - - Date d = new Date(date); - - DateFormat df = new SimpleDateFormat(datePattern, currentLocale); - df.setTimeZone(TimeZone.getTimeZone(TimeZone.getDefault().getID())); - - return df.format(d); - } + private fun stripSyncedFolderPrefix(absolutePath: String, syncedFolderLocalPath: String?): String { + if (syncedFolderLocalPath.isNullOrEmpty()) return absolutePath - /** - * Removes the synced folder from the beginning of a local file path, leaving only the subfolders that have to be - * mirrored below the remote folder. - *

- * Files are selected with a SQL {@code LIKE} on the synced folder path, which is case insensitive, so a folder - * configured as {@code DCIM/camera} also collects the files MediaStore stores under {@code DCIM/Camera}. The - * prefix therefore has to be removed case insensitively too, or the whole local path ends up on the server. - */ - private static String stripSyncedFolderPrefix(String absolutePath, String syncedFolderLocalPath) { - if (syncedFolderLocalPath == null || syncedFolderLocalPath.isEmpty()) { - return absolutePath; - } + val prefix = syncedFolderLocalPath.removeSuffix(OCFile.PATH_SEPARATOR) - String prefix = syncedFolderLocalPath.endsWith(OCFile.PATH_SEPARATOR) - ? syncedFolderLocalPath.substring(0, syncedFolderLocalPath.length() - 1) - : syncedFolderLocalPath; + return when { + absolutePath.startsWith(prefix) -> absolutePath.substring(prefix.length) - if (absolutePath.startsWith(prefix)) { - return absolutePath.substring(prefix.length()); - } + absolutePath.startsWith(prefix, ignoreCase = true) -> { + Log_OC.w(TAG, "local path differs from the synced folder in letter case only, stripping anyway") + absolutePath.substring(prefix.length) + } - if (absolutePath.regionMatches(true, 0, prefix, 0, prefix.length())) { - Log_OC.w(TAG, "local path differs from the synced folder in letter case only, stripping anyway"); - return absolutePath.substring(prefix.length()); + else -> { + Log_OC.e(TAG, "local file is not below its synced folder, dropping the local subfolders") + OCFile.PATH_SEPARATOR + File(absolutePath).name + } } - - Log_OC.e(TAG, "local file is not below its synced folder, dropping the local subfolders"); - - return OCFile.PATH_SEPARATOR + new File(absolutePath).getName(); } - /** - * Returns the InstantUploadFilePath on the nextcloud instance - * - * @param dateTaken: Time in milliseconds since 1970 when the picture was taken. - * @return instantUpload path, eg. /Camera/2017/01/fileName - */ - public static String getInstantUploadFilePath(File file, - Locale current, - String remotePath, - String syncedFolderLocalPath, - long dateTaken, - Boolean subfolderByDate, - SubFolderRule subFolderRule) { - String subfolderByDatePath = ""; - if (subfolderByDate) { - subfolderByDatePath = getSubPathFromDate(dateTaken, current, subFolderRule); - } - Log_OC.w(TAG, "FileStorageUtils:getInstantUploadFilePath subfolderByDate: " + subfolderByDate); - - File parentFile = new File(stripSyncedFolderPrefix(file.getAbsolutePath(), syncedFolderLocalPath)).getParentFile(); + @Suppress("LongParameterList") + @JvmStatic + fun getInstantUploadFilePath( + file: File, + current: Locale?, + remotePath: String?, + syncedFolderLocalPath: String?, + dateTaken: Long, + subfolderByDate: Boolean, + subFolderRule: SubFolderRule? + ): String { + val subfolderByDatePath = if (subfolderByDate) getSubPathFromDate(dateTaken, current, subFolderRule) else "" + Log_OC.w(TAG, "FileStorageUtils:getInstantUploadFilePath subfolderByDate: $subfolderByDate") - String relativeSubfolderPath = ""; + val parentFile = File(stripSyncedFolderPrefix(file.absolutePath, syncedFolderLocalPath)).parentFile if (parentFile == null) { - Log_OC.e("AutoUpload", "Parent folder does not exist!"); - } else { - relativeSubfolderPath = parentFile.getAbsolutePath(); + Log_OC.e(AUTO_UPLOAD_TAG, "Parent folder does not exist!") + } + val relativeSubfolderPath = parentFile?.absolutePath.orEmpty() + + return listOf(remotePath, subfolderByDatePath, relativeSubfolderPath, file.name) + .joinToString(OCFile.PATH_SEPARATOR) + .replace(REPEATED_PATH_SEPARATORS, OCFile.PATH_SEPARATOR) + } + + @JvmStatic + fun getParentPath(remotePath: String): String? = File(remotePath).parent?.let { parentPath -> + if (parentPath.endsWith(OCFile.PATH_SEPARATOR)) parentPath else parentPath + OCFile.PATH_SEPARATOR + } + + @JvmStatic + fun fillOCFile(remoteFile: RemoteFile?): OCFile { + val remote = requireNotNull(remoteFile) + return OCFile(remote.remotePath).apply { + decryptedRemotePath = remote.remotePath + creationTimestamp = remote.creationTimestamp + uploadTimestamp = remote.uploadTimestamp + val isRemoteDirectory = MimeType.DIRECTORY.equals(remote.mimeType, ignoreCase = true) + fileLength = if (isRemoteDirectory) remote.size else remote.length + mimeType = remote.mimeType + modificationTimestamp = remote.modifiedTimestamp + etag = remote.etag + permissions = remote.permissions + remoteId = remote.remoteId + localId = remote.localId + isFavorite = remote.isFavorite + if (isFolder) { + isEncrypted = remote.isEncrypted + } + mountType = remote.mountType + isPreviewAvailable = remote.isHasPreview + unreadCommentsCount = remote.unreadCommentsCount + ownerId = remote.ownerId + ownerDisplayName = remote.ownerDisplayName + note = remote.note + + sharees = remote.getShareeList() + isSharedWithSharee = remote.sharedWithSharee() + isSharedViaLink = remote.sharedViaLink() + + richWorkspace = remote.richWorkspace + isLocked = remote.isLocked + lockType = remote.lockType + lockOwnerId = remote.lockOwner + lockOwnerDisplayName = remote.lockOwnerDisplayName + lockOwnerEditor = remote.lockOwnerEditor + lockTimestamp = remote.lockTimestamp + lockTimeout = remote.lockTimeout + lockToken = remote.lockToken + tags = remote.tags() + imageDimension = remote.imageDimension + geoLocation = remote.geoLocation + setLivePhoto(remote.livePhoto) + isHidden = remote.hidden + } + } + + @JvmStatic + fun fillRemoteFile(ocFile: OCFile): RemoteFile = RemoteFile(ocFile.remotePath).apply { + creationTimestamp = ocFile.creationTimestamp + length = ocFile.fileLength + mimeType = ocFile.mimeType + modifiedTimestamp = ocFile.modificationTimestamp + etag = ocFile.etag + permissions = ocFile.permissions + remoteId = ocFile.remoteId + isFavorite = ocFile.isFavorite + } + + @JvmStatic + fun sortOcFolderDescDateModifiedWithoutFavoritesFirst(files: List): List = + files.sortedWith(MODIFICATION_TIMESTAMP_DESCENDING) + + @JvmStatic + fun sortOcFolderDescDateModified(files: MutableList): MutableList { + files.sortWith(MODIFICATION_TIMESTAMP_DESCENDING) + return FileSortOrder.sortCloudFilesByFavourite(files) + } + + @JvmStatic + fun getFolderSize(dir: File?): Long = dir + ?.takeIf { it.isDirectory } + ?.listFiles() + ?.sumOf { if (it.isDirectory) getFolderSize(it) else it.length() } + ?: 0L + + @JvmStatic + fun getMimeTypeFromName(path: String): String { + val extension = path.substringAfterLast('.', missingDelimiterValue = "") + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()).orEmpty() + } + + @JvmStatic + fun searchForLocalFileInDefaultPath(file: OCFile, accountName: String?) { + if (file.isFolder || file.storagePath?.let { File(it).exists() } == true) return + + val localFile = File(getDefaultSavePathFor(accountName, file)) + if (!localFile.exists()) return + + file.storagePath = localFile.absolutePath + file.lastSyncDateForData = localFile.lastModified() + } + + @JvmStatic + @SuppressFBWarnings( + value = ["OBL_UNSATISFIED_OBLIGATION_EXCEPTION_EDGE"], + justification = "False-positive on the output stream" + ) + fun copyFile(src: File, target: File): Boolean = try { + src.inputStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + true + } catch (_: IOException) { + false + } + + @JvmStatic + fun moveFile(sourceFile: File, targetFile: File): Boolean = copyFile(sourceFile, targetFile) && sourceFile.delete() + + @JvmStatic + fun copyDirs(sourceFolder: File, targetFolder: File): Boolean { + if (!targetFolder.mkdirs()) return false + + return sourceFolder.listFiles()?.all { child -> + val target = File(targetFolder, child.name) + if (child.isDirectory) copyDirs(child, target) else copyFile(child, target) + } ?: false + } + + @Suppress("TooGenericExceptionCaught") + @JvmStatic + fun deleteRecursively(file: File, storageManager: FileDataStorageManager) { + if (file.isDirectory) { + val children = file.listFiles() ?: return + children.forEach { deleteRecursively(it, storageManager) } } - // Path must be normalized; otherwise the next RefreshFolderOperation has a mismatch and deletes the local file. - return (remotePath + - OCFile.PATH_SEPARATOR + - subfolderByDatePath + - OCFile.PATH_SEPARATOR + - relativeSubfolderPath + - OCFile.PATH_SEPARATOR + - file.getName()) - .replaceAll(OCFile.PATH_SEPARATOR + "+", OCFile.PATH_SEPARATOR); - } - - - public static String getParentPath(String remotePath) { - String parentPath = new File(remotePath).getParent(); - if (parentPath != null) { - parentPath = parentPath.endsWith(OCFile.PATH_SEPARATOR) ? parentPath : parentPath + OCFile.PATH_SEPARATOR; + storageManager.deleteFileInMediaScan(file.absolutePath) + try { + Files.deleteIfExists(file.toPath()) + } catch (e: Exception) { + Log_OC.e("Error deleting file: ", e.message) } - return parentPath; } - /** - * Creates and populates a new {@link OCFile} object with the data read from the server. - * - * @param remote remote file read from the server (remote file or folder). - * @return New OCFile instance representing the remote resource described by remote. - */ - public static OCFile fillOCFile(RemoteFile remote) { - OCFile file = new OCFile(remote.getRemotePath()); - file.setDecryptedRemotePath(remote.getRemotePath()); - file.setCreationTimestamp(remote.getCreationTimestamp()); - file.setUploadTimestamp(remote.getUploadTimestamp()); - if (MimeType.DIRECTORY.equalsIgnoreCase(remote.getMimeType())) { - file.setFileLength(remote.getSize()); + @JvmStatic + fun deleteRecursive(file: File): Boolean { + val allChildrenDeleted = if (file.isDirectory) { + val children = file.listFiles() ?: return true + children.fold(true) { allDeleted, child -> deleteRecursive(child) && allDeleted } } else { - file.setFileLength(remote.getLength()); - } - file.setMimeType(remote.getMimeType()); - file.setModificationTimestamp(remote.getModifiedTimestamp()); - file.setEtag(remote.getEtag()); - file.setPermissions(remote.getPermissions()); - file.setRemoteId(remote.getRemoteId()); - file.setLocalId(remote.getLocalId()); - file.setFavorite(remote.isFavorite()); - if (file.isFolder()) { - file.setEncrypted(remote.isEncrypted()); + true } - file.setMountType(remote.getMountType()); - file.setPreviewAvailable(remote.isHasPreview()); - file.setUnreadCommentsCount(remote.getUnreadCommentsCount()); - file.setOwnerId(remote.getOwnerId()); - file.setOwnerDisplayName(remote.getOwnerDisplayName()); - file.setNote(remote.getNote()); - - file.setSharees(RemoteFileExtensionsKt.getShareeList(remote)); - file.setSharedWithSharee(RemoteFileExtensionsKt.sharedWithSharee(remote)); - file.setSharedViaLink(RemoteFileExtensionsKt.sharedViaLink(remote)); - - file.setRichWorkspace(remote.getRichWorkspace()); - file.setLocked(remote.isLocked()); - file.setLockType(remote.getLockType()); - file.setLockOwnerId(remote.getLockOwner()); - file.setLockOwnerDisplayName(remote.getLockOwnerDisplayName()); - file.setLockOwnerEditor(remote.getLockOwnerEditor()); - file.setLockTimestamp(remote.getLockTimestamp()); - file.setLockTimeout(remote.getLockTimeout()); - file.setLockToken(remote.getLockToken()); - file.setTags(RemoteFileExtensionsKt.tags(remote)); - file.setImageDimension(remote.getImageDimension()); - file.setGeoLocation(remote.getGeoLocation()); - file.setLivePhoto(remote.getLivePhoto()); - file.setHidden(remote.getHidden()); - - return file; - } - - /** - * Creates and populates a new {@link RemoteFile} object with the data read from an {@link OCFile}. - * - * @param ocFile OCFile - * @return New RemoteFile instance representing the resource described by ocFile. - */ - public static RemoteFile fillRemoteFile(OCFile ocFile) { - RemoteFile file = new RemoteFile(ocFile.getRemotePath()); - file.setCreationTimestamp(ocFile.getCreationTimestamp()); - file.setLength(ocFile.getFileLength()); - file.setMimeType(ocFile.getMimeType()); - file.setModifiedTimestamp(ocFile.getModificationTimestamp()); - file.setEtag(ocFile.getEtag()); - file.setPermissions(ocFile.getPermissions()); - file.setRemoteId(ocFile.getRemoteId()); - file.setFavorite(ocFile.isFavorite()); - return file; - } - - public static List sortOcFolderDescDateModifiedWithoutFavoritesFirst(List files) { - final int multiplier = -1; - files.sort((o1, o2) -> multiplier * Long.compare(o1.getModificationTimestamp(), o2.getModificationTimestamp())); - return files; + return file.delete() && allChildrenDeleted } - public static List sortOcFolderDescDateModified(List files) { - files = sortOcFolderDescDateModifiedWithoutFavoritesFirst(files); + @JvmStatic + fun checkIfFileFinishedSaving(file: OCFile) { + val realFile = File(file.storagePath) + if (realFile.lastModified() == file.modificationTimestamp || realFile.length() == file.fileLength) return - return FileSortOrder.sortCloudFilesByFavourite(files); - } - - - /** - * Local Folder size. - * - * @param dir File - * @return Size in bytes - */ - public static long getFolderSize(File dir) { - if (dir == null || !dir.exists() || !dir.isDirectory()) { - return 0; - } - - File[] files = dir.listFiles(); - if (files == null) { - return 0; - } - - long result = 0; - for (File f : files) { - if (f.isDirectory()) { - result += getFolderSize(f); - continue; + var lastModified = 0L + var lastSize = 0L + while (realFile.lastModified() != lastModified && realFile.length() != lastSize) { + lastModified = realFile.lastModified() + lastSize = realFile.length() + try { + Thread.sleep(FILE_SAVE_POLL_INTERVAL_MS) + } catch (_: InterruptedException) { + Log_OC.d(TAG, "Failed to sleep for a bit") } - result += f.length(); } - - return result; } - - /** - * Mimetype String of a file. - * - * @param path the file path - * @return the mime type based on the file name - */ - public static String getMimeTypeFromName(String path) { - String extension = ""; - int pos = path.lastIndexOf('.'); - if (pos >= 0) { - extension = path.substring(pos + 1); + @JvmStatic + fun checkEncryptionStatus(file: OCFile?, storageManager: FileDataStorageManager): Boolean { + if (file == null) { + Log_OC.e(TAG, "checkEncryptionStatus called with null file") + return false } - String result = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase(Locale.ROOT)); - return (result != null) ? result : ""; - } - /** - * Scans the default location for saving local copies of files searching for - * a 'lost' file with the same full name as the {@link OCFile} received as - * parameter. - * - * This method helps to keep linked local copies of the files when the app is uninstalled, and then - * reinstalled in the device. OR after the cache of the app was deleted in system settings. - * - * The method is assuming that all the local changes in the file where synchronized in the past. This is dangerous, - * but assuming the contrary could lead to massive unnecessary synchronizations of downloaded file after deleting - * the app cache. - * - * This should be changed in the near future to avoid any chance of data loss, but we need to add some options - * to limit hard automatic synchronizations to wifi, unless the user wants otherwise. - * - * @param file File to associate a possible 'lost' local file. - * @param accountName File owner account name. - */ - public static void searchForLocalFileInDefaultPath(OCFile file, String accountName) { - if ((file.getStoragePath() == null || !new File(file.getStoragePath()).exists()) && !file.isFolder()) { - File f = new File(FileStorageUtils.getDefaultSavePathFor(accountName, file)); - if (f.exists()) { - file.setStoragePath(f.getAbsolutePath()); - file.setLastSyncDateForData(f.lastModified()); - } - } + return file.isEncrypted || selfAndAncestorsBelowRoot(file, storageManager).any { it.isEncrypted } } - @SuppressFBWarnings(value = "OBL_UNSATISFIED_OBLIGATION_EXCEPTION_EDGE", - justification = "False-positive on the output stream") - public static boolean copyFile(File src, File target) { - boolean ret = true; - - try (InputStream in = new FileInputStream(src); - OutputStream out = new FileOutputStream(target)) { - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } catch (IOException ex) { - ret = false; - } - - return ret; - } + private fun selfAndAncestorsBelowRoot(file: OCFile, storageManager: FileDataStorageManager): Sequence = + generateSequence(file) { storageManager.getFileById(it.parentId) } + .takeWhile { OCFile.ROOT_PATH != it.decryptedRemotePath } - public static boolean moveFile(File sourceFile, File targetFile) { - if (copyFile(sourceFile, targetFile)) { - return sourceFile.delete(); - } else { - return false; - } - } - - public static boolean copyDirs(File sourceFolder, File targetFolder) { - if (!targetFolder.mkdirs()) { - return false; - } - - File[] listFiles = sourceFolder.listFiles(); - - if (listFiles == null) { - return false; + @JvmStatic + fun getStorageDirectories(context: Context): List { + val storageDirectories = mutableListOf() + if (!checkStoragePermission(context)) { + storageDirectories += primaryStorageDirectory() + storageDirectories += secondaryStorageDirectories() } - for (File f : listFiles) { - if (f.isDirectory()) { - if (!copyDirs(f, new File(targetFolder, f.getName()))) { - return false; - } - } else if (!FileStorageUtils.copyFile(f, new File(targetFolder, f.getName()))) { - return false; + getExtSdCardPathsForActivity(context).forEach { extSdCardPath -> + if (extSdCardPath !in storageDirectories && canListFiles(File(extSdCardPath))) { + storageDirectories += extSdCardPath } } - return true; + return storageDirectories } - public static void deleteRecursively(File file, FileDataStorageManager storageManager) { - if (file.isDirectory()) { - File[] listFiles = file.listFiles(); - - if (listFiles == null) { - return; - } + @SuppressFBWarnings( + value = ["DMI_HARDCODED_ABSOLUTE_FILENAME"], + justification = "Default Android fallback storage path" + ) + private fun primaryStorageDirectory(): String { + val emulatedStorageTarget = System.getenv(ENV_EMULATED_STORAGE_TARGET) + val externalStorage = System.getenv(ENV_EXTERNAL_STORAGE) - for (File child : listFiles) { - deleteRecursively(child, storageManager); - } - } - - storageManager.deleteFileInMediaScan(file.getAbsolutePath()); - try { - Files.deleteIfExists(file.toPath()); - } catch (Exception e) { - Log_OC.e("Error deleting file: ", e.getMessage()); + return when { + !emulatedStorageTarget.isNullOrEmpty() -> emulatedStorageDirectory(emulatedStorageTarget) + !externalStorage.isNullOrEmpty() -> externalStorage + File(DEFAULT_FALLBACK_STORAGE_PATH).exists() -> DEFAULT_FALLBACK_STORAGE_PATH + else -> Environment.getExternalStorageDirectory().absolutePath } } - public static boolean deleteRecursive(File file) { - boolean res = true; - - if (file.isDirectory()) { - File[] listFiles = file.listFiles(); - - if (listFiles == null) { - return true; - } - - for (File c : listFiles) { - res = deleteRecursive(c) && res; - } - } - - return file.delete() && res; - } + private fun emulatedStorageDirectory(emulatedStorageTarget: String): String { + val externalStoragePath = Environment.getExternalStorageDirectory().absolutePath + val lastFolder = OCFile.PATH_SEPARATOR + .split(externalStoragePath.toRegex()) + .dropLastWhile { it.isEmpty() } + .last() + val userId = lastFolder.takeIf { it.toIntOrNull() != null } - public static void checkIfFileFinishedSaving(OCFile file) { - long lastModified = 0; - long lastSize = 0; - File realFile = new File(file.getStoragePath()); - - if (realFile.lastModified() != file.getModificationTimestamp() && realFile.length() != file.getFileLength()) { - while (realFile.lastModified() != lastModified && realFile.length() != lastSize) { - lastModified = realFile.lastModified(); - lastSize = realFile.length(); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Log_OC.d(TAG, "Failed to sleep for a bit"); - } - } - } + return if (userId == null) emulatedStorageTarget else emulatedStorageTarget + File.separator + userId } - /** - * Checks and returns true if file itself or ancestor is encrypted - * - * @param file file to check - * @param storageManager up to date reference to storage manager - * @return true if file itself or ancestor is encrypted - */ - public static boolean checkEncryptionStatus(OCFile file, FileDataStorageManager storageManager) { - if (file == null) { - Log_OC.e(TAG, "checkEncryptionStatus called with null file"); - return false; - } + private fun secondaryStorageDirectories(): List = System.getenv(ENV_SECONDARY_STORAGE) + ?.takeIf { it.isNotEmpty() } + ?.split(File.pathSeparator) + ?.dropLastWhile { it.isEmpty() } + .orEmpty() - if (file.isEncrypted()) { - return true; - } + @JvmStatic + fun pathToUserFriendlyDisplay(path: String?, context: Context?, resources: Resources): String { + if (path == null || context == null) return path.orEmpty() - while (file != null && !OCFile.ROOT_PATH.equals(file.getDecryptedRemotePath())) { - if (file.isEncrypted()) { - return true; - } - file = storageManager.getFileById(file.getParentId()); - } - return false; + return getStorageDirectories(context) + .firstOrNull { path.startsWith(it) } + ?.let { storageDevice -> userFriendlyDisplay(path, storageDevice, resources) } + ?: path } - /** - * Taken from https://github.com/TeamAmaze/AmazeFileManager/blob/54652548223d151f089bdc6fc868b13ca5ab20a9/app/src - * /main/java/com/amaze/filemanager/activities/MainActivity.java#L620 on 14.02.2019 - */ - @SuppressFBWarnings(value = "DMI_HARDCODED_ABSOLUTE_FILENAME", - justification = "Default Android fallback storage path") - public static List getStorageDirectories(Context context) { - // Final set of paths - final List rv = new ArrayList<>(); - // Primary physical SD-CARD (not emulated) - final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE"); - // All Secondary SD-CARDs (all exclude primary) separated by ":" - final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE"); - // Primary emulated SD-CARD - final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET"); - if (TextUtils.isEmpty(rawEmulatedStorageTarget)) { - // Device has physical external storage; use plain paths. - if (TextUtils.isEmpty(rawExternalStorage)) { - // EXTERNAL_STORAGE undefined; falling back to default. - // Check for actual existence of the directory before adding to list - if (new File(DEFAULT_FALLBACK_STORAGE_PATH).exists()) { - rv.add(DEFAULT_FALLBACK_STORAGE_PATH); - } else { - //We know nothing else, use Environment's fallback - rv.add(Environment.getExternalStorageDirectory().getAbsolutePath()); - } - } else { - rv.add(rawExternalStorage); - } - } else { - // Device has emulated storage; external storage paths should have - // userId burned into them. - final String rawUserId; - final String path = Environment.getExternalStorageDirectory().getAbsolutePath(); - final String[] folders = OCFile.PATH_SEPARATOR.split(path); - final String lastFolder = folders[folders.length - 1]; - boolean isDigit = false; - try { - Integer.valueOf(lastFolder); - isDigit = true; - } catch (NumberFormatException ignored) { - } - rawUserId = isDigit ? lastFolder : ""; + private fun userFriendlyDisplay(path: String, storageDevice: String, resources: Resources): String { + val storageFolder = path.drop(storageDevice.length + 1) + val folderDisplayName = StandardDirectory.fromPath(storageFolder) + ?.let { " " + resources.getString(it.displayName) } + ?: storageFolder - // /storage/emulated/0[1,2,...] - if (TextUtils.isEmpty(rawUserId)) { - rv.add(rawEmulatedStorageTarget); - } else { - rv.add(rawEmulatedStorageTarget + File.separator + rawUserId); - } - } - // Add all secondary storages - if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) { - // All Secondary SD-CARDs splited into array - final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator); - Collections.addAll(rv, rawSecondaryStorages); - } - if (checkStoragePermission(context)) { - rv.clear(); - } - - String[] extSdCardPaths = getExtSdCardPathsForActivity(context); - File f; - for (String extSdCardPath : extSdCardPaths) { - f = new File(extSdCardPath); - if (!rv.contains(extSdCardPath) && canListFiles(f)) { - rv.add(extSdCardPath); - } + val deviceDisplayName = if (storageDevice.startsWith(Environment.getExternalStorageDirectory().absolutePath)) { + resources.getString(R.string.storage_internal_storage) + } else { + File(storageDevice).name } - return rv; + return resources.getString(R.string.local_folder_friendly_path, deviceDisplayName, folderDisplayName) } - /** - * Update the local path summary display. If a special directory is recognized, it is replaced by its name. - *

- * Example: /storage/emulated/0/Movies -> Internal Storage / Movies Example: /storage/ABC/non/standard/directory -> - * ABC /non/standard/directory - * - * @param path the path to display - * @return a user friendly path as defined in examples, or {@param path} if the storage device isn't recognized. - */ - public static String pathToUserFriendlyDisplay(String path, Context context, Resources resources) { - // Determine storage device (external, sdcard...) - String storageDevice = null; - for (String storageDirectory : FileStorageUtils.getStorageDirectories(context)) { - if (path.startsWith(storageDirectory)) { - storageDevice = storageDirectory; - break; - } - } + private fun checkStoragePermission(context: Context): Boolean = + ActivityCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED - // If storage device was not found, display full path - if (storageDevice == null) { - return path; - } - - // Default to full path without storage device path - String storageFolder; - try { - storageFolder = path.substring(storageDevice.length() + 1); - } catch (StringIndexOutOfBoundsException e) { - storageFolder = ""; - } + private fun getExtSdCardPathsForActivity(context: Context): List = context + .getExternalFilesDirs(EXTERNAL_FILES_TYPE) + .filterNotNull() + .mapNotNull(::storageRootOf) + .ifEmpty { listOf(DEFAULT_EXT_SD_CARD_PATH) } - FileStorageUtils.StandardDirectory standardDirectory = FileStorageUtils.StandardDirectory.fromPath(storageFolder); - if (standardDirectory != null) { - // Friendly name of standard directory - storageFolder = " " + resources.getString(standardDirectory.getDisplayName()); + private fun storageRootOf(externalFilesDir: File): String? { + val absolutePath = externalFilesDir.absolutePath + val index = absolutePath.lastIndexOf(ANDROID_DATA_FOLDER) + if (index < 0) { + Log_OC.w(TAG, "Unexpected external file dir: $absolutePath") + return null } - // Shorten the storage device to a friendlier display name - if (storageDevice.startsWith(Environment.getExternalStorageDirectory().getAbsolutePath())) { - storageDevice = resources.getString(R.string.storage_internal_storage); - } else { - storageDevice = new File(storageDevice).getName(); + val path = absolutePath.substring(0, index) + return try { + File(path).canonicalPath + } catch (_: IOException) { + path } - - return resources.getString(R.string.local_folder_friendly_path, storageDevice, storageFolder); } - /** - * Taken from https://github.com/TeamAmaze/AmazeFileManager/blob/d11e0d2874c6067910e58e059859431a31ad6aee/app/src - * /main/java/com/amaze/filemanager/activities/superclasses/PermissionsActivity.java#L47 on 14.02.2019 - */ - private static boolean checkStoragePermission(Context context) { - // Verify that all required contact permissions have been granted. - return ActivityCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED; - } + private fun canListFiles(f: File): Boolean = f.canRead() && f.isDirectory - /** - * Taken from https://github.com/TeamAmaze/AmazeFileManager/blob/616f2a696823ab0e64ea7a017602dc08e783162e/app/src - * /main/java/com/amaze/filemanager/filesystem/FileUtil.java#L764 on 14.02.2019 - */ - private static String[] getExtSdCardPathsForActivity(Context context) { - List paths = new ArrayList<>(); - for (File file : context.getExternalFilesDirs("external")) { - if (file != null) { - int index = file.getAbsolutePath().lastIndexOf("/Android/data"); - if (index < 0) { - Log_OC.w(TAG, "Unexpected external file dir: " + file.getAbsolutePath()); - } else { - String path = file.getAbsolutePath().substring(0, index); - try { - path = new File(path).getCanonicalPath(); - } catch (IOException e) { - // Keep non-canonical path. - } - paths.add(path); - } - } + @Suppress("TooGenericExceptionThrown") + @JvmStatic + fun checkIfEnoughSpace(file: OCFile): Boolean { + val availableSpaceOnDevice = FileOperationsHelper.getAvailableSpaceOnDevice() + if (availableSpaceOnDevice == UNKNOWN_AVAILABLE_SPACE) { + throw RuntimeException("Error while computing available space") } - if (paths.isEmpty()) { - paths.add("/storage/sdcard1"); - } - return paths.toArray(new String[0]); - } - /** - * Taken from https://github.com/TeamAmaze/AmazeFileManager/blob/9cf1fd5ff1653c692cb54cf6bc71b572c19a11cd/app/src - * /main/java/com/amaze/filemanager/utils/files/FileUtils.java#L754 on 14.02.2019 - */ - private static boolean canListFiles(File f) { - return f.canRead() && f.isDirectory(); + return checkIfEnoughSpace(availableSpaceOnDevice, file) } - /** - * // 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(); - } + @JvmStatic + fun checkIfEnoughSpace(availableSpaceOnDevice: Long, file: OCFile): Boolean = if (file.isFolder) { + availableSpaceOnDevice > file.fileLength - localFolderSize(file) + } else { + availableSpaceOnDevice > file.fileLength } - 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 - */ - public static class StandardDirectory { - public static final StandardDirectory PICTURES = new StandardDirectory( - Environment.DIRECTORY_PICTURES, - R.string.storage_pictures, - R.drawable.ic_image_grey600 - ); - public static final StandardDirectory CAMERA = new StandardDirectory( - Environment.DIRECTORY_DCIM, - R.string.storage_camera, - R.drawable.ic_camera - ); - - public static final StandardDirectory DOCUMENTS; - - static { - DOCUMENTS = new StandardDirectory( - Environment.DIRECTORY_DOCUMENTS, - R.string.storage_documents, - R.drawable.ic_document_grey600 - ); - } - - public static final StandardDirectory DOWNLOADS = new StandardDirectory( - Environment.DIRECTORY_DOWNLOADS, - R.string.storage_downloads, - R.drawable.ic_download_grey600 - ); - public static final StandardDirectory MOVIES = new StandardDirectory( - Environment.DIRECTORY_MOVIES, - R.string.storage_movies, - R.drawable.ic_movie_grey600 - ); - public static final StandardDirectory MUSIC = new StandardDirectory( - Environment.DIRECTORY_MUSIC, - R.string.storage_music, - R.drawable.ic_music_grey600 - ); - - private final String name; - private final int displayNameResource; - private final int iconResource; - - private StandardDirectory(String name, int displayNameResource, int iconResource) { - this.name = name; - this.displayNameResource = displayNameResource; - this.iconResource = iconResource; - } - - public String getName() { - return this.name; - } - - public int getDisplayName() { - return this.displayNameResource; - } - - public int getIcon() { - return this.iconResource; - } - - public static Collection getStandardDirectories() { - Collection standardDirectories = new HashSet<>(); - standardDirectories.add(PICTURES); - standardDirectories.add(CAMERA); - if (DOCUMENTS != null) { - standardDirectories.add(DOCUMENTS); - } - standardDirectories.add(DOWNLOADS); - standardDirectories.add(MOVIES); - standardDirectories.add(MUSIC); - return standardDirectories; - } - - @Nullable - public static StandardDirectory fromPath(String path) { - for (StandardDirectory directory : getStandardDirectories()) { - if (directory.getName().equals(path)) { - return directory; - } - } - return null; - } - } + private fun localFolderSize(file: OCFile): Long = file.storagePath?.let { getFolderSize(File(it)) } ?: 0L } diff --git a/app/src/main/java/com/owncloud/android/utils/StandardDirectory.kt b/app/src/main/java/com/owncloud/android/utils/StandardDirectory.kt new file mode 100644 index 000000000000..5a7d2632e604 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/StandardDirectory.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.utils + +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.owncloud.android.R + +class StandardDirectory private constructor( + val name: String, + @StringRes val displayName: Int, + @DrawableRes val icon: Int +) { + companion object { + @JvmField + val PICTURES = StandardDirectory( + Environment.DIRECTORY_PICTURES, + R.string.storage_pictures, + R.drawable.ic_image_grey600 + ) + + @JvmField + val CAMERA = StandardDirectory( + Environment.DIRECTORY_DCIM, + R.string.storage_camera, + R.drawable.ic_camera + ) + + @JvmField + val DOCUMENTS = StandardDirectory( + Environment.DIRECTORY_DOCUMENTS, + R.string.storage_documents, + R.drawable.ic_document_grey600 + ) + + @JvmField + val DOWNLOADS = StandardDirectory( + Environment.DIRECTORY_DOWNLOADS, + R.string.storage_downloads, + R.drawable.ic_download_grey600 + ) + + @JvmField + val MOVIES = StandardDirectory( + Environment.DIRECTORY_MOVIES, + R.string.storage_movies, + R.drawable.ic_movie_grey600 + ) + + @JvmField + val MUSIC = StandardDirectory( + Environment.DIRECTORY_MUSIC, + R.string.storage_music, + R.drawable.ic_music_grey600 + ) + + private val ALL: Set = hashSetOf(PICTURES, CAMERA, DOCUMENTS, DOWNLOADS, MOVIES, MUSIC) + + @JvmStatic + fun getStandardDirectories(): Collection = ALL + + @JvmStatic + fun fromPath(path: String?): StandardDirectory? = ALL.firstOrNull { it.name == path } + } +} diff --git a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt index 79e3a13ab64d..c5f2f60221c5 100644 --- a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2019 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Nextcloud * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -8,17 +9,46 @@ package com.nextcloud.client.utils import com.nextcloud.client.preferences.SubFolderRule +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import java.io.File import java.util.Locale +import java.util.TimeZone -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class FileStorageUtilsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var defaultTimeZone: TimeZone + + @Before + fun setUp() { + defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @After + fun tearDown() { + TimeZone.setDefault(defaultTimeZone) + } + @Test fun testValidFilenames() { assertTrue(FileStorageUtils.isValidExtFilename("example.txt")) @@ -382,4 +412,363 @@ class FileStorageUtilsTest { val result = FileStorageUtils.containsBidiControlCharacters("safe\u202Ebad\u202Bname.txt") assertTrue(result) } + + @Test + fun testIsValidExtFilenameRejectsDeleteCharacter() { + assertFalse(FileStorageUtils.isValidExtFilename("file\u007Fname")) + } + + @Test + fun testIsValidExtFilenameAcceptsSpaceAndUnicode() { + assertTrue(FileStorageUtils.isValidExtFilename("my file äöü 😀.txt")) + } + + @Test + fun testContainsBidiControlCharactersDetectsEveryBidiCharacter() { + val bidiCharacters = listOf( + '\u202A', '\u202B', '\u202C', '\u202D', '\u202E', + '\u200E', '\u200F', '\u2066', '\u2067', '\u2068', + '\u2069', '\u061C' + ) + + bidiCharacters.forEach { + assertTrue("$it must be detected", FileStorageUtils.containsBidiControlCharacters("a${it}b.txt")) + } + } + + @Test + fun testContainsBidiControlCharactersIgnoresSurrogatePairsAndDeleteCharacter() { + assertFalse(FileStorageUtils.containsBidiControlCharacters("photo😀.jpg")) + assertFalse(FileStorageUtils.containsBidiControlCharacters("file\u007F.txt")) + } + + @Test + fun testContainsBidiControlCharactersDetectsTab() { + assertTrue(FileStorageUtils.containsBidiControlCharacters("file\tname.txt")) + } + + @Test + fun testInstantUploadPathWithYearRule() { + val result = instantUploadPath(subFolderByDate = true, subFolderRule = SubFolderRule.YEAR) + assertEquals("/Camera/2019/file.jpg", result) + } + + @Test + fun testInstantUploadPathWithYearMonthDayRule() { + val result = instantUploadPath(subFolderByDate = true, subFolderRule = SubFolderRule.YEAR_MONTH_DAY) + assertEquals("/Camera/2019/10/01/file.jpg", result) + } + + @Test + fun testInstantUploadPathWithoutRuleIgnoresDate() { + val result = instantUploadPath(subFolderByDate = true, subFolderRule = null) + assertEquals("/Camera/file.jpg", result) + } + + @Test + fun testInstantUploadPathCollapsesRepeatedSeparators() { + val result = instantUploadPath(remotePath = "/Camera//", subFolderByDate = true) + assertEquals("/Camera/2019/10/file.jpg", result) + } + + @Test + fun testInstantUploadPathWithoutSyncedFolderKeepsFullLocalPath() { + assertEquals("/Camera/sdcard/DCIM/file.jpg", instantUploadPath(syncedFolderLocalPath = null)) + assertEquals("/Camera/sdcard/DCIM/file.jpg", instantUploadPath(syncedFolderLocalPath = "")) + } + + @Test + fun testGetParentPath() { + assertEquals("/a/b/", FileStorageUtils.getParentPath("/a/b/c.txt")) + assertEquals("/", FileStorageUtils.getParentPath("/c.txt")) + assertNull(FileStorageUtils.getParentPath("c.txt")) + } + + @Test + fun testGetFolderSizeOfInvalidInputsIsZero() { + val regularFile = temporaryFolder.newFile("regular.txt").apply { writeText("content") } + + assertEquals(0L, FileStorageUtils.getFolderSize(null)) + assertEquals(0L, FileStorageUtils.getFolderSize(File(temporaryFolder.root, "missing"))) + assertEquals(0L, FileStorageUtils.getFolderSize(regularFile)) + } + + @Test + fun testGetFolderSizeSumsNestedFiles() { + val root = temporaryFolder.newFolder("root") + File(root, "a.txt").writeText("12345") + File(root, "sub").mkdirs() + File(root, "sub/b.txt").writeText("123") + File(root, "sub/empty").mkdirs() + + assertEquals(8L, FileStorageUtils.getFolderSize(root)) + } + + @Test + fun testCopyFileCopiesContent() { + val content = ByteArray(LARGE_FILE_SIZE) { it.toByte() } + val source = temporaryFolder.newFile("source.bin").apply { writeBytes(content) } + val target = File(temporaryFolder.root, "target.bin") + + assertTrue(FileStorageUtils.copyFile(source, target)) + assertArrayEquals(content, target.readBytes()) + assertTrue(source.exists()) + } + + @Test + fun testCopyFileReturnsFalseForMissingSource() { + val target = File(temporaryFolder.root, "target.txt") + + assertFalse(FileStorageUtils.copyFile(File(temporaryFolder.root, "missing.txt"), target)) + assertFalse(target.exists()) + } + + @Test + fun testMoveFileDeletesSource() { + val source = temporaryFolder.newFile("source.txt").apply { writeText("content") } + val target = File(temporaryFolder.root, "target.txt") + + assertTrue(FileStorageUtils.moveFile(source, target)) + assertFalse(source.exists()) + assertEquals("content", target.readText()) + } + + @Test + fun testMoveFileReturnsFalseForMissingSource() { + val target = File(temporaryFolder.root, "target.txt") + + assertFalse(FileStorageUtils.moveFile(File(temporaryFolder.root, "missing.txt"), target)) + assertFalse(target.exists()) + } + + @Test + fun testCopyDirsCopiesNestedStructure() { + val source = temporaryFolder.newFolder("source") + File(source, "a.txt").writeText("a") + File(source, "sub").mkdirs() + File(source, "sub/b.txt").writeText("b") + val target = File(temporaryFolder.root, "target") + + assertTrue(FileStorageUtils.copyDirs(source, target)) + assertEquals("a", File(target, "a.txt").readText()) + assertEquals("b", File(target, "sub/b.txt").readText()) + } + + @Test + fun testCopyDirsReturnsFalseWhenTargetAlreadyExists() { + val source = temporaryFolder.newFolder("source") + val target = temporaryFolder.newFolder("target") + + assertFalse(FileStorageUtils.copyDirs(source, target)) + } + + @Test + fun testDeleteRecursiveDeletesNestedStructure() { + val root = temporaryFolder.newFolder("root") + File(root, "sub/deeper").mkdirs() + File(root, "a.txt").writeText("a") + File(root, "sub/deeper/b.txt").writeText("b") + + assertTrue(FileStorageUtils.deleteRecursive(root)) + assertFalse(root.exists()) + } + + @Test + fun testDeleteRecursiveReturnsFalseForMissingFile() { + assertFalse(FileStorageUtils.deleteRecursive(File(temporaryFolder.root, "missing"))) + } + + @Test + fun testCheckEncryptionStatusOfNullFileIsFalse() { + assertFalse(FileStorageUtils.checkEncryptionStatus(null, mock())) + } + + @Test + fun testCheckEncryptionStatusOfEncryptedFileIsTrue() { + val file = ocFile("/folder/file.txt", id = 2, parentId = 1).apply { isEncrypted = true } + assertTrue(FileStorageUtils.checkEncryptionStatus(file, mock())) + } + + @Test + fun testCheckEncryptionStatusDetectsEncryptedAncestor() { + val storageManager = mock() + val root = ocFile(OCFile.ROOT_PATH, id = 1, parentId = 0) + val encryptedFolder = ocFile("/e2e/", id = 2, parentId = 1).apply { isEncrypted = true } + val subFolder = ocFile("/e2e/sub/", id = 3, parentId = 2) + val file = ocFile("/e2e/sub/file.txt", id = 4, parentId = 3) + listOf(root, encryptedFolder, subFolder).forEach { + whenever(storageManager.getFileById(it.fileId)).thenReturn(it) + } + + assertTrue(FileStorageUtils.checkEncryptionStatus(file, storageManager)) + } + + @Test + fun testCheckEncryptionStatusStopsAtRoot() { + val storageManager = mock() + val root = ocFile(OCFile.ROOT_PATH, id = 1, parentId = 0).apply { isEncrypted = true } + val file = ocFile("/file.txt", id = 2, parentId = 1) + whenever(storageManager.getFileById(1)).thenReturn(root) + + assertFalse(FileStorageUtils.checkEncryptionStatus(file, storageManager)) + } + + @Test + fun testCheckEncryptionStatusWithMissingParentIsFalse() { + val file = ocFile("/folder/file.txt", id = 2, parentId = 1) + assertFalse(FileStorageUtils.checkEncryptionStatus(file, mock())) + } + + @Test + fun testSortOcFolderDescDateModifiedWithoutFavoritesFirstIsStableAndDescending() { + val oldest = ocFile("/oldest.txt", modificationTimestamp = 1) + val firstOfTie = ocFile("/first.txt", modificationTimestamp = 2) + val secondOfTie = ocFile("/second.txt", modificationTimestamp = 2).apply { isFavorite = true } + val newest = ocFile("/newest.txt", modificationTimestamp = 3) + + val result = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst( + listOf(oldest, firstOfTie, newest, secondOfTie) + ) + + assertEquals(listOf(newest, firstOfTie, secondOfTie, oldest), result) + } + + @Test + fun testSortOcFolderDescDateModifiedPutsFavoritesFirst() { + val oldFavorite = ocFile("/favorite.txt", modificationTimestamp = 1).apply { isFavorite = true } + val middle = ocFile("/middle.txt", modificationTimestamp = 2) + val newest = ocFile("/newest.txt", modificationTimestamp = 3) + + val result = FileStorageUtils.sortOcFolderDescDateModified(mutableListOf(middle, oldFavorite, newest)) + + assertEquals(listOf(oldFavorite, newest, middle), result) + } + + @Test + fun testCheckIfEnoughSpaceForFileRequiresMoreThanFileLength() { + val file = ocFile("/file.txt").apply { fileLength = 100 } + + assertTrue(FileStorageUtils.checkIfEnoughSpace(101L, file)) + assertFalse(FileStorageUtils.checkIfEnoughSpace(100L, file)) + } + + @Test + fun testCheckIfEnoughSpaceForFolderSubtractsLocalFolderSize() { + val localFolder = temporaryFolder.newFolder("local") + File(localFolder, "a.txt").writeBytes(ByteArray(LOCAL_FOLDER_CONTENT_SIZE)) + val notDownloadedFolder = ocFile("/folder/").apply { + mimeType = MimeType.DIRECTORY + fileLength = 100 + } + val downloadedFolder = ocFile("/folder/").apply { + mimeType = MimeType.DIRECTORY + fileLength = 100 + storagePath = localFolder.absolutePath + } + + assertFalse(FileStorageUtils.checkIfEnoughSpace(50L, notDownloadedFolder)) + assertTrue(FileStorageUtils.checkIfEnoughSpace(50L, downloadedFolder)) + assertFalse(FileStorageUtils.checkIfEnoughSpace(40L, downloadedFolder)) + } + + @Test + fun testFillOCFileForFileUsesLengthAndIgnoresEncryption() { + val remote = RemoteFile("/file.txt").apply { + mimeType = "text/plain" + length = 10 + size = 20 + isEncrypted = true + etag = "etag" + remoteId = "remoteId" + modifiedTimestamp = 1234 + isFavorite = true + } + + val result = FileStorageUtils.fillOCFile(remote) + + assertEquals("/file.txt", result.remotePath) + assertEquals("/file.txt", result.decryptedRemotePath) + assertEquals(10L, result.fileLength) + assertFalse(result.isEncrypted) + assertEquals("etag", result.etag) + assertEquals("remoteId", result.remoteId) + assertEquals(1234L, result.modificationTimestamp) + assertTrue(result.isFavorite) + } + + @Test + fun testFillOCFileForFolderUsesSizeAndEncryption() { + val remote = RemoteFile("/folder/").apply { + mimeType = MimeType.DIRECTORY + length = 10 + size = 20 + isEncrypted = true + } + + val result = FileStorageUtils.fillOCFile(remote) + + assertEquals(20L, result.fileLength) + assertTrue(result.isEncrypted) + } + + @Test + fun testFillRemoteFileCopiesFields() { + val ocFile = ocFile("/file.txt", modificationTimestamp = 1234).apply { + creationTimestamp = 1000 + fileLength = 42 + mimeType = "text/plain" + etag = "etag" + permissions = "RGDNVW" + remoteId = "remoteId" + isFavorite = true + } + + val result = FileStorageUtils.fillRemoteFile(ocFile) + + assertEquals("/file.txt", result.remotePath) + assertEquals(1000L, result.creationTimestamp) + assertEquals(42L, result.length) + assertEquals("text/plain", result.mimeType) + assertEquals(1234L, result.modifiedTimestamp) + assertEquals("etag", result.etag) + assertEquals("RGDNVW", result.permissions) + assertEquals("remoteId", result.remoteId) + assertTrue(result.isFavorite) + } + + @Test + fun testPathToUserFriendlyDisplayWithoutPathOrContextReturnsPath() { + assertEquals("", FileStorageUtils.pathToUserFriendlyDisplay(null, null, mock())) + assertEquals("/storage/x", FileStorageUtils.pathToUserFriendlyDisplay("/storage/x", null, mock())) + } + + @Suppress("LongParameterList") + private fun instantUploadPath( + remotePath: String = "/Camera", + syncedFolderLocalPath: String? = "/sdcard/DCIM", + subFolderByDate: Boolean = false, + subFolderRule: SubFolderRule? = SubFolderRule.YEAR_MONTH + ): String = FileStorageUtils.getInstantUploadFilePath( + File("/sdcard/DCIM/file.jpg"), + Locale.ROOT, + remotePath, + syncedFolderLocalPath, + DATE_TAKEN_2019_10_01, + subFolderByDate, + subFolderRule + ) + + private fun ocFile(path: String, id: Long = 0, parentId: Long = 0, modificationTimestamp: Long = 0): OCFile = + OCFile(path).apply { + fileId = id + this.parentId = parentId + decryptedRemotePath = path + this.modificationTimestamp = modificationTimestamp + } + + companion object { + private const val DATE_TAKEN_2019_10_01 = 1569918628000L + private const val LARGE_FILE_SIZE = 20_000 + private const val LOCAL_FOLDER_CONTENT_SIZE = 60 + } } From c9e961b319d566f1139185c9753e5f86a88a1c5d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 9 Oct 2026 08:50:02 +0200 Subject: [PATCH 3/3] logs errors Signed-off-by: alperozturk96 --- .../java/com/owncloud/android/utils/FileStorageUtils.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt index 322c41e86026..b5e631c19fb0 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.kt @@ -348,7 +348,7 @@ object FileStorageUtils { try { Files.deleteIfExists(file.toPath()) } catch (e: Exception) { - Log_OC.e("Error deleting file: ", e.message) + Log_OC.e(TAG, "Error deleting file: ${file.absolutePath}", e) } } @@ -377,7 +377,9 @@ object FileStorageUtils { try { Thread.sleep(FILE_SAVE_POLL_INTERVAL_MS) } catch (_: InterruptedException) { - Log_OC.d(TAG, "Failed to sleep for a bit") + Log_OC.d(TAG, "Interrupted while waiting for file to finish saving") + Thread.currentThread().interrupt() + break } } }