2 * Copyright 2020-2023 Soren Stoutner <soren@stoutner.com>.
4 * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android>.
6 * Privacy Browser Android is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
11 * Privacy Browser Android is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with Privacy Browser Android. If not, see <http://www.gnu.org/licenses/>.
20 package com.stoutner.privacybrowser.coroutines
22 import android.app.Activity
23 import android.content.Context
24 import android.net.Uri
25 import android.os.Build
26 import android.provider.OpenableColumns
27 import android.util.Base64
28 import android.webkit.CookieManager
30 import androidx.viewpager2.widget.ViewPager2
32 import com.google.android.material.snackbar.Snackbar
34 import com.stoutner.privacybrowser.R
35 import com.stoutner.privacybrowser.helpers.ProxyHelper
37 import kotlinx.coroutines.CoroutineScope
38 import kotlinx.coroutines.Dispatchers
39 import kotlinx.coroutines.launch
40 import kotlinx.coroutines.withContext
42 import java.io.BufferedInputStream
43 import java.io.InputStream
44 import java.net.HttpURLConnection
46 import java.text.NumberFormat
48 class SaveUrlCoroutine {
49 fun save(context: Context, activity: Activity, urlString: String, fileUri: Uri, userAgent: String, cookiesEnabled: Boolean) {
50 // Use a coroutine to save the URL.
51 CoroutineScope(Dispatchers.Main).launch {
52 // Create a file name string.
53 val fileNameString: String
55 // Query the exact file name if the API >= 26.
56 if (Build.VERSION.SDK_INT >= 26) {
57 // Get a cursor from the content resolver.
58 val contentResolverCursor = activity.contentResolver.query(fileUri, null, null, null)!!
60 // Move to the first row.
61 contentResolverCursor.moveToFirst()
63 // Get the file name from the cursor.
64 fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
67 contentResolverCursor.close()
69 // Use the file URI last path segment as the file name string.
70 fileNameString = fileUri.lastPathSegment!!
73 // Get a handle for the no swipe view pager.
74 val webViewViewPager2 = activity.findViewById<ViewPager2>(R.id.webview_viewpager2)
76 // Create a saving file snackbar.
77 val savingFileSnackbar = Snackbar.make(webViewViewPager2, activity.getString(R.string.saving_file, 0, fileNameString), Snackbar.LENGTH_INDEFINITE)
79 // Display the saving file snackbar.
80 savingFileSnackbar.show()
82 // Download the URL on the IO thread.
83 withContext(Dispatchers.IO) {
85 // Open an output stream.
86 val outputStream = activity.contentResolver.openOutputStream(fileUri)!!
89 if (urlString.startsWith("data:")) { // The URL contains the entire data of an image.
90 // Get the Base64 data, which begins after a `,`.
91 val base64DataString = urlString.substring(urlString.indexOf(",") + 1)
93 // Decode the Base64 string to a byte array.
94 val base64DecodedDataByteArray = Base64.decode(base64DataString, Base64.DEFAULT)
96 // Write the Base64 byte array to the output stream.
97 outputStream.write(base64DecodedDataByteArray)
98 } else { // The URL points to the data location on the internet.
99 // Get the URL from the calling activity.
100 val url = URL(urlString)
102 // Instantiate the proxy helper.
103 val proxyHelper = ProxyHelper()
105 // Get the current proxy.
106 val proxy = proxyHelper.getCurrentProxy(context)
108 // Open a connection to the URL. No data is actually sent at this point.
109 val httpUrlConnection = url.openConnection(proxy) as HttpURLConnection
111 // Add the user agent to the header property.
112 httpUrlConnection.setRequestProperty("User-Agent", userAgent)
114 // Add the cookies if they are enabled.
115 if (cookiesEnabled) {
116 // Get the cookies for the current domain.
117 val cookiesString = CookieManager.getInstance().getCookie(url.toString())
119 // Only add the cookies if they are not null.
120 if (cookiesString != null) {
121 // Add the cookies to the header property.
122 httpUrlConnection.setRequestProperty("Cookie", cookiesString)
126 // Create the file size value.
129 // Create the formatted file size variable.
130 var formattedFileSize = ""
132 // The actual network request is in a `try` bracket so that `disconnect()` is run in the `finally` section even if an error is encountered in the main block.
134 // Get the content length header, which causes the connection to the server to be made.
135 val contentLengthString = httpUrlConnection.getHeaderField("Content-Length")
137 // Check to see if the content length is populated.
138 if (contentLengthString != null) { // The content length is populated.
139 // Convert the content length to an long.
140 fileSize = contentLengthString.toLong()
142 // Format the file size for display.
143 formattedFileSize = NumberFormat.getInstance().format(fileSize)
144 } else { // The content length is null.
145 // Set the file size to be `-1`.
149 // Get the response body stream.
150 val inputStream: InputStream = BufferedInputStream(httpUrlConnection.inputStream)
152 // Initialize the conversion buffer byte array.
153 // This is set to a megabyte so that frequent updating of the snackbar doesn't freeze the interface on download. <https://redmine.stoutner.com/issues/709>
154 val conversionBufferByteArray = ByteArray(1048576)
156 // Initialize the downloaded kilobytes counter.
157 var downloadedKilobytesCounter: Long = 0
159 // Define the buffer length variable.
160 var bufferLength: Int
162 // Attempt to read data from the input stream and store it in the output stream. Also store the amount of data read in the buffer length variable.
163 while (inputStream.read(conversionBufferByteArray).also { bufferLength = it } > 0) { // Proceed while the amount of data stored in the buffer in > 0.
164 // Write the contents of the conversion buffer to the file output stream.
165 outputStream.write(conversionBufferByteArray, 0, bufferLength)
167 // Update the downloaded kilobytes counter.
168 downloadedKilobytesCounter += bufferLength
170 // Format the number of bytes downloaded.
171 val formattedNumberOfBytesDownloadedString = NumberFormat.getInstance().format(downloadedKilobytesCounter)
174 withContext(Dispatchers.Main) {
175 // Check to see if the file size is known.
176 if (fileSize == -1L) { // The size of the download file is not known.
177 // Update the snackbar.
178 savingFileSnackbar.setText(activity.getString(R.string.saving_file_progress, formattedNumberOfBytesDownloadedString, fileNameString))
179 } else { // The size of the download file is known.
180 // Calculate the download percentage.
181 val downloadPercentage = downloadedKilobytesCounter * 100 / fileSize
183 // Update the snackbar.
184 savingFileSnackbar.setText(activity.getString(R.string.saving_file_percentage_progress, downloadPercentage, formattedNumberOfBytesDownloadedString, formattedFileSize,
191 // Close the input stream.
194 // Disconnect the HTTP URL connection.
195 httpUrlConnection.disconnect()
199 // Close the output stream.
203 withContext(Dispatchers.Main) {
204 // Dismiss the saving file snackbar.
205 savingFileSnackbar.dismiss()
207 // Display the file saved snackbar.
208 Snackbar.make(webViewViewPager2, activity.getString(R.string.saved, fileNameString), Snackbar.LENGTH_LONG).show()
210 } catch (exception: Exception) {
212 withContext(Dispatchers.Main) {
213 // Dismiss the saving file snackbar.
214 savingFileSnackbar.dismiss()
216 // Display the file saving error.
217 Snackbar.make(webViewViewPager2, activity.getString(R.string.error_saving_file, fileNameString, exception), Snackbar.LENGTH_INDEFINITE).show()