diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/privacypolicy/PrivacyPolicyActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/privacypolicy/PrivacyPolicyActivity.kt index b559a7f137..494510508a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/privacypolicy/PrivacyPolicyActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/settings/privacypolicy/PrivacyPolicyActivity.kt @@ -20,10 +20,13 @@ package eu.opencloud.android.presentation.settings.privacypolicy +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle import android.view.MenuItem import android.view.View import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import android.widget.LinearLayout @@ -38,6 +41,7 @@ import eu.opencloud.android.extensions.showMessageInSnackbar import eu.opencloud.android.ui.activity.enableEdgeToEdgePostSetContentView import eu.opencloud.android.ui.activity.enableEdgeToEdgePreSetContentView import eu.opencloud.android.utils.PreferenceUtils +import timber.log.Timber /** * Activity to show the privacy policy to the user @@ -95,6 +99,23 @@ class PrivacyPolicyActivity : AppCompatActivity() { override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { showMessageInSnackbar(message = getString(R.string.privacy_policy_error) + description) } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url ?: return false + val scheme = url.scheme?.lowercase() + if (scheme == "http" || scheme == "https") return false + + return try { + val intent = Intent(Intent.ACTION_VIEW, url).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + Timber.w(e, "No Activity found to handle privacy policy URL") + false + } + } } val urlPrivacyPolicy = resources.getString(R.string.url_privacy_policy) diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt index b6c8421120..47b5f4affc 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/network/ChunkFromFileRequestBody.kt @@ -88,6 +88,7 @@ class ChunkFromFileRequestBody( } } catch (exception: Exception) { Timber.e(exception, "Transferred " + alreadyTransferred + " bytes from a total of " + file.length()) + throw exception } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt index b482fc5f21..dfd23720ce 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -46,124 +46,167 @@ class CreateTusUploadRemoteOperation( Base64.encodeToString(bytes, Base64.NO_WRAP) } - override fun run(client: OpenCloudClient): RemoteOperationResult = try { - // Determine TUS endpoint URL based on provided parameters - val targetFileUrl = if (!tusUrl.isNullOrBlank()) { - tusUrl - } else { - val baseCollection = (collectionUrlOverride - ?: client.userFilesWebDavUri.toString()).trim() - // Remove trailing slash - OpenCloud expects no slash on space endpoints - val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') - Timber.d("TUS resolved collection: %s", resolvedCollection) - resolvedCollection - } + private data class CreationRequestBody( + val body: RequestBody, + val fileToClose: RandomAccessFile? + ) + override fun run(client: OpenCloudClient): RemoteOperationResult { + var creationUploadFile: RandomAccessFile? = null + return try { + val targetFileUrl = resolveTargetFileUrl(client) Timber.d("TUS Creation URL: %s", targetFileUrl) - // Prepare request body first - val postBody: RequestBody = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { - // creation-with-upload: include first chunk - // Don't use .use{} here - the channel must stay open for OkHttp to read - val raf = RandomAccessFile(file, "r") - val channel: FileChannel = raf.channel - ChunkFromFileRequestBody( + val creationRequestBody = buildCreationRequestBody() + creationUploadFile = creationRequestBody.fileToClose + val postMethod = PostMethod(URL(targetFileUrl), creationRequestBody.body) + configureCreationRequest(postMethod) + + val status = client.executeHttpMethod(postMethod) + Timber.d("TUS Creation [%s] - %d%s", targetFileUrl, status, if (!isSuccess(status)) " (FAIL)" else "") + if (!isSuccess(status)) { + logCreationFailure(status, targetFileUrl, postMethod, client) + } + + if (status == HttpConstants.HTTP_PRECONDITION_FAILED) { + logPreconditionFailure(postMethod, creationRequestBody.body) + } + + buildCreationResult(status, postMethod) + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "TUS creation operation failed") + result + } finally { + try { + creationUploadFile?.close() + } catch (e: Exception) { + Timber.w(e, "Failed to close TUS creation upload file") + } + } + } + + private fun resolveTargetFileUrl(client: OpenCloudClient): String { + if (!tusUrl.isNullOrBlank()) { + return tusUrl + } + + val baseCollection = (collectionUrlOverride + ?: client.userFilesWebDavUri.toString()).trim() + val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') + Timber.d("TUS resolved collection: %s", resolvedCollection) + return resolvedCollection + } + + private fun buildCreationRequestBody(): CreationRequestBody = + if (shouldUseCreationWithUpload()) { + val raf = RandomAccessFile(file, "r") + val channel: FileChannel = raf.channel + CreationRequestBody( + body = ChunkFromFileRequestBody( file = file, contentType = HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM.toMediaTypeOrNull(), channel = channel, chunkSize = firstChunkSize!! - ) - } else { - // creation only: empty body - ByteArray(0).toRequestBody(null) - } + ), + fileToClose = raf + ) + } else { + CreationRequestBody( + body = ByteArray(0).toRequestBody(null), + fileToClose = null + ) + } - val postMethod = PostMethod(URL(targetFileUrl), postBody) - - // Set TUS headers - postMethod.setRequestHeader(HttpConstants.TUS_RESUMABLE, "1.0.0") - postMethod.setRequestHeader(HttpConstants.UPLOAD_LENGTH, file.length().toString()) - postMethod.setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) - // Set TUS-Extension header to indicate which extensions we want to use - val extensions = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { - "creation,creation-with-upload" - } else { - "creation" - } - postMethod.setRequestHeader(HttpConstants.TUS_EXTENSION, extensions) + private fun configureCreationRequest(postMethod: PostMethod) { + postMethod.setRequestHeader(HttpConstants.TUS_RESUMABLE, "1.0.0") + postMethod.setRequestHeader(HttpConstants.UPLOAD_LENGTH, file.length().toString()) + postMethod.setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + postMethod.setRequestHeader(HttpConstants.TUS_EXTENSION, tusExtensionHeader()) - // Prepare Upload-Metadata like iOS SDK - val allMetadata = metadata.toMutableMap() - allMetadata.putIfAbsent("filename", remotePath.substringAfterLast('/')) - allMetadata.putIfAbsent("mtime", (file.lastModified() / 1000).toString()) + val allMetadata = metadata.toMutableMap() + allMetadata.putIfAbsent("filename", remotePath.substringAfterLast('/')) + allMetadata.putIfAbsent("mtime", (file.lastModified() / 1000).toString()) - if (allMetadata.isNotEmpty()) { - postMethod.setRequestHeader(HttpConstants.UPLOAD_METADATA, encodeTusMetadata(allMetadata)) - } + if (allMetadata.isNotEmpty()) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_METADATA, encodeTusMetadata(allMetadata)) + } - // Set Upload-Offset for creation-with-upload - if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { - postMethod.setRequestHeader(HttpConstants.UPLOAD_OFFSET, "0") - } + if (shouldUseCreationWithUpload()) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_OFFSET, "0") + } + } - val status = client.executeHttpMethod(postMethod) - Timber.d("TUS Creation [%s] - %d%s", targetFileUrl, status, if (!isSuccess(status)) " (FAIL)" else "") - if (!isSuccess(status)) { - Timber.w("TUS Creation failed - Status: %d", status) - Timber.w(" Target URL: %s", targetFileUrl) - Timber.w(" Collection Override: %s", collectionUrlOverride) - Timber.w(" User Files WebDAV: %s", client.userFilesWebDavUri) - Timber.w(" Remote Path: %s", remotePath) - Timber.w(" File Size: %d bytes", file.length()) - Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) - Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) - Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) - } + private fun tusExtensionHeader(): String = + if (shouldUseCreationWithUpload()) { + "creation,creation-with-upload" + } else { + "creation" + } - // Debug logging for troubleshooting - if (status == 412) { - Timber.w("HTTP 412 Precondition Failed - Request headers:") - Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) - Timber.w(" Tus-Extension: %s", postMethod.getRequestHeader(HttpConstants.TUS_EXTENSION)) - Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) - Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) - Timber.w(" Content-Type: %s", postMethod.getRequestHeader(HttpConstants.CONTENT_TYPE_HEADER)) - Timber.w(" Content-Length: %d", postBody.contentLength()) - if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { - Timber.w(" Upload-Offset: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_OFFSET)) - } - } + private fun buildCreationResult(status: Int, postMethod: PostMethod): RemoteOperationResult = + if (isSuccess(status)) { + buildSuccessfulCreationResult(postMethod) + } else { + Timber.w("TUS creation failed with status: %d", status) + RemoteOperationResult(postMethod).apply { data = null } + } - if (isSuccess(status)) { - val locationHeader = postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER) - ?: postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER_LOWER) - val base = URL(postMethod.getFinalUrl().toString()) - val resolved = resolveLocationToAbsolute(locationHeader, base) - - val offsetHeader = postMethod.getResponseHeader(HttpConstants.UPLOAD_OFFSET) - val offset = offsetHeader?.toLongOrNull() ?: 0L - - if (resolved != null) { - Timber.d("TUS upload resource created: %s (offset=%d)", resolved, offset) - RemoteOperationResult(ResultCode.OK).apply { - data = CreationResult(resolved, offset) - } - } else { - Timber.e("Location header is missing in TUS creation response") - RemoteOperationResult(IllegalStateException("Location header missing")).apply { - data = null - } - } - } else { - Timber.w("TUS creation failed with status: %d", status) - RemoteOperationResult(postMethod).apply { data = null } + private fun buildSuccessfulCreationResult(postMethod: PostMethod): RemoteOperationResult { + val locationHeader = postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER) + ?: postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER_LOWER) + val base = URL(postMethod.getFinalUrl().toString()) + val resolved = resolveLocationToAbsolute(locationHeader, base) + + val offsetHeader = postMethod.getResponseHeader(HttpConstants.UPLOAD_OFFSET) + val offset = offsetHeader?.toLongOrNull() ?: 0L + + return if (resolved != null) { + Timber.d("TUS upload resource created: %s (offset=%d)", resolved, offset) + RemoteOperationResult(ResultCode.OK).apply { + data = CreationResult(resolved, offset) + } + } else { + Timber.e("Location header is missing in TUS creation response") + RemoteOperationResult(IllegalStateException("Location header missing")).apply { + data = null } - } catch (e: Exception) { - val result = RemoteOperationResult(e) - Timber.e(e, "TUS creation operation failed") - result + } + } + + private fun logCreationFailure( + status: Int, + targetFileUrl: String, + postMethod: PostMethod, + client: OpenCloudClient + ) { + Timber.w("TUS Creation failed - Status: %d", status) + Timber.w(" Target URL: %s", targetFileUrl) + Timber.w(" Collection Override: %s", collectionUrlOverride) + Timber.w(" User Files WebDAV: %s", client.userFilesWebDavUri) + Timber.w(" Remote Path: %s", remotePath) + Timber.w(" File Size: %d bytes", file.length()) + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + } + + private fun logPreconditionFailure(postMethod: PostMethod, postBody: RequestBody) { + Timber.w("HTTP 412 Precondition Failed - Request headers:") + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Tus-Extension: %s", postMethod.getRequestHeader(HttpConstants.TUS_EXTENSION)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + Timber.w(" Content-Type: %s", postMethod.getRequestHeader(HttpConstants.CONTENT_TYPE_HEADER)) + Timber.w(" Content-Length: %d", postBody.contentLength()) + if (shouldUseCreationWithUpload()) { + Timber.w(" Upload-Offset: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_OFFSET)) + } } + private fun shouldUseCreationWithUpload(): Boolean = + useCreationWithUpload && (firstChunkSize ?: 0L) > 0L + private fun isSuccess(status: Int) = status.isOneOf(HttpConstants.HTTP_CREATED, HttpConstants.HTTP_OK) @@ -184,7 +227,6 @@ class CreateTusUploadRemoteOperation( } - private fun buildCollectionUrl(base: String, remotePath: String): String { val normalizedBase = base.trim().trimEnd('/') val sanitizedRemotePath = remotePath.trim().trimEnd('/').ifEmpty { "/" } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt index 944c7155ae..c9153d2130 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -8,12 +8,14 @@ import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.accounts.AccountUtils import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody import eu.opencloud.android.lib.common.operations.RemoteOperationResult import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation.Base64Encoder import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -21,6 +23,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.ClosedChannelException import java.util.Base64 @RunWith(RobolectricTestRunner::class) @@ -245,6 +249,32 @@ class TusIntegrationTest { // creation-with-upload sends Content-Type and Content-Length for the chunk assertEquals("application/offset+octet-stream", postReq.getHeader("Content-Type")) assertEquals(firstChunkSize.toString(), postReq.getHeader("Content-Length")) + assertTrue("Local file should be deletable after TUS creation-with-upload", localFile.delete()) + } + + @Test + fun chunk_body_propagates_channel_failures() { + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(ByteArray(10) { it.toByte() }) + } + val raf = RandomAccessFile(localFile, "r") + val body = ChunkFromFileRequestBody( + file = localFile, + contentType = null, + channel = raf.channel, + chunkSize = 5 + ) + + raf.close() + + try { + body.writeTo(Buffer()) + fail("Expected closed channel failure") + } catch (expected: ClosedChannelException) { + // Expected failure must reach OkHttp so the upload can fail. + } finally { + localFile.delete() + } } @Test