|
| 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 | + |
| 8 | +package com.owncloud.android.utils |
| 9 | + |
| 10 | +import android.app.DownloadManager |
| 11 | +import android.content.Context |
| 12 | +import android.net.Uri |
| 13 | +import android.os.Environment |
| 14 | +import android.webkit.CookieManager |
| 15 | +import androidx.appcompat.app.AppCompatActivity |
| 16 | +import androidx.lifecycle.lifecycleScope |
| 17 | +import com.owncloud.android.R |
| 18 | +import com.owncloud.android.lib.common.utils.Log_OC |
| 19 | +import kotlinx.coroutines.Dispatchers |
| 20 | +import kotlinx.coroutines.launch |
| 21 | +import kotlinx.coroutines.withContext |
| 22 | +import okhttp3.OkHttpClient |
| 23 | +import okhttp3.Request |
| 24 | +import okhttp3.ResponseBody |
| 25 | +import java.io.File |
| 26 | +import java.net.URLDecoder |
| 27 | +import java.util.UUID |
| 28 | + |
| 29 | +class RichDocumentDownloader(private val activity: AppCompatActivity) { |
| 30 | + private val client = OkHttpClient() |
| 31 | + |
| 32 | + fun download(uri: Uri, filename: String?, userAgent: String?) { |
| 33 | + activity.lifecycleScope.launch(Dispatchers.IO) { |
| 34 | + runCatching { |
| 35 | + // if filename not provided get filename from header |
| 36 | + if (filename == null) { |
| 37 | + val url = uri.toString() |
| 38 | + downloadWithOkHttp(url, userAgent) |
| 39 | + } else { |
| 40 | + downloadWithDownloadManager(uri, filename) |
| 41 | + } |
| 42 | + } |
| 43 | + .onFailure { Log_OC.e(TAG, "download failed: $it") } |
| 44 | + .getOrDefault(false) |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + private suspend fun downloadWithDownloadManager(url: Uri, filename: String) = withContext(Dispatchers.Main) { |
| 49 | + val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager |
| 50 | + ?: return@withContext showMessage(false) |
| 51 | + |
| 52 | + DownloadManager.Request(url).apply { |
| 53 | + @Suppress("DEPRECATION") |
| 54 | + allowScanningByMediaScanner() |
| 55 | + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) |
| 56 | + setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) |
| 57 | + }.let(downloadManager::enqueue) |
| 58 | + } |
| 59 | + |
| 60 | + private fun downloadWithOkHttp(url: String, userAgent: String?) { |
| 61 | + val requestBuilder = Request.Builder().url(url).get() |
| 62 | + |
| 63 | + CookieManager.getInstance().getCookie(url)?.let { requestBuilder.header(COOKIE_HEADER, it) } |
| 64 | + userAgent?.let { requestBuilder.header(USER_AGENT_HEADER, it) } |
| 65 | + |
| 66 | + client.newCall(requestBuilder.build()).execute().use { response -> |
| 67 | + val body = response.body |
| 68 | + if (!response.isSuccessful) { |
| 69 | + Log_OC.e(TAG, "server returned ${response.code} for download") |
| 70 | + showMessage(false) |
| 71 | + return |
| 72 | + } |
| 73 | + |
| 74 | + val disposition = response.header(CONTENT_DISPOSITION_HEADER) |
| 75 | + val resolvedName = resolveFileName(disposition) |
| 76 | + val result = saveToDownloads(body, resolvedName) |
| 77 | + showMessage(result) |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + private fun showMessage(success: Boolean) { |
| 82 | + activity.runOnUiThread { |
| 83 | + val message = if (success) { |
| 84 | + R.string.downloader_download_succeeded_ticker |
| 85 | + } else { |
| 86 | + R.string.failed_to_download |
| 87 | + } |
| 88 | + |
| 89 | + DisplayUtils.showSnackMessage(activity, message) |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + private fun saveToDownloads(body: ResponseBody, filename: String): Boolean { |
| 94 | + val mimeType = body.contentType()?.let { "${it.type}/${it.subtype}" } ?: DEFAULT_MIME_TYPE |
| 95 | + val tempFile = File.createTempFile(TEMP_PREFIX, null, activity.cacheDir) |
| 96 | + |
| 97 | + return try { |
| 98 | + tempFile.outputStream().use { output -> body.byteStream().copyTo(output) } |
| 99 | + FileExportUtils().exportFile(filename, mimeType, activity.contentResolver, null, tempFile) |
| 100 | + true |
| 101 | + } finally { |
| 102 | + tempFile.delete() |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + private fun resolveFileName(disposition: String?): String = |
| 107 | + extractFilenameFromDisposition(disposition) ?: randomFilename() |
| 108 | + |
| 109 | + private fun extractFilenameFromDisposition(disposition: String?): String? { |
| 110 | + if (disposition.isNullOrBlank()) return null |
| 111 | + return extractExtendedFilename(disposition) ?: extractPlainFilename(disposition) |
| 112 | + } |
| 113 | + |
| 114 | + private fun extractExtendedFilename(disposition: String): String? { |
| 115 | + val regex = EXTENDED_FILENAME_REGEX.toRegex(RegexOption.IGNORE_CASE) |
| 116 | + val value = regex.find(disposition)?.groupValues?.get(1)?.trim() ?: return null |
| 117 | + return runCatching { URLDecoder.decode(value, EXTENDED_FILENAME_ENCODER) }.getOrNull() |
| 118 | + } |
| 119 | + |
| 120 | + private fun extractPlainFilename(disposition: String): String? { |
| 121 | + val regex = PLAIN_FILENAME_REGEX.toRegex(RegexOption.IGNORE_CASE) |
| 122 | + return regex.find(disposition)?.groupValues?.get(1)?.trim() |
| 123 | + } |
| 124 | + |
| 125 | + private fun randomFilename(): String = "file_" + UUID.randomUUID().toString().take(RANDOM_NAME_LENGTH) |
| 126 | + |
| 127 | + companion object { |
| 128 | + private const val TAG = "RichDocumentDownloader" |
| 129 | + private const val COOKIE_HEADER = "Cookie" |
| 130 | + private const val USER_AGENT_HEADER = "User-Agent" |
| 131 | + private const val CONTENT_DISPOSITION_HEADER = "Content-Disposition" |
| 132 | + private const val DEFAULT_MIME_TYPE = "application/octet-stream" |
| 133 | + private const val TEMP_PREFIX = "richdocument_download" |
| 134 | + private const val RANDOM_NAME_LENGTH = 8 |
| 135 | + private const val EXTENDED_FILENAME_REGEX = """filename\*\s*=\s*[^']*''([^;]+)""" |
| 136 | + private const val PLAIN_FILENAME_REGEX = """filename\s*=\s*"?([^";]+)"?""" |
| 137 | + private const val EXTENDED_FILENAME_ENCODER = "UTF-8" |
| 138 | + } |
| 139 | +} |
0 commit comments