From 116ad494910c4d803c3eaa19230a5d3dae604024 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 9 Nov 2026 13:26:13 +0100 Subject: [PATCH 1/3] fix(file-util): is folder writable Signed-off-by: alperozturk96 --- .../com/owncloud/android/utils/FileUtil.kt | 127 ++++++++++++++++-- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt index 125543a989bf..3533972e45e0 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt @@ -6,6 +6,8 @@ */ package com.owncloud.android.utils +import android.os.Build +import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import com.owncloud.android.lib.common.utils.Log_OC @@ -14,12 +16,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.IOException +import java.nio.file.FileSystemException import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path import java.nio.file.attribute.BasicFileAttributes +import java.util.UUID import java.util.concurrent.TimeUnit object FileUtil { private const val TAG = "FileUtil" + private const val WRITE_PROBE_PREFIX = ".nc_write_probe_" + private const val WRITE_PROBE_FILE_NAME = "probe" @JvmStatic fun getCreationTimestamp(file: File): Long? { @@ -46,23 +54,120 @@ object FileUtil { } suspend fun isFolderWritable(folder: File?): Boolean = withContext(Dispatchers.IO) { - if (folder == null || !folder.isDirectory() || !folder.canWrite()) { + if (folder == null) { + Log_OC.w(TAG, "isFolderWritable: folder is null") return@withContext false } - return@withContext try { - val tempDir = File(folder, ".test_write_dir_${System.currentTimeMillis()}") - if (!tempDir.mkdir()) return@withContext false + val directory = try { + folder.toPath() + } catch (e: InvalidPathException) { + Log_OC.w(TAG, "isFolderWritable: invalid path: ${folder.path}: ${e.describe()}") + return@withContext false + } + + isUsableDirectory(directory) && canCreateAndDeleteIn(directory) + } + + private fun isUsableDirectory(directory: Path): Boolean { + if (Files.notExists(directory)) { + Log_OC.w(TAG, "isFolderWritable: does not exist: $directory [${describeStorage(directory)}]") + return false + } + + if (!Files.exists(directory)) { + Log_OC.w(TAG, "isFolderWritable: existence cannot be verified: $directory [${describeStorage(directory)}]") + return false + } + + if (!Files.isDirectory(directory)) { + Log_OC.w(TAG, "isFolderWritable: not a directory: $directory") + return false + } + + val storageState = Environment.getExternalStorageState(directory.toFile()) + if (storageState != Environment.MEDIA_MOUNTED && storageState != Environment.MEDIA_UNKNOWN) { + Log_OC.w(TAG, "isFolderWritable: storage is $storageState: $directory [${describeStorage(directory)}]") + return false + } + + if (!Files.isWritable(directory)) { + Log_OC.w(TAG, "isFolderWritable: no write access: $directory [${describeStorage(directory)}]") + return false + } + + Log_OC.i(TAG, "isFolderWritable: usable directory") + + return true + } - val tempFile = File(tempDir, "test_file") - val created = tempFile.createNewFile() + private fun canCreateAndDeleteIn(directory: Path): Boolean { + val probeDirectory = directory.resolve("$WRITE_PROBE_PREFIX${UUID.randomUUID()}") + val probeFile = probeDirectory.resolve(WRITE_PROBE_FILE_NAME) - if (created) tempFile.delete() - tempDir.delete() + val writable = runProbeStep("create directory", probeDirectory) { Files.createDirectory(probeDirectory) } && + runProbeStep("create file", probeFile) { Files.createFile(probeFile) } && + runProbeStep("delete file", probeFile) { Files.delete(probeFile) } && + runProbeStep("delete directory", probeDirectory) { Files.delete(probeDirectory) } - created - } catch (_: Exception) { - false + if (writable) { + Log_OC.d(TAG, "isFolderWritable: writable: $directory") + } else { + removeLeftovers(probeFile, probeDirectory) } + + Log_OC.i(TAG, "isFolderWritable: writable: $writable") + + return writable + } + + private inline fun runProbeStep(step: String, target: Path, action: () -> Unit): Boolean = try { + action() + true + } catch (e: IOException) { + Log_OC.w(TAG, "isFolderWritable: cannot $step $target: ${e.describe()} [${describeStorage(target)}]") + false + } catch (e: SecurityException) { + Log_OC.w(TAG, "isFolderWritable: cannot $step $target: ${e.describe()} [${describeStorage(target)}]") + false + } + + private fun removeLeftovers(vararg paths: Path) { + paths.forEach { path -> + try { + Files.deleteIfExists(path) + } catch (e: IOException) { + Log_OC.e(TAG, "isFolderWritable: leftover write probe could not be removed: $path: ${e.describe()}") + } catch (e: SecurityException) { + Log_OC.e(TAG, "isFolderWritable: leftover write probe could not be removed: $path: ${e.describe()}") + } + } + } + + private fun Exception.describe(): String { + val reason = (this as? FileSystemException)?.reason + return listOfNotNull(javaClass.simpleName, reason ?: message).joinToString(": ") + } + + private fun describeStorage(path: Path): String { + val file = path.toFile() + val removable = try { + Environment.isExternalStorageRemovable(file).toString() + } catch (_: IllegalArgumentException) { + "not shared storage" + } + val allFilesAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager().toString() + } else { + "not applicable" + } + val legacyStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Environment.isExternalStorageLegacy().toString() + } else { + "not applicable" + } + + return "api=${Build.VERSION.SDK_INT}, state=${Environment.getExternalStorageState(file)}, " + + "removable=$removable, allFilesAccess=$allFilesAccess, legacyStorage=$legacyStorage" } } From c0eff005132c29be38690e3df40fd14a376ef958 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Nov 2026 14:15:38 +0100 Subject: [PATCH 2/3] catch exceptions and remove extra check Signed-off-by: alperozturk96 --- .../main/java/com/owncloud/android/utils/FileUtil.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt index 3533972e45e0..1a4e3c560e4f 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt @@ -66,15 +66,15 @@ object FileUtil { return@withContext false } - isUsableDirectory(directory) && canCreateAndDeleteIn(directory) + return@withContext try { + isUsableDirectory(directory) && canCreateAndDeleteIn(directory) + } catch (e: Exception) { + Log_OC.e(TAG, "isFolderWritable: $e") + false + } } private fun isUsableDirectory(directory: Path): Boolean { - if (Files.notExists(directory)) { - Log_OC.w(TAG, "isFolderWritable: does not exist: $directory [${describeStorage(directory)}]") - return false - } - if (!Files.exists(directory)) { Log_OC.w(TAG, "isFolderWritable: existence cannot be verified: $directory [${describeStorage(directory)}]") return false From ad1b183a4e743928bc387973c19e0dc012ca61e5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 11 Nov 2026 14:21:11 +0100 Subject: [PATCH 3/3] catch all since throwing too many exceptions Signed-off-by: alperozturk96 --- .../com/owncloud/android/utils/FileUtil.kt | 67 +++++-------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt index 1a4e3c560e4f..a2f41da7c939 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt @@ -6,7 +6,6 @@ */ package com.owncloud.android.utils -import android.os.Build import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope @@ -16,7 +15,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.IOException -import java.nio.file.FileSystemException import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path @@ -62,7 +60,7 @@ object FileUtil { val directory = try { folder.toPath() } catch (e: InvalidPathException) { - Log_OC.w(TAG, "isFolderWritable: invalid path: ${folder.path}: ${e.describe()}") + Log_OC.w(TAG, "isFolderWritable: invalid path: ${folder.path}: $e") return@withContext false } @@ -76,7 +74,7 @@ object FileUtil { private fun isUsableDirectory(directory: Path): Boolean { if (!Files.exists(directory)) { - Log_OC.w(TAG, "isFolderWritable: existence cannot be verified: $directory [${describeStorage(directory)}]") + Log_OC.w(TAG, "isFolderWritable: existence cannot be verified: $directory") return false } @@ -87,12 +85,12 @@ object FileUtil { val storageState = Environment.getExternalStorageState(directory.toFile()) if (storageState != Environment.MEDIA_MOUNTED && storageState != Environment.MEDIA_UNKNOWN) { - Log_OC.w(TAG, "isFolderWritable: storage is $storageState: $directory [${describeStorage(directory)}]") + Log_OC.w(TAG, "isFolderWritable: storage is $storageState: $directory") return false } if (!Files.isWritable(directory)) { - Log_OC.w(TAG, "isFolderWritable: no write access: $directory [${describeStorage(directory)}]") + Log_OC.w(TAG, "isFolderWritable: no write access: $directory") return false } @@ -105,10 +103,11 @@ object FileUtil { val probeDirectory = directory.resolve("$WRITE_PROBE_PREFIX${UUID.randomUUID()}") val probeFile = probeDirectory.resolve(WRITE_PROBE_FILE_NAME) - val writable = runProbeStep("create directory", probeDirectory) { Files.createDirectory(probeDirectory) } && - runProbeStep("create file", probeFile) { Files.createFile(probeFile) } && - runProbeStep("delete file", probeFile) { Files.delete(probeFile) } && - runProbeStep("delete directory", probeDirectory) { Files.delete(probeDirectory) } + val writable = + runCatchingLogged("create directory", probeDirectory) { Files.createDirectory(probeDirectory) } && + runCatchingLogged("create file", probeFile) { Files.createFile(probeFile) } && + runCatchingLogged("delete file", probeFile) { Files.delete(probeFile) } && + runCatchingLogged("delete directory", probeDirectory) { Files.delete(probeDirectory) } if (writable) { Log_OC.d(TAG, "isFolderWritable: writable: $directory") @@ -121,53 +120,19 @@ object FileUtil { return writable } - private inline fun runProbeStep(step: String, target: Path, action: () -> Unit): Boolean = try { - action() - true - } catch (e: IOException) { - Log_OC.w(TAG, "isFolderWritable: cannot $step $target: ${e.describe()} [${describeStorage(target)}]") - false - } catch (e: SecurityException) { - Log_OC.w(TAG, "isFolderWritable: cannot $step $target: ${e.describe()} [${describeStorage(target)}]") - false - } - private fun removeLeftovers(vararg paths: Path) { paths.forEach { path -> - try { + runCatchingLogged("delete if exists", path) { Files.deleteIfExists(path) - } catch (e: IOException) { - Log_OC.e(TAG, "isFolderWritable: leftover write probe could not be removed: $path: ${e.describe()}") - } catch (e: SecurityException) { - Log_OC.e(TAG, "isFolderWritable: leftover write probe could not be removed: $path: ${e.describe()}") } } } - private fun Exception.describe(): String { - val reason = (this as? FileSystemException)?.reason - return listOfNotNull(javaClass.simpleName, reason ?: message).joinToString(": ") - } - - private fun describeStorage(path: Path): String { - val file = path.toFile() - val removable = try { - Environment.isExternalStorageRemovable(file).toString() - } catch (_: IllegalArgumentException) { - "not shared storage" - } - val allFilesAccess = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager().toString() - } else { - "not applicable" - } - val legacyStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Environment.isExternalStorageLegacy().toString() - } else { - "not applicable" - } - - return "api=${Build.VERSION.SDK_INT}, state=${Environment.getExternalStorageState(file)}, " + - "removable=$removable, allFilesAccess=$allFilesAccess, legacyStorage=$legacyStorage" + private inline fun runCatchingLogged(step: String, target: Path, action: () -> Unit): Boolean = try { + action() + true + } catch (e: Exception) { + Log_OC.e(TAG, "isFolderWritable: cannot $step $target: $e") + false } }