diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt index 6c27e85983bd..e59f6e10fe7b 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileUtilTest.kt @@ -1,61 +1,100 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.owncloud.android.utils import com.owncloud.android.AbstractIT -import org.junit.Assert +import androidx.test.platform.app.InstrumentationRegistry +import com.owncloud.android.utils.FileUtil.isFolderWritable +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test import java.io.File class FileUtilTest : AbstractIT() { - @Test - fun assertNullInput() { - Assert.assertEquals("", FileUtil.getFilenameFromPathString(null)) + + private lateinit var context: android.content.Context + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext } @Test - fun assertEmptyInput() { - Assert.assertEquals("", FileUtil.getFilenameFromPathString("")) + fun testIsFolderWritableWhenGivenCacheDirShouldReturnTrue() = runBlocking { + val writableDir = context.cacheDir + val result = isFolderWritable(writableDir) + assertTrue("Internal cache directory should be writable", result) } @Test - fun assertFileInput() { - val file = getDummyFile("empty.txt") - Assert.assertEquals("empty.txt", FileUtil.getFilenameFromPathString(file.absolutePath)) + fun testIsFolderWritableWhenGivenNonExistentDirShouldReturnFalse() = runBlocking { + val nonExistentFile = File(context.cacheDir, "ghost_folder_123") + val result = isFolderWritable(nonExistentFile) + assertFalse("Non-existent folder should not be writable", result) } @Test - fun assertSlashInput() { - val tempPath = File(FileStorageUtils.getTemporalPath(account.name) + File.pathSeparator + "folder") - if (!tempPath.exists()) { - Assert.assertTrue(tempPath.mkdirs()) - } - Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) + fun testIsFolderWritableWhenGivenFileShouldReturnFalse() = runBlocking { + val regularFile = File(context.cacheDir, "test_file.txt") + regularFile.createNewFile() + val result = isFolderWritable(regularFile) + assertFalse("A regular file should not be treated as a writable folder", result) } @Test - fun assertDotFileInput() { - val file = getDummyFile(".dotfile.ext") - Assert.assertEquals(".dotfile.ext", FileUtil.getFilenameFromPathString(file.absolutePath)) + fun testIsFolderWritableWhenGivenNullShouldReturnFalse() = runBlocking { + val result = isFolderWritable(null) + assertFalse("Null input should return false", result) } @Test - fun assertFolderInput() { - val tempPath = File(FileStorageUtils.getTemporalPath(account.name)) - if (!tempPath.exists()) { - Assert.assertTrue(tempPath.mkdirs()) + fun testIsFolderWritableWhenGivenReadOnlyDirShouldReturnFalse() = runBlocking { + val readOnlyDir = File(context.cacheDir, "readonly_test") + readOnlyDir.mkdir() + + try { + readOnlyDir.setReadOnly() + val result = isFolderWritable(readOnlyDir) + assertFalse("Read-only directory should return false", result) + } finally { + readOnlyDir.setWritable(true) + readOnlyDir.delete() } + } - Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath)) + @Test + fun testIsFolderWritableWhenGivenNestedStructureShouldReturnTrue() = runBlocking { + val rootDir = File(context.cacheDir, "test_root") + rootDir.mkdir() + + try { + val result = isFolderWritable(rootDir) + assertTrue("Should be able to create and delete nested temp structures", result) + val children = rootDir.list() + assertTrue("Temp directory should have been cleaned up", children == null || children.isEmpty()) + } finally { + rootDir.delete() + } } @Test - fun assertNoFileExtensionInput() { - val file = getDummyFile("file") - Assert.assertEquals("file", FileUtil.getFilenameFromPathString(file.absolutePath)) + fun testIsFolderWritableWhenGivenReadonlyNestedStructureShouldReturnFalse() = runBlocking { + val readOnlyDir = File(context.cacheDir, "locked_dir") + readOnlyDir.mkdir() + readOnlyDir.setReadOnly() + + try { + val result = isFolderWritable(readOnlyDir) + assertFalse("Should return false if temp folder creation fails", result) + } finally { + readOnlyDir.setWritable(true) + readOnlyDir.delete() + } } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java index 01cd186d83f6..5dab3516d444 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java @@ -51,7 +51,7 @@ import com.owncloud.android.ui.fragment.ExtendedListFragment; import com.owncloud.android.ui.fragment.LocalFileListFragment; import com.owncloud.android.utils.FileSortOrder; -import com.owncloud.android.utils.FileStorageUtils; +import com.owncloud.android.utils.FileUtil; import com.owncloud.android.utils.PermissionUtil; import java.io.File; @@ -69,6 +69,8 @@ import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; import static com.owncloud.android.ui.activity.FileActivity.EXTRA_USER; @@ -596,23 +598,24 @@ public void onDirectoryClick(File directory) { } private void checkWritableFolder(File folder) { - boolean canWriteIntoFolder = FileStorageUtils.isFolderWritable(folder); - - binding.uploadFilesSpinnerBehaviour.setEnabled(canWriteIntoFolder); + FileUtil.INSTANCE.isFolderWritable(folder, getLifecycle(), canWriteIntoFolder -> { + binding.uploadFilesSpinnerBehaviour.setEnabled(canWriteIntoFolder); - TextView textView = findViewById(R.id.upload_files_upload_files_behaviour_text); + TextView textView = findViewById(R.id.upload_files_upload_files_behaviour_text); - if (canWriteIntoFolder) { - textView.setText(getString(R.string.uploader_upload_files_behaviour)); - int localBehaviour = preferences.getUploaderBehaviour(); - binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour); - } else { - binding.uploadFilesSpinnerBehaviour.setSelection(1); - textView.setText(new StringBuilder().append(getString(R.string.uploader_upload_files_behaviour)) - .append(' ') - .append(getString(R.string.uploader_upload_files_behaviour_not_writable)) - .toString()); - } + if (canWriteIntoFolder) { + textView.setText(getString(R.string.uploader_upload_files_behaviour)); + int localBehaviour = preferences.getUploaderBehaviour(); + binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour); + } else { + binding.uploadFilesSpinnerBehaviour.setSelection(1); + textView.setText(new StringBuilder().append(getString(R.string.uploader_upload_files_behaviour)) + .append(' ') + .append(getString(R.string.uploader_upload_files_behaviour_not_writable)) + .toString()); + } + return Unit.INSTANCE; + }); } /** diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt index 6656d0beb144..618bd7ab9033 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt @@ -19,6 +19,7 @@ import android.view.View import android.widget.AdapterView import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.client.di.Injectable import com.nextcloud.client.preferences.SubFolderRule @@ -35,7 +36,11 @@ import com.owncloud.android.ui.activity.UploadFilesActivity import com.owncloud.android.ui.dialog.parcel.SyncedFolderParcelable import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.FileUtil import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import javax.inject.Inject @@ -279,20 +284,29 @@ class SyncedFolderPreferencesDialogFragment : binding?.settingInstantBehaviourContainer?.alpha = ALPHA_DISABLED return } - if (syncedFolder!!.localPath != null && - FileStorageUtils.isFolderWritable(File(syncedFolder!!.localPath)) - ) { - binding?.settingInstantBehaviourContainer?.isEnabled = true - binding?.settingInstantBehaviourContainer?.alpha = ALPHA_ENABLED - binding?.settingInstantBehaviourSummary?.text = - uploadBehaviorItemStrings[syncedFolder!!.uploadActionInteger] - } else { - binding?.settingInstantBehaviourContainer?.isEnabled = false - binding?.settingInstantBehaviourContainer?.alpha = ALPHA_DISABLED - syncedFolder?.setUploadAction( - resources.getTextArray(R.array.pref_behaviour_entryValues)[0].toString() - ) - binding?.settingInstantBehaviourSummary?.setText(R.string.auto_upload_file_behaviour_kept_in_folder) + + val folderFile = syncedFolder?.localPath?.let { File(it) } + lifecycleScope.launch { + val writable = FileUtil.isFolderWritable(folderFile) + withContext(Dispatchers.Main) { + binding?.settingInstantBehaviourContainer?.run { + if (writable) { + isEnabled = true + alpha = ALPHA_ENABLED + binding?.settingInstantBehaviourSummary?.text = + uploadBehaviorItemStrings[syncedFolder!!.uploadActionInteger] + } else { + isEnabled = false + alpha = ALPHA_DISABLED + syncedFolder?.setUploadAction( + resources.getTextArray(R.array.pref_behaviour_entryValues)[0].toString() + ) + binding?.settingInstantBehaviourSummary?.setText( + R.string.auto_upload_file_behaviour_kept_in_folder + ) + } + } + } } } diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index c31e21a6a48d..dd0240234c5e 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java @@ -791,16 +791,6 @@ public static boolean checkIfEnoughSpace(OCFile file) { return checkIfEnoughSpace(availableSpaceOnDevice, file); } - public static boolean isFolderWritable(File folder) { - File[] children = folder.listFiles(); - - if (children != null && children.length > 0) { - return children[0].canWrite(); - } else { - return folder.canWrite(); - } - } - @VisibleForTesting public static boolean checkIfEnoughSpace(long availableSpaceOnDevice, OCFile file) { if (file.isFolder()) { diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.java b/app/src/main/java/com/owncloud/android/utils/FileUtil.java deleted file mode 100644 index d41411db69e3..000000000000 --- a/app/src/main/java/com/owncloud/android/utils/FileUtil.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.utils; - -import android.text.TextUtils; - -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.concurrent.TimeUnit; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public final class FileUtil { - - private FileUtil() { - // utility class -> private constructor - } - - /** - * returns the file name of a given path. - * - * @param filePath (absolute) file path - * @return the filename including its file extension, empty String for invalid input values - */ - public static @NonNull - String getFilenameFromPathString(@Nullable String filePath) { - if (!TextUtils.isEmpty(filePath)) { - File file = new File(filePath); - if (file.isFile()) { - return file.getName(); - } else { - return ""; - } - } else { - return ""; - } - } - - public static @Nullable - Long getCreationTimestamp(File file) { - try { - return Files.readAttributes(file.toPath(), BasicFileAttributes.class) - .creationTime() - .to(TimeUnit.SECONDS); - } catch (IOException e) { - Log_OC.e(UploadFileRemoteOperation.class.getSimpleName(), - "Failed to read creation timestamp for file: " + file.getName()); - return null; - } - } -} diff --git a/app/src/main/java/com/owncloud/android/utils/FileUtil.kt b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt new file mode 100644 index 000000000000..125543a989bf --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/FileUtil.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.TimeUnit + +object FileUtil { + private const val TAG = "FileUtil" + + @JvmStatic + fun getCreationTimestamp(file: File): Long? { + try { + return Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) + .creationTime() + .to(TimeUnit.SECONDS) + } catch (_: IOException) { + Log_OC.e( + TAG, + "failed to read creation timestamp for file: " + file.getName() + ) + return null + } + } + + fun isFolderWritable(folder: File?, lifecycle: Lifecycle, onCompleted: (Boolean) -> Unit) { + lifecycle.coroutineScope.launch { + val result = isFolderWritable(folder) + withContext(Dispatchers.Main) { + onCompleted(result) + } + } + } + + suspend fun isFolderWritable(folder: File?): Boolean = withContext(Dispatchers.IO) { + if (folder == null || !folder.isDirectory() || !folder.canWrite()) { + return@withContext false + } + + return@withContext try { + val tempDir = File(folder, ".test_write_dir_${System.currentTimeMillis()}") + if (!tempDir.mkdir()) return@withContext false + + val tempFile = File(tempDir, "test_file") + val created = tempFile.createNewFile() + + if (created) tempFile.delete() + tempDir.delete() + + created + } catch (_: Exception) { + false + } + } +}