From c07fabbfc10362cd4b3f699552711ae5989bb6ee Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 23 Jun 2026 17:40:04 +0300 Subject: [PATCH 1/6] fix(re-download): office file Signed-off-by: alperozturk96 --- .../android/utils/RichDocumentDownloadAsParser.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt index 48a9583e1337..9a063cbfeac7 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt @@ -47,13 +47,18 @@ object RichDocumentDownloadAsParser { val format = obj[FORMAT]?.jsonPrimitive?.contentOrNull val name = obj[NAME]?.jsonPrimitive?.contentOrNull if (format == null || url == null) return null - return DownloadAs(format = format, filename = name ?: "", url = url) + return DownloadAs(format = format, filename = createFilename(name, format), url = url) } private fun tryParseV1(obj: JsonObject, url: String?): DownloadAs? { val type = obj[TYPE]?.jsonPrimitive?.contentOrNull val filename = obj[FILENAME]?.jsonPrimitive?.contentOrNull if (type == null || url == null) return null - return DownloadAs(format = type, filename = filename ?: "", url = url) + return DownloadAs(format = type, filename = createFilename(filename, type), url = url) + } + + // TODO: add correct file format + private fun createFilename(filename: String?, format: String?): String { + return filename ?: ("document_${System.currentTimeMillis()}") } } From 9016cc8b5e514704cae0b3dc60259010164bc398 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 16:03:28 +0200 Subject: [PATCH 2/6] wip Signed-off-by: alperozturk96 --- .../ui/activity/RichDocumentsEditorWebView.kt | 37 ++++++-- .../utils/RichDocumentDownloadAsParser.kt | 59 ++++++++++++- ...hDocumentDownloadAsParserExtensionTests.kt | 85 +++++++++++++++++++ 3 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt diff --git a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt index d4b08d318dbd..993e871287d0 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt @@ -32,9 +32,12 @@ import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.RichDocumentDownloadAsParser import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import okhttp3.OkHttpClient +import okhttp3.Request import org.json.JSONException import org.json.JSONObject import java.io.File +import java.io.IOException import java.lang.ref.WeakReference import javax.inject.Inject @@ -52,6 +55,8 @@ class RichDocumentsEditorWebView : EditorWebView() { private var activityResult: ActivityResultLauncher? = null + private val okHttpClient by lazy { OkHttpClient() } + @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT_INTERFACE") override fun postOnCreate() { super.postOnCreate() @@ -151,6 +156,31 @@ class RichDocumentsEditorWebView : EditorWebView() { startActivity(intent) } + private fun downloadExport(url: Uri, fallbackFilename: String) { + if (RichDocumentDownloadAsParser.hasExtension(fallbackFilename)) { + downloadFile(url, fallbackFilename) + return + } + + Thread { + val filename = resolveFilenameFromHeaders(url) ?: fallbackFilename + runOnUiThread { downloadFile(url, filename) } + }.start() + } + + private fun resolveFilenameFromHeaders(url: Uri): String? { + val request = Request.Builder().url(url.toString()).head().build() + return try { + okHttpClient.newCall(request).execute().use { response -> + val disposition = response.header(CONTENT_DISPOSITION_HEADER) + RichDocumentDownloadAsParser.filenameFromContentDisposition(disposition) + } + } catch (e: IOException) { + Log_OC.e(this, "Failed to resolve download filename from headers: $e") + null + } + } + private inner class RichDocumentsMobileInterface : MobileInterface() { @JavascriptInterface fun insertGraphic() { @@ -168,12 +198,8 @@ class RichDocumentsEditorWebView : EditorWebView() { val url = result.url.toUri() when (result.format) { PRINT -> printFile(url) - SLIDESHOW -> showSlideShow(url) - - else -> { - downloadFile(url, result.filename) - } + else -> downloadExport(url, result.filename) } } @@ -217,5 +243,6 @@ class RichDocumentsEditorWebView : EditorWebView() { private const val PRINT = "print" private const val SLIDESHOW = "slideshow" private const val NEW_NAME = "NewName" + private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" } } diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt index 9a063cbfeac7..b039e758905c 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt @@ -14,6 +14,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.net.URLDecoder +import java.nio.charset.StandardCharsets object RichDocumentDownloadAsParser { @@ -26,6 +28,17 @@ object RichDocumentDownloadAsParser { private const val TYPE = "Type" private const val FILENAME = "filename" + private const val EXTENSION_SEPARATOR = "." + private const val DEFAULT_FILENAME_PREFIX = "document_" + + private val CONTROL_FORMATS = setOf("print", "slideshow", "export") + private val EXTENSION_PATTERN = Regex("^[A-Za-z0-9]{1,10}$") + + private val CONTENT_DISPOSITION_FILENAME_STAR = + Regex("""filename\*\s*=\s*[^']*''([^;]+)""", RegexOption.IGNORE_CASE) + private val CONTENT_DISPOSITION_FILENAME = + Regex("""filename\s*=\s*"?([^";]+)"?""", RegexOption.IGNORE_CASE) + private val json = Json { ignoreUnknownKeys = true } @Suppress("TooGenericExceptionCaught") @@ -43,6 +56,38 @@ object RichDocumentDownloadAsParser { } } + fun hasExtension(name: String): Boolean { + val dotIndex = name.lastIndexOf(EXTENSION_SEPARATOR) + return dotIndex in 1 until name.length - 1 + } + + fun filenameFromContentDisposition(contentDisposition: String?): String? { + if (contentDisposition.isNullOrBlank()) return null + return encodedFilename(contentDisposition) ?: plainFilename(contentDisposition) + } + + @Suppress("TooGenericExceptionCaught") + private fun encodedFilename(contentDisposition: String): String? { + val encoded = CONTENT_DISPOSITION_FILENAME_STAR.find(contentDisposition) + ?.groupValues?.get(1) ?: return null + + val decoded = try { + URLDecoder.decode(encoded, StandardCharsets.UTF_8.name()) + } catch (e: Exception) { + Log_OC.e(TAG, "filename* decode failed: $e") + null + } + + return decoded?.trim()?.takeIf { it.isNotBlank() } + } + + private fun plainFilename(contentDisposition: String): String? = + CONTENT_DISPOSITION_FILENAME.find(contentDisposition) + ?.groupValues?.get(1) + ?.trim() + ?.trim('"') + ?.takeIf { it.isNotBlank() } + private fun tryParseV2(obj: JsonObject, url: String?): DownloadAs? { val format = obj[FORMAT]?.jsonPrimitive?.contentOrNull val name = obj[NAME]?.jsonPrimitive?.contentOrNull @@ -57,8 +102,18 @@ object RichDocumentDownloadAsParser { return DownloadAs(format = type, filename = createFilename(filename, type), url = url) } - // TODO: add correct file format private fun createFilename(filename: String?, format: String?): String { - return filename ?: ("document_${System.currentTimeMillis()}") + val name = filename?.takeIf { it.isNotBlank() } + ?: "$DEFAULT_FILENAME_PREFIX${System.currentTimeMillis()}" + + val extension = extensionFromFormat(format) + return when { + hasExtension(name) || extension == null -> name + else -> "$name$EXTENSION_SEPARATOR$extension" + } } + + private fun extensionFromFormat(format: String?): String? = format?.lowercase() + ?.takeUnless { it in CONTROL_FORMATS } + ?.takeIf { EXTENSION_PATTERN.matches(it) } } diff --git a/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt b/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt new file mode 100644 index 000000000000..8e59952960d1 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.model + +import com.owncloud.android.utils.RichDocumentDownloadAsParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RichDocumentDownloadAsParserExtensionTests { + + @Test + fun `parse v2 appends pdf extension when name has none`() { + val json = """{"format":"pdf","name":"document","url":"https://example.com/token"}""" + val result = RichDocumentDownloadAsParser.parse(json) + assertNotNull(result) + assertEquals("document.pdf", result!!.filename) + } + + @Test + fun `parse v2 appends epub extension when name has none`() { + val json = """{"format":"epub","name":"book","url":"https://example.com/token"}""" + val result = RichDocumentDownloadAsParser.parse(json) + assertNotNull(result) + assertEquals("book.epub", result!!.filename) + } + + @Test + fun `parse v2 keeps existing extension`() { + val json = """{"format":"pdf","name":"document.pdf","url":"https://example.com/token"}""" + val result = RichDocumentDownloadAsParser.parse(json) + assertNotNull(result) + assertEquals("document.pdf", result!!.filename) + } + + @Test + fun `parse v1 export without filename produces extensionless fallback`() { + val json = """{"Type":"export","URL":"https://example.com/download/token?WOPISrc=x"}""" + val result = RichDocumentDownloadAsParser.parse(json) + assertNotNull(result) + assertEquals("export", result!!.format) + assertFalse(RichDocumentDownloadAsParser.hasExtension(result.filename)) + } + + @Test + fun `hasExtension detects extension presence`() { + assertTrue(RichDocumentDownloadAsParser.hasExtension("document.pdf")) + assertFalse(RichDocumentDownloadAsParser.hasExtension("document")) + assertFalse(RichDocumentDownloadAsParser.hasExtension(".pdf")) + assertFalse(RichDocumentDownloadAsParser.hasExtension("document.")) + } + + @Test + fun `filenameFromContentDisposition parses quoted filename`() { + val header = """attachment; filename="MyDocument.pdf"""" + assertEquals("MyDocument.pdf", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) + } + + @Test + fun `filenameFromContentDisposition parses unquoted filename`() { + val header = "attachment; filename=MyDocument.epub" + assertEquals("MyDocument.epub", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) + } + + @Test + fun `filenameFromContentDisposition parses rfc5987 encoded filename`() { + val header = "attachment; filename*=UTF-8''My%20Document.pdf" + assertEquals("My Document.pdf", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) + } + + @Test + fun `filenameFromContentDisposition returns null for blank or missing`() { + assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition(null)) + assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition("")) + assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition("attachment")) + } +} From 353d887089b6266b7c5166c8bcbdb9ad517dc443 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 09:00:25 +0200 Subject: [PATCH 3/6] wip Signed-off-by: alperozturk96 --- .../ui/activity/RichDocumentsEditorWebView.kt | 33 +------ .../utils/RichDocumentDownloadAsParser.kt | 94 ++++++++----------- ...hDocumentDownloadAsParserExtensionTests.kt | 85 ----------------- 3 files changed, 39 insertions(+), 173 deletions(-) delete mode 100644 app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt diff --git a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt index 993e871287d0..29ce411a6e2c 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/RichDocumentsEditorWebView.kt @@ -32,12 +32,9 @@ import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.RichDocumentDownloadAsParser import edu.umd.cs.findbugs.annotations.SuppressFBWarnings -import okhttp3.OkHttpClient -import okhttp3.Request import org.json.JSONException import org.json.JSONObject import java.io.File -import java.io.IOException import java.lang.ref.WeakReference import javax.inject.Inject @@ -55,8 +52,6 @@ class RichDocumentsEditorWebView : EditorWebView() { private var activityResult: ActivityResultLauncher? = null - private val okHttpClient by lazy { OkHttpClient() } - @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT_INTERFACE") override fun postOnCreate() { super.postOnCreate() @@ -156,31 +151,6 @@ class RichDocumentsEditorWebView : EditorWebView() { startActivity(intent) } - private fun downloadExport(url: Uri, fallbackFilename: String) { - if (RichDocumentDownloadAsParser.hasExtension(fallbackFilename)) { - downloadFile(url, fallbackFilename) - return - } - - Thread { - val filename = resolveFilenameFromHeaders(url) ?: fallbackFilename - runOnUiThread { downloadFile(url, filename) } - }.start() - } - - private fun resolveFilenameFromHeaders(url: Uri): String? { - val request = Request.Builder().url(url.toString()).head().build() - return try { - okHttpClient.newCall(request).execute().use { response -> - val disposition = response.header(CONTENT_DISPOSITION_HEADER) - RichDocumentDownloadAsParser.filenameFromContentDisposition(disposition) - } - } catch (e: IOException) { - Log_OC.e(this, "Failed to resolve download filename from headers: $e") - null - } - } - private inner class RichDocumentsMobileInterface : MobileInterface() { @JavascriptInterface fun insertGraphic() { @@ -199,7 +169,7 @@ class RichDocumentsEditorWebView : EditorWebView() { when (result.format) { PRINT -> printFile(url) SLIDESHOW -> showSlideShow(url) - else -> downloadExport(url, result.filename) + else -> downloadFile(url, result.filename) } } @@ -243,6 +213,5 @@ class RichDocumentsEditorWebView : EditorWebView() { private const val PRINT = "print" private const val SLIDESHOW = "slideshow" private const val NEW_NAME = "NewName" - private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" } } diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt index b039e758905c..b0023b8ad05d 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt @@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.net.URLDecoder -import java.nio.charset.StandardCharsets +import okhttp3.OkHttpClient +import okhttp3.Request object RichDocumentDownloadAsParser { @@ -28,19 +28,12 @@ object RichDocumentDownloadAsParser { private const val TYPE = "Type" private const val FILENAME = "filename" - private const val EXTENSION_SEPARATOR = "." - private const val DEFAULT_FILENAME_PREFIX = "document_" - - private val CONTROL_FORMATS = setOf("print", "slideshow", "export") - private val EXTENSION_PATTERN = Regex("^[A-Za-z0-9]{1,10}$") - - private val CONTENT_DISPOSITION_FILENAME_STAR = - Regex("""filename\*\s*=\s*[^']*''([^;]+)""", RegexOption.IGNORE_CASE) - private val CONTENT_DISPOSITION_FILENAME = - Regex("""filename\s*=\s*"?([^";]+)"?""", RegexOption.IGNORE_CASE) + private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" private val json = Json { ignoreUnknownKeys = true } + private val client = OkHttpClient() + @Suppress("TooGenericExceptionCaught") fun parse(jsonString: String?): DownloadAs? { if (jsonString.isNullOrBlank()) return null @@ -56,64 +49,53 @@ object RichDocumentDownloadAsParser { } } - fun hasExtension(name: String): Boolean { - val dotIndex = name.lastIndexOf(EXTENSION_SEPARATOR) - return dotIndex in 1 until name.length - 1 - } - - fun filenameFromContentDisposition(contentDisposition: String?): String? { - if (contentDisposition.isNullOrBlank()) return null - return encodedFilename(contentDisposition) ?: plainFilename(contentDisposition) - } - - @Suppress("TooGenericExceptionCaught") - private fun encodedFilename(contentDisposition: String): String? { - val encoded = CONTENT_DISPOSITION_FILENAME_STAR.find(contentDisposition) - ?.groupValues?.get(1) ?: return null - - val decoded = try { - URLDecoder.decode(encoded, StandardCharsets.UTF_8.name()) - } catch (e: Exception) { - Log_OC.e(TAG, "filename* decode failed: $e") - null - } - - return decoded?.trim()?.takeIf { it.isNotBlank() } - } - - private fun plainFilename(contentDisposition: String): String? = - CONTENT_DISPOSITION_FILENAME.find(contentDisposition) - ?.groupValues?.get(1) - ?.trim() - ?.trim('"') - ?.takeIf { it.isNotBlank() } - private fun tryParseV2(obj: JsonObject, url: String?): DownloadAs? { val format = obj[FORMAT]?.jsonPrimitive?.contentOrNull val name = obj[NAME]?.jsonPrimitive?.contentOrNull if (format == null || url == null) return null - return DownloadAs(format = format, filename = createFilename(name, format), url = url) + return DownloadAs(format = format, filename = createFilename(url, name, format), url = url) } private fun tryParseV1(obj: JsonObject, url: String?): DownloadAs? { val type = obj[TYPE]?.jsonPrimitive?.contentOrNull val filename = obj[FILENAME]?.jsonPrimitive?.contentOrNull if (type == null || url == null) return null - return DownloadAs(format = type, filename = createFilename(filename, type), url = url) + return DownloadAs(format = type, filename = createFilename(url, filename, type), url = url) } - private fun createFilename(filename: String?, format: String?): String { - val name = filename?.takeIf { it.isNotBlank() } - ?: "$DEFAULT_FILENAME_PREFIX${System.currentTimeMillis()}" + private fun createFilename(url: String, filename: String?, format: String?): String { + if (filename != null && format != null) { + return filename + format + } - val extension = extensionFromFormat(format) - return when { - hasExtension(name) || extension == null -> name - else -> "$name$EXTENSION_SEPARATOR$extension" + val request = Request.Builder().url(url).head().build() + + return try { + client.newCall(request).execute().use { response -> + val disposition = response.header(CONTENT_DISPOSITION_HEADER) + extractFilenameFromDisposition(disposition) ?: randomFilename(format) + } + } catch (e: Exception) { + Log_OC.e(TAG, "createFilename failed: $e") + randomFilename(format) } } - private fun extensionFromFormat(format: String?): String? = format?.lowercase() - ?.takeUnless { it in CONTROL_FORMATS } - ?.takeIf { EXTENSION_PATTERN.matches(it) } + private fun extractFilenameFromDisposition(disposition: String?): String? { + if (disposition.isNullOrBlank()) return null + + val extendedRegex = """filename\*\s*=\s*[^']*''([^;]+)""".toRegex(RegexOption.IGNORE_CASE) + extendedRegex.find(disposition)?.groupValues?.get(1)?.let { + return runCatching { java.net.URLDecoder.decode(it.trim(), "UTF-8") }.getOrNull() + } + + val regex = """filename\s*=\s*"?([^";]+)"?""".toRegex(RegexOption.IGNORE_CASE) + return regex.find(disposition)?.groupValues?.get(1)?.trim() + } + + private fun randomFilename(format: String? = null): String { + val random = java.util.UUID.randomUUID().toString().take(8) + val extension = format?.removePrefix(".")?.let { ".$it" } ?: "" + return "file_$random$extension" + } } diff --git a/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt b/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt deleted file mode 100644 index 8e59952960d1..000000000000 --- a/app/src/test/java/com/owncloud/android/ui/model/RichDocumentDownloadAsParserExtensionTests.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.owncloud.android.ui.model - -import com.owncloud.android.utils.RichDocumentDownloadAsParser -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class RichDocumentDownloadAsParserExtensionTests { - - @Test - fun `parse v2 appends pdf extension when name has none`() { - val json = """{"format":"pdf","name":"document","url":"https://example.com/token"}""" - val result = RichDocumentDownloadAsParser.parse(json) - assertNotNull(result) - assertEquals("document.pdf", result!!.filename) - } - - @Test - fun `parse v2 appends epub extension when name has none`() { - val json = """{"format":"epub","name":"book","url":"https://example.com/token"}""" - val result = RichDocumentDownloadAsParser.parse(json) - assertNotNull(result) - assertEquals("book.epub", result!!.filename) - } - - @Test - fun `parse v2 keeps existing extension`() { - val json = """{"format":"pdf","name":"document.pdf","url":"https://example.com/token"}""" - val result = RichDocumentDownloadAsParser.parse(json) - assertNotNull(result) - assertEquals("document.pdf", result!!.filename) - } - - @Test - fun `parse v1 export without filename produces extensionless fallback`() { - val json = """{"Type":"export","URL":"https://example.com/download/token?WOPISrc=x"}""" - val result = RichDocumentDownloadAsParser.parse(json) - assertNotNull(result) - assertEquals("export", result!!.format) - assertFalse(RichDocumentDownloadAsParser.hasExtension(result.filename)) - } - - @Test - fun `hasExtension detects extension presence`() { - assertTrue(RichDocumentDownloadAsParser.hasExtension("document.pdf")) - assertFalse(RichDocumentDownloadAsParser.hasExtension("document")) - assertFalse(RichDocumentDownloadAsParser.hasExtension(".pdf")) - assertFalse(RichDocumentDownloadAsParser.hasExtension("document.")) - } - - @Test - fun `filenameFromContentDisposition parses quoted filename`() { - val header = """attachment; filename="MyDocument.pdf"""" - assertEquals("MyDocument.pdf", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) - } - - @Test - fun `filenameFromContentDisposition parses unquoted filename`() { - val header = "attachment; filename=MyDocument.epub" - assertEquals("MyDocument.epub", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) - } - - @Test - fun `filenameFromContentDisposition parses rfc5987 encoded filename`() { - val header = "attachment; filename*=UTF-8''My%20Document.pdf" - assertEquals("My Document.pdf", RichDocumentDownloadAsParser.filenameFromContentDisposition(header)) - } - - @Test - fun `filenameFromContentDisposition returns null for blank or missing`() { - assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition(null)) - assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition("")) - assertNull(RichDocumentDownloadAsParser.filenameFromContentDisposition("attachment")) - } -} From 779d453573398c4ab190b028984be2bb0d4a2f0f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 10:47:24 +0200 Subject: [PATCH 4/6] wip Signed-off-by: alperozturk96 --- .../utils/RichDocumentDownloadAsParser.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt index b0023b8ad05d..31b6f234c4e7 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt @@ -53,19 +53,19 @@ object RichDocumentDownloadAsParser { val format = obj[FORMAT]?.jsonPrimitive?.contentOrNull val name = obj[NAME]?.jsonPrimitive?.contentOrNull if (format == null || url == null) return null - return DownloadAs(format = format, filename = createFilename(url, name, format), url = url) + return DownloadAs(format = format, filename = createFilename(url, name), url = url) } private fun tryParseV1(obj: JsonObject, url: String?): DownloadAs? { val type = obj[TYPE]?.jsonPrimitive?.contentOrNull val filename = obj[FILENAME]?.jsonPrimitive?.contentOrNull if (type == null || url == null) return null - return DownloadAs(format = type, filename = createFilename(url, filename, type), url = url) + return DownloadAs(format = type, filename = createFilename(url, filename), url = url) } - private fun createFilename(url: String, filename: String?, format: String?): String { - if (filename != null && format != null) { - return filename + format + private fun createFilename(url: String, filename: String?): String { + if (filename != null) { + return filename } val request = Request.Builder().url(url).head().build() @@ -73,11 +73,11 @@ object RichDocumentDownloadAsParser { return try { client.newCall(request).execute().use { response -> val disposition = response.header(CONTENT_DISPOSITION_HEADER) - extractFilenameFromDisposition(disposition) ?: randomFilename(format) + extractFilenameFromDisposition(disposition) ?: randomFilename() } } catch (e: Exception) { Log_OC.e(TAG, "createFilename failed: $e") - randomFilename(format) + randomFilename() } } @@ -93,9 +93,8 @@ object RichDocumentDownloadAsParser { return regex.find(disposition)?.groupValues?.get(1)?.trim() } - private fun randomFilename(format: String? = null): String { + private fun randomFilename(): String { val random = java.util.UUID.randomUUID().toString().take(8) - val extension = format?.removePrefix(".")?.let { ".$it" } ?: "" - return "file_$random$extension" + return "file_$random" } } From d7a25445b06a95bc6a6e0314a405770f6e7bfbf9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 11:32:39 +0200 Subject: [PATCH 5/6] wip Signed-off-by: alperozturk96 --- .../android/ui/activity/EditorWebView.java | 36 ++--- .../owncloud/android/ui/model/DownloadAs.kt | 2 +- .../utils/RichDocumentDownloadAsParser.kt | 45 +----- .../android/utils/RichDocumentDownloader.kt | 140 ++++++++++++++++++ 4 files changed, 159 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt diff --git a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java index fb17f5a5bc7a..29eaca5b1ebd 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java @@ -7,17 +7,16 @@ */ package com.owncloud.android.ui.activity; -import android.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.ClipData; -import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.net.Uri; -import android.os.Environment; +import android.os.Bundle; import android.os.Handler; +import android.os.PersistableBundle; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.ValueCallback; @@ -37,6 +36,7 @@ import com.owncloud.android.ui.asynctasks.TextEditorLoadUrlTask; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.RichDocumentDownloader; import com.owncloud.android.utils.WebViewUtil; import java.util.ArrayList; @@ -44,6 +44,8 @@ import javax.inject.Inject; +import androidx.annotation.Nullable; + public abstract class EditorWebView extends ExternalSiteWebView { public static final int REQUEST_LOCAL_FILE = 101; public ValueCallback uploadMessage; @@ -53,6 +55,8 @@ public abstract class EditorWebView extends ExternalSiteWebView { RichdocumentsWebviewBinding binding; + private RichDocumentDownloader richDocumentDownloader; + @Inject SyncedFolderProvider syncedFolderProvider; protected void loadUrl(String url) { @@ -128,6 +132,12 @@ protected void bindView() { binding = RichdocumentsWebviewBinding.inflate(getLayoutInflater()); } + @Override + public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { + super.onCreate(savedInstanceState, persistentState); + richDocumentDownloader = new RichDocumentDownloader(this); + } + @Override protected void postOnCreate() { super.postOnCreate(); @@ -285,23 +295,9 @@ protected void setThumbnailView(final User user) { } } - protected void downloadFile(Uri url, String fileName) { - DownloadManager downloadmanager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); - - if (downloadmanager == null) { - DisplayUtils.showSnackMessage(getWebView(), getString(R.string.failed_to_download)); - return; - } - - DownloadManager.Request request = new DownloadManager.Request(url); - request.allowScanningByMediaScanner(); - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); - - // change the name file and your current activity. - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); - - - downloadmanager.enqueue(request); + protected void downloadFile(Uri uri, String filename) { + String userAgent = getWebView().getSettings().getUserAgentString(); + richDocumentDownloader.download(uri, filename, userAgent); } public void setLoadingSnackbar(Snackbar loadingSnackbar) { diff --git a/app/src/main/java/com/owncloud/android/ui/model/DownloadAs.kt b/app/src/main/java/com/owncloud/android/ui/model/DownloadAs.kt index 25994455e3bf..ce77adb0c0e6 100644 --- a/app/src/main/java/com/owncloud/android/ui/model/DownloadAs.kt +++ b/app/src/main/java/com/owncloud/android/ui/model/DownloadAs.kt @@ -7,4 +7,4 @@ package com.owncloud.android.ui.model -data class DownloadAs(val format: String, val filename: String, val url: String) +data class DownloadAs(val format: String, val filename: String?, val url: String) diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt index 31b6f234c4e7..3e4ca06d6847 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloadAsParser.kt @@ -14,8 +14,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import okhttp3.OkHttpClient -import okhttp3.Request object RichDocumentDownloadAsParser { @@ -28,12 +26,8 @@ object RichDocumentDownloadAsParser { private const val TYPE = "Type" private const val FILENAME = "filename" - private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" - private val json = Json { ignoreUnknownKeys = true } - private val client = OkHttpClient() - @Suppress("TooGenericExceptionCaught") fun parse(jsonString: String?): DownloadAs? { if (jsonString.isNullOrBlank()) return null @@ -53,48 +47,13 @@ object RichDocumentDownloadAsParser { val format = obj[FORMAT]?.jsonPrimitive?.contentOrNull val name = obj[NAME]?.jsonPrimitive?.contentOrNull if (format == null || url == null) return null - return DownloadAs(format = format, filename = createFilename(url, name), url = url) + return DownloadAs(format = format, filename = name, url = url) } private fun tryParseV1(obj: JsonObject, url: String?): DownloadAs? { val type = obj[TYPE]?.jsonPrimitive?.contentOrNull val filename = obj[FILENAME]?.jsonPrimitive?.contentOrNull if (type == null || url == null) return null - return DownloadAs(format = type, filename = createFilename(url, filename), url = url) - } - - private fun createFilename(url: String, filename: String?): String { - if (filename != null) { - return filename - } - - val request = Request.Builder().url(url).head().build() - - return try { - client.newCall(request).execute().use { response -> - val disposition = response.header(CONTENT_DISPOSITION_HEADER) - extractFilenameFromDisposition(disposition) ?: randomFilename() - } - } catch (e: Exception) { - Log_OC.e(TAG, "createFilename failed: $e") - randomFilename() - } - } - - private fun extractFilenameFromDisposition(disposition: String?): String? { - if (disposition.isNullOrBlank()) return null - - val extendedRegex = """filename\*\s*=\s*[^']*''([^;]+)""".toRegex(RegexOption.IGNORE_CASE) - extendedRegex.find(disposition)?.groupValues?.get(1)?.let { - return runCatching { java.net.URLDecoder.decode(it.trim(), "UTF-8") }.getOrNull() - } - - val regex = """filename\s*=\s*"?([^";]+)"?""".toRegex(RegexOption.IGNORE_CASE) - return regex.find(disposition)?.groupValues?.get(1)?.trim() - } - - private fun randomFilename(): String { - val random = java.util.UUID.randomUUID().toString().take(8) - return "file_$random" + return DownloadAs(format = type, filename = filename, url = url) } } diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt new file mode 100644 index 000000000000..982469b423c6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt @@ -0,0 +1,140 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.webkit.CookieManager +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.ResponseBody +import java.io.File +import java.net.URLDecoder +import java.util.UUID + +class RichDocumentDownloader(private val activity: AppCompatActivity) { + private val client = OkHttpClient() + + fun download(uri: Uri, filename: String?, userAgent: String?) { + activity.lifecycleScope.launch(Dispatchers.IO) { + runCatching { + if (filename == null) { + val url = uri.toString() + downloadWithOkHttp(url, filename, userAgent) + } else { + downloadWithDownloadManager(uri, filename) + } + } + .onFailure { Log_OC.e(TAG, "download failed: $it") } + .getOrDefault(false) + } + } + + private suspend fun downloadWithDownloadManager(url: Uri, filename: String) = withContext(Dispatchers.Main) { + val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager? + + if (downloadManager == null) { + showMessage(false) + return@withContext + } + + val request = DownloadManager.Request(url) + request.allowScanningByMediaScanner() + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + + // change the name file and your current activity. + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) + + downloadManager.enqueue(request) + } + + private fun downloadWithOkHttp(url: String, filename: String?, userAgent: String?) { + val requestBuilder = Request.Builder().url(url).get() + + CookieManager.getInstance().getCookie(url)?.let { requestBuilder.header(COOKIE_HEADER, it) } + userAgent?.let { requestBuilder.header(USER_AGENT_HEADER, it) } + + client.newCall(requestBuilder.build()).execute().use { response -> + val body = response.body + if (!response.isSuccessful) { + Log_OC.e(TAG, "server returned ${response.code} for download") + showMessage(false) + return + } + + val resolvedName = resolveFileName(filename, response.header(CONTENT_DISPOSITION_HEADER)) + val result = saveToDownloads(body, resolvedName) + showMessage(result) + } + } + + private fun showMessage(success: Boolean) { + activity.runOnUiThread { + val message = if (success) + R.string.downloader_download_succeeded_ticker + else + R.string.failed_to_download + + DisplayUtils.showSnackMessage(activity, message) + } + } + + private fun saveToDownloads(body: ResponseBody, filename: String): Boolean { + val mimeType = body.contentType()?.let { "${it.type}/${it.subtype}" } ?: DEFAULT_MIME_TYPE + val tempFile = File.createTempFile(TEMP_PREFIX, null, activity.cacheDir) + + return try { + tempFile.outputStream().use { output -> body.byteStream().copyTo(output) } + FileExportUtils().exportFile(filename, mimeType, activity.contentResolver, null, tempFile) + true + } finally { + tempFile.delete() + } + } + + private fun resolveFileName(provided: String?, disposition: String?): String = provided?.takeIf { it.isNotBlank() } + ?: extractFilenameFromDisposition(disposition) + ?: randomFilename() + + private fun extractFilenameFromDisposition(disposition: String?): String? { + if (disposition.isNullOrBlank()) return null + return extractExtendedFilename(disposition) ?: extractPlainFilename(disposition) + } + + private fun extractExtendedFilename(disposition: String): String? { + val regex = """filename\*\s*=\s*[^']*''([^;]+)""".toRegex(RegexOption.IGNORE_CASE) + val value = regex.find(disposition)?.groupValues?.get(1)?.trim() ?: return null + return runCatching { URLDecoder.decode(value, "UTF-8") }.getOrNull() + } + + private fun extractPlainFilename(disposition: String): String? { + val regex = """filename\s*=\s*"?([^";]+)"?""".toRegex(RegexOption.IGNORE_CASE) + return regex.find(disposition)?.groupValues?.get(1)?.trim() + } + + private fun randomFilename(): String = "file_" + UUID.randomUUID().toString().take(RANDOM_NAME_LENGTH) + + companion object { + private const val TAG = "RichDocumentDownloader" + private const val COOKIE_HEADER = "Cookie" + private const val USER_AGENT_HEADER = "User-Agent" + private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" + private const val DEFAULT_MIME_TYPE = "application/octet-stream" + private const val TEMP_PREFIX = "richdocument_download" + private const val RANDOM_NAME_LENGTH = 8 + } +} From 665280315ae8550f3bc8e94ec88121c5ee0623ed Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 11:42:46 +0200 Subject: [PATCH 6/6] wip Signed-off-by: alperozturk96 --- .../android/ui/activity/EditorWebView.java | 20 +++----- .../android/utils/RichDocumentDownloader.kt | 51 +++++++++---------- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java index 29eaca5b1ebd..4721bbbe434f 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/EditorWebView.java @@ -14,9 +14,7 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.net.Uri; -import android.os.Bundle; import android.os.Handler; -import android.os.PersistableBundle; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.ValueCallback; @@ -44,8 +42,6 @@ import javax.inject.Inject; -import androidx.annotation.Nullable; - public abstract class EditorWebView extends ExternalSiteWebView { public static final int REQUEST_LOCAL_FILE = 101; public ValueCallback uploadMessage; @@ -55,8 +51,6 @@ public abstract class EditorWebView extends ExternalSiteWebView { RichdocumentsWebviewBinding binding; - private RichDocumentDownloader richDocumentDownloader; - @Inject SyncedFolderProvider syncedFolderProvider; protected void loadUrl(String url) { @@ -132,12 +126,6 @@ protected void bindView() { binding = RichdocumentsWebviewBinding.inflate(getLayoutInflater()); } - @Override - public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { - super.onCreate(savedInstanceState, persistentState); - richDocumentDownloader = new RichDocumentDownloader(this); - } - @Override protected void postOnCreate() { super.postOnCreate(); @@ -296,8 +284,12 @@ protected void setThumbnailView(final User user) { } protected void downloadFile(Uri uri, String filename) { - String userAgent = getWebView().getSettings().getUserAgentString(); - richDocumentDownloader.download(uri, filename, userAgent); + // downloadAs is invoked from the WebView JavaScript bridge thread, but WebView methods + // (getSettings) must run on the main thread, so read the user agent there. + runOnUiThread(() -> { + String userAgent = getWebView().getSettings().getUserAgentString(); + new RichDocumentDownloader(this).download(uri, filename, userAgent); + }); } public void setLoadingSnackbar(Snackbar loadingSnackbar) { diff --git a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt index 982469b423c6..79e3add6a541 100644 --- a/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt +++ b/app/src/main/java/com/owncloud/android/utils/RichDocumentDownloader.kt @@ -32,9 +32,10 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { fun download(uri: Uri, filename: String?, userAgent: String?) { activity.lifecycleScope.launch(Dispatchers.IO) { runCatching { + // if filename not provided get filename from header if (filename == null) { val url = uri.toString() - downloadWithOkHttp(url, filename, userAgent) + downloadWithOkHttp(url, userAgent) } else { downloadWithDownloadManager(uri, filename) } @@ -45,24 +46,18 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { } private suspend fun downloadWithDownloadManager(url: Uri, filename: String) = withContext(Dispatchers.Main) { - val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager? - - if (downloadManager == null) { - showMessage(false) - return@withContext - } - - val request = DownloadManager.Request(url) - request.allowScanningByMediaScanner() - request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - - // change the name file and your current activity. - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) - - downloadManager.enqueue(request) + val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager + ?: return@withContext showMessage(false) + + DownloadManager.Request(url).apply { + @Suppress("DEPRECATION") + allowScanningByMediaScanner() + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) + }.let(downloadManager::enqueue) } - private fun downloadWithOkHttp(url: String, filename: String?, userAgent: String?) { + private fun downloadWithOkHttp(url: String, userAgent: String?) { val requestBuilder = Request.Builder().url(url).get() CookieManager.getInstance().getCookie(url)?.let { requestBuilder.header(COOKIE_HEADER, it) } @@ -76,7 +71,8 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { return } - val resolvedName = resolveFileName(filename, response.header(CONTENT_DISPOSITION_HEADER)) + val disposition = response.header(CONTENT_DISPOSITION_HEADER) + val resolvedName = resolveFileName(disposition) val result = saveToDownloads(body, resolvedName) showMessage(result) } @@ -84,10 +80,11 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { private fun showMessage(success: Boolean) { activity.runOnUiThread { - val message = if (success) + val message = if (success) { R.string.downloader_download_succeeded_ticker - else + } else { R.string.failed_to_download + } DisplayUtils.showSnackMessage(activity, message) } @@ -106,9 +103,8 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { } } - private fun resolveFileName(provided: String?, disposition: String?): String = provided?.takeIf { it.isNotBlank() } - ?: extractFilenameFromDisposition(disposition) - ?: randomFilename() + private fun resolveFileName(disposition: String?): String = + extractFilenameFromDisposition(disposition) ?: randomFilename() private fun extractFilenameFromDisposition(disposition: String?): String? { if (disposition.isNullOrBlank()) return null @@ -116,13 +112,13 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { } private fun extractExtendedFilename(disposition: String): String? { - val regex = """filename\*\s*=\s*[^']*''([^;]+)""".toRegex(RegexOption.IGNORE_CASE) + val regex = EXTENDED_FILENAME_REGEX.toRegex(RegexOption.IGNORE_CASE) val value = regex.find(disposition)?.groupValues?.get(1)?.trim() ?: return null - return runCatching { URLDecoder.decode(value, "UTF-8") }.getOrNull() + return runCatching { URLDecoder.decode(value, EXTENDED_FILENAME_ENCODER) }.getOrNull() } private fun extractPlainFilename(disposition: String): String? { - val regex = """filename\s*=\s*"?([^";]+)"?""".toRegex(RegexOption.IGNORE_CASE) + val regex = PLAIN_FILENAME_REGEX.toRegex(RegexOption.IGNORE_CASE) return regex.find(disposition)?.groupValues?.get(1)?.trim() } @@ -136,5 +132,8 @@ class RichDocumentDownloader(private val activity: AppCompatActivity) { private const val DEFAULT_MIME_TYPE = "application/octet-stream" private const val TEMP_PREFIX = "richdocument_download" private const val RANDOM_NAME_LENGTH = 8 + private const val EXTENDED_FILENAME_REGEX = """filename\*\s*=\s*[^']*''([^;]+)""" + private const val PLAIN_FILENAME_REGEX = """filename\s*=\s*"?([^";]+)"?""" + private const val EXTENDED_FILENAME_ENCODER = "UTF-8" } }