Skip to content

Commit 7dacbad

Browse files
Merge pull request #16809 from nextcloud/fix/anr-folder-writable
fix(util): is folder writable anr
2 parents dfbf33c + 5924657 commit 7dacbad

6 files changed

Lines changed: 182 additions & 129 deletions

File tree

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,100 @@
11
/*
22
* Nextcloud - Android Client
33
*
4-
* SPDX-FileCopyrightText: 2020 Andy Scherzinger <info@andy-scherzinger.de>
5-
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
66
*/
77
package com.owncloud.android.utils
88

99
import com.owncloud.android.AbstractIT
10-
import org.junit.Assert
10+
import androidx.test.platform.app.InstrumentationRegistry
11+
import com.owncloud.android.utils.FileUtil.isFolderWritable
12+
import kotlinx.coroutines.runBlocking
13+
import org.junit.Assert.assertFalse
14+
import org.junit.Assert.assertTrue
15+
import org.junit.Before
1116
import org.junit.Test
1217
import java.io.File
1318

1419
class FileUtilTest : AbstractIT() {
15-
@Test
16-
fun assertNullInput() {
17-
Assert.assertEquals("", FileUtil.getFilenameFromPathString(null))
20+
21+
private lateinit var context: android.content.Context
22+
23+
@Before
24+
fun setup() {
25+
context = InstrumentationRegistry.getInstrumentation().targetContext
1826
}
1927

2028
@Test
21-
fun assertEmptyInput() {
22-
Assert.assertEquals("", FileUtil.getFilenameFromPathString(""))
29+
fun testIsFolderWritableWhenGivenCacheDirShouldReturnTrue() = runBlocking {
30+
val writableDir = context.cacheDir
31+
val result = isFolderWritable(writableDir)
32+
assertTrue("Internal cache directory should be writable", result)
2333
}
2434

2535
@Test
26-
fun assertFileInput() {
27-
val file = getDummyFile("empty.txt")
28-
Assert.assertEquals("empty.txt", FileUtil.getFilenameFromPathString(file.absolutePath))
36+
fun testIsFolderWritableWhenGivenNonExistentDirShouldReturnFalse() = runBlocking {
37+
val nonExistentFile = File(context.cacheDir, "ghost_folder_123")
38+
val result = isFolderWritable(nonExistentFile)
39+
assertFalse("Non-existent folder should not be writable", result)
2940
}
3041

3142
@Test
32-
fun assertSlashInput() {
33-
val tempPath = File(FileStorageUtils.getTemporalPath(account.name) + File.pathSeparator + "folder")
34-
if (!tempPath.exists()) {
35-
Assert.assertTrue(tempPath.mkdirs())
36-
}
37-
Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath))
43+
fun testIsFolderWritableWhenGivenFileShouldReturnFalse() = runBlocking {
44+
val regularFile = File(context.cacheDir, "test_file.txt")
45+
regularFile.createNewFile()
46+
val result = isFolderWritable(regularFile)
47+
assertFalse("A regular file should not be treated as a writable folder", result)
3848
}
3949

4050
@Test
41-
fun assertDotFileInput() {
42-
val file = getDummyFile(".dotfile.ext")
43-
Assert.assertEquals(".dotfile.ext", FileUtil.getFilenameFromPathString(file.absolutePath))
51+
fun testIsFolderWritableWhenGivenNullShouldReturnFalse() = runBlocking {
52+
val result = isFolderWritable(null)
53+
assertFalse("Null input should return false", result)
4454
}
4555

4656
@Test
47-
fun assertFolderInput() {
48-
val tempPath = File(FileStorageUtils.getTemporalPath(account.name))
49-
if (!tempPath.exists()) {
50-
Assert.assertTrue(tempPath.mkdirs())
57+
fun testIsFolderWritableWhenGivenReadOnlyDirShouldReturnFalse() = runBlocking {
58+
val readOnlyDir = File(context.cacheDir, "readonly_test")
59+
readOnlyDir.mkdir()
60+
61+
try {
62+
readOnlyDir.setReadOnly()
63+
val result = isFolderWritable(readOnlyDir)
64+
assertFalse("Read-only directory should return false", result)
65+
} finally {
66+
readOnlyDir.setWritable(true)
67+
readOnlyDir.delete()
5168
}
69+
}
5270

53-
Assert.assertEquals("", FileUtil.getFilenameFromPathString(tempPath.absolutePath))
71+
@Test
72+
fun testIsFolderWritableWhenGivenNestedStructureShouldReturnTrue() = runBlocking {
73+
val rootDir = File(context.cacheDir, "test_root")
74+
rootDir.mkdir()
75+
76+
try {
77+
val result = isFolderWritable(rootDir)
78+
assertTrue("Should be able to create and delete nested temp structures", result)
79+
val children = rootDir.list()
80+
assertTrue("Temp directory should have been cleaned up", children == null || children.isEmpty())
81+
} finally {
82+
rootDir.delete()
83+
}
5484
}
5585

5686
@Test
57-
fun assertNoFileExtensionInput() {
58-
val file = getDummyFile("file")
59-
Assert.assertEquals("file", FileUtil.getFilenameFromPathString(file.absolutePath))
87+
fun testIsFolderWritableWhenGivenReadonlyNestedStructureShouldReturnFalse() = runBlocking {
88+
val readOnlyDir = File(context.cacheDir, "locked_dir")
89+
readOnlyDir.mkdir()
90+
readOnlyDir.setReadOnly()
91+
92+
try {
93+
val result = isFolderWritable(readOnlyDir)
94+
assertFalse("Should return false if temp folder creation fails", result)
95+
} finally {
96+
readOnlyDir.setWritable(true)
97+
readOnlyDir.delete()
98+
}
6099
}
61100
}

app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
import com.owncloud.android.ui.fragment.ExtendedListFragment;
5252
import com.owncloud.android.ui.fragment.LocalFileListFragment;
5353
import com.owncloud.android.utils.FileSortOrder;
54-
import com.owncloud.android.utils.FileStorageUtils;
54+
import com.owncloud.android.utils.FileUtil;
5555
import com.owncloud.android.utils.PermissionUtil;
5656

5757
import java.io.File;
@@ -69,6 +69,8 @@
6969
import androidx.fragment.app.DialogFragment;
7070
import androidx.fragment.app.FragmentManager;
7171
import androidx.fragment.app.FragmentTransaction;
72+
import kotlin.Unit;
73+
import kotlin.jvm.functions.Function1;
7274

7375
import static com.owncloud.android.ui.activity.FileActivity.EXTRA_USER;
7476

@@ -596,23 +598,24 @@ public void onDirectoryClick(File directory) {
596598
}
597599

598600
private void checkWritableFolder(File folder) {
599-
boolean canWriteIntoFolder = FileStorageUtils.isFolderWritable(folder);
600-
601-
binding.uploadFilesSpinnerBehaviour.setEnabled(canWriteIntoFolder);
601+
FileUtil.INSTANCE.isFolderWritable(folder, getLifecycle(), canWriteIntoFolder -> {
602+
binding.uploadFilesSpinnerBehaviour.setEnabled(canWriteIntoFolder);
602603

603-
TextView textView = findViewById(R.id.upload_files_upload_files_behaviour_text);
604+
TextView textView = findViewById(R.id.upload_files_upload_files_behaviour_text);
604605

605-
if (canWriteIntoFolder) {
606-
textView.setText(getString(R.string.uploader_upload_files_behaviour));
607-
int localBehaviour = preferences.getUploaderBehaviour();
608-
binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour);
609-
} else {
610-
binding.uploadFilesSpinnerBehaviour.setSelection(1);
611-
textView.setText(new StringBuilder().append(getString(R.string.uploader_upload_files_behaviour))
612-
.append(' ')
613-
.append(getString(R.string.uploader_upload_files_behaviour_not_writable))
614-
.toString());
615-
}
606+
if (canWriteIntoFolder) {
607+
textView.setText(getString(R.string.uploader_upload_files_behaviour));
608+
int localBehaviour = preferences.getUploaderBehaviour();
609+
binding.uploadFilesSpinnerBehaviour.setSelection(localBehaviour);
610+
} else {
611+
binding.uploadFilesSpinnerBehaviour.setSelection(1);
612+
textView.setText(new StringBuilder().append(getString(R.string.uploader_upload_files_behaviour))
613+
.append(' ')
614+
.append(getString(R.string.uploader_upload_files_behaviour_not_writable))
615+
.toString());
616+
}
617+
return Unit.INSTANCE;
618+
});
616619
}
617620

618621
/**

app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.view.View
1919
import android.widget.AdapterView
2020
import androidx.appcompat.app.AlertDialog
2121
import androidx.fragment.app.DialogFragment
22+
import androidx.lifecycle.lifecycleScope
2223
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2324
import com.nextcloud.client.di.Injectable
2425
import com.nextcloud.client.preferences.SubFolderRule
@@ -35,7 +36,11 @@ import com.owncloud.android.ui.activity.UploadFilesActivity
3536
import com.owncloud.android.ui.dialog.parcel.SyncedFolderParcelable
3637
import com.owncloud.android.utils.DisplayUtils
3738
import com.owncloud.android.utils.FileStorageUtils
39+
import com.owncloud.android.utils.FileUtil
3840
import com.owncloud.android.utils.theme.ViewThemeUtils
41+
import kotlinx.coroutines.Dispatchers
42+
import kotlinx.coroutines.launch
43+
import kotlinx.coroutines.withContext
3944
import java.io.File
4045
import javax.inject.Inject
4146

@@ -279,20 +284,29 @@ class SyncedFolderPreferencesDialogFragment :
279284
binding?.settingInstantBehaviourContainer?.alpha = ALPHA_DISABLED
280285
return
281286
}
282-
if (syncedFolder!!.localPath != null &&
283-
FileStorageUtils.isFolderWritable(File(syncedFolder!!.localPath))
284-
) {
285-
binding?.settingInstantBehaviourContainer?.isEnabled = true
286-
binding?.settingInstantBehaviourContainer?.alpha = ALPHA_ENABLED
287-
binding?.settingInstantBehaviourSummary?.text =
288-
uploadBehaviorItemStrings[syncedFolder!!.uploadActionInteger]
289-
} else {
290-
binding?.settingInstantBehaviourContainer?.isEnabled = false
291-
binding?.settingInstantBehaviourContainer?.alpha = ALPHA_DISABLED
292-
syncedFolder?.setUploadAction(
293-
resources.getTextArray(R.array.pref_behaviour_entryValues)[0].toString()
294-
)
295-
binding?.settingInstantBehaviourSummary?.setText(R.string.auto_upload_file_behaviour_kept_in_folder)
287+
288+
val folderFile = syncedFolder?.localPath?.let { File(it) }
289+
lifecycleScope.launch {
290+
val writable = FileUtil.isFolderWritable(folderFile)
291+
withContext(Dispatchers.Main) {
292+
binding?.settingInstantBehaviourContainer?.run {
293+
if (writable) {
294+
isEnabled = true
295+
alpha = ALPHA_ENABLED
296+
binding?.settingInstantBehaviourSummary?.text =
297+
uploadBehaviorItemStrings[syncedFolder!!.uploadActionInteger]
298+
} else {
299+
isEnabled = false
300+
alpha = ALPHA_DISABLED
301+
syncedFolder?.setUploadAction(
302+
resources.getTextArray(R.array.pref_behaviour_entryValues)[0].toString()
303+
)
304+
binding?.settingInstantBehaviourSummary?.setText(
305+
R.string.auto_upload_file_behaviour_kept_in_folder
306+
)
307+
}
308+
}
309+
}
296310
}
297311
}
298312

app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -791,16 +791,6 @@ public static boolean checkIfEnoughSpace(OCFile file) {
791791
return checkIfEnoughSpace(availableSpaceOnDevice, file);
792792
}
793793

794-
public static boolean isFolderWritable(File folder) {
795-
File[] children = folder.listFiles();
796-
797-
if (children != null && children.length > 0) {
798-
return children[0].canWrite();
799-
} else {
800-
return folder.canWrite();
801-
}
802-
}
803-
804794
@VisibleForTesting
805795
public static boolean checkIfEnoughSpace(long availableSpaceOnDevice, OCFile file) {
806796
if (file.isFolder()) {

app/src/main/java/com/owncloud/android/utils/FileUtil.java

Lines changed: 0 additions & 61 deletions
This file was deleted.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.owncloud.android.utils
8+
9+
import androidx.lifecycle.Lifecycle
10+
import androidx.lifecycle.coroutineScope
11+
import com.owncloud.android.lib.common.utils.Log_OC
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.withContext
15+
import java.io.File
16+
import java.io.IOException
17+
import java.nio.file.Files
18+
import java.nio.file.attribute.BasicFileAttributes
19+
import java.util.concurrent.TimeUnit
20+
21+
object FileUtil {
22+
private const val TAG = "FileUtil"
23+
24+
@JvmStatic
25+
fun getCreationTimestamp(file: File): Long? {
26+
try {
27+
return Files.readAttributes(file.toPath(), BasicFileAttributes::class.java)
28+
.creationTime()
29+
.to(TimeUnit.SECONDS)
30+
} catch (_: IOException) {
31+
Log_OC.e(
32+
TAG,
33+
"failed to read creation timestamp for file: " + file.getName()
34+
)
35+
return null
36+
}
37+
}
38+
39+
fun isFolderWritable(folder: File?, lifecycle: Lifecycle, onCompleted: (Boolean) -> Unit) {
40+
lifecycle.coroutineScope.launch {
41+
val result = isFolderWritable(folder)
42+
withContext(Dispatchers.Main) {
43+
onCompleted(result)
44+
}
45+
}
46+
}
47+
48+
suspend fun isFolderWritable(folder: File?): Boolean = withContext(Dispatchers.IO) {
49+
if (folder == null || !folder.isDirectory() || !folder.canWrite()) {
50+
return@withContext false
51+
}
52+
53+
return@withContext try {
54+
val tempDir = File(folder, ".test_write_dir_${System.currentTimeMillis()}")
55+
if (!tempDir.mkdir()) return@withContext false
56+
57+
val tempFile = File(tempDir, "test_file")
58+
val created = tempFile.createNewFile()
59+
60+
if (created) tempFile.delete()
61+
tempDir.delete()
62+
63+
created
64+
} catch (_: Exception) {
65+
false
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)