Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 OpenCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package eu.opencloud.android.workers

internal object ContentUriUploadCacheValidator {
fun isValidCacheSize(
actualSize: Long,
expectedSize: Long,
): Boolean =
actualSize > 0 && (expectedSize <= 0 || actualSize == expectedSize)
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,37 @@ class UploadFileFromContentUriWorker(
cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) +
File.separator + flatCacheName

// Re-copy if the cache file is missing or empty. A previous run may have copied it
// and then had it removed (e.g. by removeCacheFile() at the end of a successful run
// that the OS killed before bookkeeping). Only the contentUri from worker params is
// authoritative.
val cacheFile = File(cachePath)
if (!cacheFile.exists() || cacheFile.length() == 0L) {
if (!isCacheFileReadyForUpload(cacheFile)) {
checkDocumentFileExists()
checkPermissionsToReadDocumentAreGranted()
copyFileToLocalStorage()
}
}

private fun isCacheFileReadyForUpload(cacheFile: File): Boolean {
if (!cacheFile.exists()) return false

val cacheSize = cacheFile.length()
val isValidCacheSize = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = cacheSize,
expectedSize = ocTransfer.fileSize,
)
if (isValidCacheSize) return true

Timber.w(
"Cached upload file for %s has invalid size. expected=%d actual=%d. Deleting and recopying.",
contentUri,
ocTransfer.fileSize,
cacheSize,
)
if (!cacheFile.delete()) {
Timber.w("Could not delete invalid cached upload file: %s", cacheFile.absolutePath)
}
clearTusState()
return false
}

private fun areParametersValid(): Boolean {
val paramAccountName = workerParameters.inputData.getString(KEY_PARAM_ACCOUNT_NAME)
val paramUploadPath = workerParameters.inputData.getString(KEY_PARAM_UPLOAD_PATH)
Expand Down Expand Up @@ -208,11 +227,14 @@ class UploadFileFromContentUriWorker(
private fun copyFileToLocalStorage() {
val documentFile = DocumentFile.fromSingleUri(appContext, contentUri)
val cacheFile = File(cachePath)
val partFile = File("$cachePath.part")
val cacheDir = cacheFile.parentFile
if (cacheDir != null && !cacheDir.exists()) {
cacheDir.mkdirs()
}
cacheFile.createNewFile()
if (partFile.exists() && !partFile.delete()) {
Timber.w("Could not delete stale partial cache file: %s", partFile.absolutePath)
}

// openInputStream can return null if the content provider is unavailable or permissions were revoked.
// Failing here avoids silently uploading a 0-byte file.
Expand All @@ -221,18 +243,57 @@ class UploadFileFromContentUriWorker(
Timber.e("Failed to open input stream for %s — content provider unavailable or permissions revoked", contentUri)
throw LocalFileNotFoundException()
}
val outputStream = FileOutputStream(cachePath)
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
try {
val outputStream = FileOutputStream(partFile)
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
} catch (throwable: Throwable) {
partFile.delete()
throw throwable
}

// Guard against a truncated or empty copy (e.g. file deleted mid-read).
if (cacheFile.length() == 0L) {
Timber.e("Cache file is 0 bytes after copy from %s — source may have been deleted mid-read", contentUri)
val copiedSize = partFile.length()
if (!ContentUriUploadCacheValidator.isValidCacheSize(copiedSize, ocTransfer.fileSize)) {
Timber.e(
"Partial cache copy from %s. expected=%d actual=%d",
contentUri,
ocTransfer.fileSize,
copiedSize,
)
partFile.delete()
clearTusState()
throw IOException(
"Cache copy size mismatch for $contentUri: " +
"expected ${ocTransfer.fileSize} bytes, copied $copiedSize bytes"
)
}

if (cacheFile.exists() && !cacheFile.delete()) {
partFile.delete()
throw IOException("Could not replace cached upload file: ${cacheFile.absolutePath}")
}
if (!partFile.renameTo(cacheFile)) {
partFile.delete()
throw IOException("Could not finalize cached upload file: ${cacheFile.absolutePath}")
}

val finalSize = cacheFile.length()
if (!ContentUriUploadCacheValidator.isValidCacheSize(finalSize, ocTransfer.fileSize)) {
Timber.e(
"Invalid finalized cache copy from %s. expected=%d actual=%d",
contentUri,
ocTransfer.fileSize,
finalSize,
)
cacheFile.delete()
throw LocalFileNotFoundException()
clearTusState()
throw IOException(
"Final cache copy size mismatch for $contentUri: " +
"expected ${ocTransfer.fileSize} bytes, copied $finalSize bytes"
)
}

transferRepository.updateTransferSourcePath(uploadIdInStorageManager, contentUri.toString())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 OpenCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package eu.opencloud.android.workers

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class ContentUriUploadCacheValidatorTest {

@Test
fun `exact large cache size is valid`() {
val fileSize = 5_832_800_958L

val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = fileSize,
expectedSize = fileSize,
)

assertTrue(isValid)
}

@Test
fun `partial non-zero cache size is invalid`() {
val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = 1_180_123_136L,
expectedSize = 5_832_800_958L,
)

assertFalse(isValid)
}

@Test
fun `larger than expected cache size is invalid`() {
val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = 5_832_800_959L,
expectedSize = 5_832_800_958L,
)

assertFalse(isValid)
}

@Test
fun `zero byte cache size is invalid`() {
val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = 0L,
expectedSize = 5_832_800_958L,
)

assertFalse(isValid)
}

@Test
fun `unknown expected size keeps existing non-zero behavior`() {
val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = 42L,
expectedSize = -1L,
)

assertTrue(isValid)
}

@Test
fun `non-positive expected size still rejects zero byte cache`() {
val isValid = ContentUriUploadCacheValidator.isValidCacheSize(
actualSize = 0L,
expectedSize = -1L,
)

assertFalse(isValid)
}
}
Loading