diff --git a/logcat.txt b/logcat.txt new file mode 100644 index 000000000..74d61e310 Binary files /dev/null and b/logcat.txt differ diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 0d6ba96aa..bd2c11fef 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ API >= 23; the app needs to handle this --> + + Download all files + Download all files from your cloud for offline access (requires significant storage) + Download Everything + This will download ALL files from your cloud. This may use significant storage space and bandwidth. Continue? + + + Auto-sync local changes + Automatically upload changes to locally modified files + Auto-Sync + Local file changes will be automatically synced to the cloud. This requires a stable network connection. Continue? + + + Prefer local version on conflict + When a file is modified both locally and on server, upload local version instead of creating a conflicted copy + diff --git a/opencloudApp/src/main/res/xml/settings_security.xml b/opencloudApp/src/main/res/xml/settings_security.xml index 91c72bb0d..3e2888145 100644 --- a/opencloudApp/src/main/res/xml/settings_security.xml +++ b/opencloudApp/src/main/res/xml/settings_security.xml @@ -49,4 +49,25 @@ app:summary="@string/prefs_touches_with_other_visible_windows_summary" app:title="@string/prefs_touches_with_other_visible_windows" /> + + + + + + + + + \ No newline at end of file diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt index 005aa600a..db0c44584 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/DownloadRemoteFileOperation.kt @@ -77,7 +77,23 @@ class DownloadRemoteFileOperation( // perform the download return try { - tmpFile.parentFile?.mkdirs() + val parent = tmpFile.parentFile + if (parent != null && !parent.exists()) { + if (!parent.mkdirs() && !parent.exists()) { + Timber.w("Failed to mkdirs for %s, checking for blocking files", parent.absolutePath) + var current = parent + while (current != null && !current.exists()) { + current = current.parentFile + } + if (current != null && current.isFile) { + Timber.w("Deleting blocking file: %s", current.absolutePath) + current.delete() + } + if (!parent.mkdirs() && !parent.exists()) { + throw java.io.IOException("Could not create parent directory: " + parent.absolutePath) + } + } + } downloadFile(client, tmpFile).also { Timber.i("Download of $remotePath to $tmpPath - HTTP status code: $status") } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt index 887eb3fcd..6f0bfd4b1 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/GetRemoteUserAvatarOperation.kt @@ -41,7 +41,8 @@ import java.net.URL * @author David A. Velasco * @author David González Verdugo */ -class GetRemoteUserAvatarOperation : RemoteOperation() { +@Suppress("UnusedPrivateProperty") +class GetRemoteUserAvatarOperation(private val avatarDimension: Int) : RemoteOperation() { override fun run(client: OpenCloudClient): RemoteOperationResult { var inputStream: InputStream? = null var result: RemoteOperationResult diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt index 8481f9167..d50095817 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/users/services/implementation/OCUserService.kt @@ -43,6 +43,6 @@ class OCUserService(override val client: OpenCloudClient) : UserService { GetRemoteUserQuotaOperation().execute(client) override fun getUserAvatar(avatarDimension: Int): RemoteOperationResult = - GetRemoteUserAvatarOperation().execute(client) + GetRemoteUserAvatarOperation(avatarDimension).execute(client) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt b/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt index c41846fa6..fd3545dfe 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/providers/LocalStorageProvider.kt @@ -49,7 +49,10 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { /** * Get local storage path for accountName. */ - private fun getAccountDirectoryPath( + /** + * Get local storage path for accountName. + */ + protected open fun getAccountDirectoryPath( accountName: String ): String = getRootFolderPath() + File.separator + getEncodedAccountName(accountName) @@ -62,9 +65,15 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { accountName: String, remotePath: String, spaceId: String?, + spaceName: String? = null, ): String = if (spaceId != null) { - getAccountDirectoryPath(accountName) + File.separator + spaceId + File.separator + remotePath + val spaceFolder = if (!spaceName.isNullOrBlank()) { + spaceName.replace("/", "_").replace("\\", "_").replace(":", "_") + } else { + spaceId + } + getAccountDirectoryPath(accountName) + File.separator + spaceFolder + File.separator + remotePath } else { getAccountDirectoryPath(accountName) + remotePath } @@ -231,7 +240,16 @@ sealed class LocalStorageProvider(private val rootFolderName: String) { val targetFile = File(finalStoragePath) val targetFolder = targetFile.parentFile if (targetFolder != null && !targetFolder.exists()) { - targetFolder.mkdirs() + if (!targetFolder.mkdirs() && !targetFolder.exists()) { + var current = targetFolder + while (current != null && !current.exists()) { + current = current.parentFile + } + if (current != null && current.isFile) { + current.delete() + } + targetFolder.mkdirs() + } } fileToMove.renameTo(targetFile) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt index a9a4c996c..3464bcc49 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/providers/ScopedStorageProvider.kt @@ -20,12 +20,50 @@ package eu.opencloud.android.data.providers import android.content.Context +import android.net.Uri +import android.os.Environment +import timber.log.Timber import java.io.File +@Suppress("UnusedPrivateProperty") class ScopedStorageProvider( rootFolderName: String, private val context: Context ) : LocalStorageProvider(rootFolderName) { - override fun getPrimaryStorageDirectory(): File = context.filesDir + override fun getPrimaryStorageDirectory(): File = Environment.getExternalStorageDirectory() + + override fun getAccountDirectoryPath(accountName: String): String { + val sanitizedName = accountName.replace("/", "_").replace("\\", "_").replace(":", "_") + val newPath = getRootFolderPath() + File.separator + sanitizedName + + val oldEncodedName = Uri.encode(accountName, "@") + val oldPath = getRootFolderPath() + File.separator + oldEncodedName + + if (oldPath != newPath) { + val oldDir = File(oldPath) + val newDir = File(newPath) + + // If old encoded directory exists, but the new readable one doesn't, migrate it! + if (oldDir.exists() && oldDir.isDirectory && !newDir.exists()) { + try { + if (oldDir.renameTo(newDir)) { + Timber.i("Successfully migrated account directory from $oldEncodedName to readable name $sanitizedName") + return newPath + } else { + return oldPath // Fallback if rename fails + } + } catch (e: Exception) { + Timber.e(e, "Failed to migrate account directory to readable name") + return oldPath + } + } else if (oldDir.exists() && oldDir.isDirectory && newDir.exists()) { + // If both exist, we should probably stick to the new one or the old one. Let's use old one to not lose files + // that haven't been migrated yet. + return oldPath + } + } + + return newPath + } } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt index d01ab0ae0..99dc87200 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/providers/ScopedStorageProviderTest.kt @@ -2,13 +2,17 @@ package eu.opencloud.android.data.providers import android.content.Context import android.net.Uri +import android.os.Environment import eu.opencloud.android.domain.transfers.model.OCTransfer import eu.opencloud.android.testutil.OC_FILE import eu.opencloud.android.testutil.OC_SPACE_PROJECT_WITH_IMAGE import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.spyk import io.mockk.verify +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -43,17 +47,25 @@ class ScopedStorageProviderTest { File(this, "child.bin").writeBytes(ByteArray(expectedSizeOfDirectoryValue.toInt())) } - scopedStorageProvider = ScopedStorageProvider(rootFolderName, context) + mockkStatic(Environment::class) + every { Environment.getExternalStorageDirectory() } returns filesDir + + scopedStorageProvider = spyk(ScopedStorageProvider(rootFolderName, context)) every { context.filesDir } returns filesDir } + @After + fun tearDown() { + unmockkAll() + } + @Test fun `getPrimaryStorageDirectory returns filesDir`() { val result = scopedStorageProvider.getPrimaryStorageDirectory() assertEquals(filesDir, result) verify(exactly = 1) { - context.filesDir + Environment.getExternalStorageDirectory() } } @@ -74,7 +86,9 @@ class ScopedStorageProviderTest { mockkStatic(Uri::class) every { Uri.encode(accountName, "@") } returns uriEncoded - val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + uriEncoded + // Since old directory doesn't exist, it should use the new sanitized account name "opencloud" + val expectedSanitizedName = accountName.replace("/", "_").replace("\\", "_").replace(":", "_") + val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + expectedSanitizedName val expectedPath = accountDirectoryPath + File.separator + spaceId + File.separator + remotePath val actualPath = scopedStorageProvider.getDefaultSavePathFor(accountName, remotePath, spaceId) @@ -92,7 +106,9 @@ class ScopedStorageProviderTest { mockkStatic(Uri::class) every { Uri.encode(accountName, "@") } returns uriEncoded - val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + uriEncoded + // Since old directory doesn't exist, it should use the new sanitized account name "opencloud" + val expectedSanitizedName = accountName.replace("/", "_").replace("\\", "_").replace(":", "_") + val accountDirectoryPath = filesDir.absolutePath + File.separator + rootFolderName + File.separator + expectedSanitizedName val expectedPath = accountDirectoryPath + remotePath val actualPath = scopedStorageProvider.getDefaultSavePathFor(accountName, remotePath, spaceId)