]> gitweb.stoutner.com Git - PrivacyBrowserAndroid.git/blob - app/src/main/java/com/stoutner/privacybrowser/coroutines/SaveUrlCoroutine.kt
Bump the minimum API to 26. https://redmine.stoutner.com/issues/1163
[PrivacyBrowserAndroid.git] / app / src / main / java / com / stoutner / privacybrowser / coroutines / SaveUrlCoroutine.kt
1 /*
2  * Copyright 2020-2024 Soren Stoutner <soren@stoutner.com>.
3  *
4  * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android>.
5  *
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.
10  *
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.
15  *
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/>.
18  */
19
20 package com.stoutner.privacybrowser.coroutines
21
22 import android.app.Activity
23 import android.content.Context
24 import android.net.Uri
25 import android.provider.OpenableColumns
26 import android.util.Base64
27 import android.webkit.CookieManager
28
29 import androidx.viewpager2.widget.ViewPager2
30
31 import com.google.android.material.snackbar.Snackbar
32
33 import com.stoutner.privacybrowser.R
34 import com.stoutner.privacybrowser.helpers.ProxyHelper
35
36 import kotlinx.coroutines.CoroutineScope
37 import kotlinx.coroutines.Dispatchers
38 import kotlinx.coroutines.launch
39 import kotlinx.coroutines.withContext
40
41 import java.io.BufferedInputStream
42 import java.io.InputStream
43 import java.net.HttpURLConnection
44 import java.net.URL
45 import java.text.NumberFormat
46 import java.util.Date
47
48 class SaveUrlCoroutine {
49     fun save(context: Context, activity: Activity, urlString: String, fileUri: Uri, userAgent: String, cookiesEnabled: Boolean) {
50         // Create a canceled boolean.
51         var canceled = false
52
53         // Use a coroutine to save the URL.
54         CoroutineScope(Dispatchers.Main).launch {
55             // Get a cursor from the content resolver.
56             val contentResolverCursor = activity.contentResolver.query(fileUri, null, null, null)!!
57
58             // Move to the first row.
59             contentResolverCursor.moveToFirst()
60
61             // Get the file name from the cursor.
62             val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
63
64             // Close the cursor.
65             contentResolverCursor.close()
66
67             // Get a handle for the no swipe view pager.
68             val webViewViewPager2 = activity.findViewById<ViewPager2>(R.id.webview_viewpager2)
69
70             // Create a saving file snackbar.
71             val savingFileSnackbar = Snackbar.make(webViewViewPager2, activity.getString(R.string.saving_file, 0, fileNameString), Snackbar.LENGTH_INDEFINITE)
72                                              .setAction(R.string.cancel) { canceled = true }
73
74             // Display the saving file snackbar.
75             savingFileSnackbar.show()
76
77             // Download the URL on the IO thread.
78             withContext(Dispatchers.IO) {
79                 try {
80                     // Open an output stream.
81                     val outputStream = activity.contentResolver.openOutputStream(fileUri)!!
82
83                     // Save the URL.
84                     if (urlString.startsWith("data:")) {  // The URL contains the entire data of an image.
85                         // Get the Base64 data, which begins after a `,`.
86                         val base64DataString = urlString.substring(urlString.indexOf(",") + 1)
87
88                         // Decode the Base64 string to a byte array.
89                         val base64DecodedDataByteArray = Base64.decode(base64DataString, Base64.DEFAULT)
90
91                         // Write the Base64 byte array to the output stream.
92                         outputStream.write(base64DecodedDataByteArray)
93                     } else {  // The URL points to the data location on the internet.
94                         // Get the URL from the calling activity.
95                         val url = URL(urlString)
96
97                         // Instantiate the proxy helper.
98                         val proxyHelper = ProxyHelper()
99
100                         // Get the current proxy.
101                         val proxy = proxyHelper.getCurrentProxy(context)
102
103                         // Open a connection to the URL.  No data is actually sent at this point.
104                         val httpUrlConnection = url.openConnection(proxy) as HttpURLConnection
105
106                         // Add the user agent to the header property.
107                         httpUrlConnection.setRequestProperty("User-Agent", userAgent)
108
109                         // Add the cookies if they are enabled.
110                         if (cookiesEnabled) {
111                             // Get the cookies for the current domain.
112                             val cookiesString = CookieManager.getInstance().getCookie(url.toString())
113
114                             // Only add the cookies if they are not null.
115                             if (cookiesString != null) {
116                                 // Add the cookies to the header property.
117                                 httpUrlConnection.setRequestProperty("Cookie", cookiesString)
118                             }
119                         }
120
121                         // Create the file size value.
122                         val fileSize: Long
123
124                         // Create the formatted file size variable.
125                         var formattedFileSize = ""
126
127                         // 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.
128                         try {
129                             // Get the content length header, which causes the connection to the server to be made.
130                             val contentLengthString = httpUrlConnection.getHeaderField("Content-Length")
131
132                             // Check to see if the content length is populated.
133                             if (contentLengthString != null) {  // The content length is populated.
134                                 // Convert the content length to an long.
135                                 fileSize = contentLengthString.toLong()
136
137                                 // Format the file size for display.
138                                 formattedFileSize = NumberFormat.getInstance().format(fileSize)
139                             } else {  // The content length is null.
140                                 // Set the file size to be `-1`.
141                                 fileSize = -1
142                             }
143
144                             // Get the response body stream.
145                             val inputStream: InputStream = BufferedInputStream(httpUrlConnection.inputStream)
146
147                             // Initialize the conversion buffer byte array.
148                             // This is set to a 50,000 bytes so that frequent updating of the snackbar doesn't freeze the interface on download, although `inputStream.read` currently uses 8,000 as an upper limit.
149                             // <https://redmine.stoutner.com/issues/709>
150                             val conversionBufferByteArray = ByteArray(50_000)
151
152                             // Initialize the longs.
153                             var downloadedBytesCounterLong: Long = 0
154                             var lastSnackbarUpdateLong: Long = 0
155
156                             // Define the buffer length variable.
157                             var bufferLength: Int
158
159                             // 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.
160                             while ((inputStream.read(conversionBufferByteArray).also { bufferLength = it } > 0) && !canceled) {  // Proceed while the amount of data stored in the buffer in > 0.
161                                 // Write the contents of the conversion buffer to the file output stream.
162                                 outputStream.write(conversionBufferByteArray, 0, bufferLength)
163
164                                 // Update the downloaded bytes counter.
165                                 downloadedBytesCounterLong += bufferLength
166
167                                 // Format the number of bytes downloaded.
168                                 val formattedNumberOfBytesDownloadedString = NumberFormat.getInstance().format(downloadedBytesCounterLong)
169
170                                 // Get the current time.
171                                 val currentTimeLong = Date().time
172
173                                 // Update the snackbar if more than 100 milliseconds have passed since the last update.
174                                 // Updating the snackbar is so resource intensive that it will throttle the download if it is done too frequently.
175                                 if (currentTimeLong - lastSnackbarUpdateLong > 100) {
176                                     // Store the update time.
177                                     lastSnackbarUpdateLong = currentTimeLong
178
179                                     // Update the UI.
180                                     withContext(Dispatchers.Main) {
181                                         // Check to see if the file size is known.
182                                         if (fileSize == -1L) {  // The size of the download file is not known.
183                                             // Update the snackbar.
184                                             savingFileSnackbar.setText(activity.getString(R.string.saving_file_progress, formattedNumberOfBytesDownloadedString, fileNameString))
185                                         } else {  // The size of the download file is known.
186                                             // Calculate the download percentage.
187                                             val downloadPercentage = downloadedBytesCounterLong * 100 / fileSize
188
189                                             // Update the snackbar.
190                                             savingFileSnackbar.setText(activity.getString(R.string.saving_file_percentage_progress, downloadPercentage, formattedNumberOfBytesDownloadedString, formattedFileSize,
191                                                 fileNameString)
192                                             )
193                                         }
194                                     }
195                                 }
196                             }
197
198                             // Close the input stream.
199                             inputStream.close()
200                         } finally {
201                             // Disconnect the HTTP URL connection.
202                             httpUrlConnection.disconnect()
203                         }
204                     }
205
206                     // Close the output stream.
207                     outputStream.close()
208
209                     // Update the UI.
210                     withContext(Dispatchers.Main) {
211                         // Dismiss the saving file snackbar.
212                         savingFileSnackbar.dismiss()
213
214                         // Display a final disposition snackbar.
215                         if (canceled)
216                             // Display the download cancelled snackbar.
217                             Snackbar.make(webViewViewPager2, activity.getString(R.string.download_cancelled), Snackbar.LENGTH_SHORT).show()
218                         else
219                             // Display the file saved snackbar.
220                             Snackbar.make(webViewViewPager2, activity.getString(R.string.saved, fileNameString), Snackbar.LENGTH_LONG).show()
221                     }
222                 } catch (exception: Exception) {
223                     // Update the UI.
224                     withContext(Dispatchers.Main) {
225                         // Dismiss the saving file snackbar.
226                         savingFileSnackbar.dismiss()
227
228                         // Display the file saving error.
229                         Snackbar.make(webViewViewPager2, activity.getString(R.string.error_saving_file, fileNameString, exception), Snackbar.LENGTH_INDEFINITE).show()
230                     }
231                 }
232             }
233         }
234     }
235 }