Allow saving of `data:` URLs. https://redmine.stoutner.com/issues/596
[PrivacyBrowser.git] / app / src / main / java / com / stoutner / privacybrowser / asynctasks / SaveUrl.java
1 /*
2  * Copyright © 2020 Soren Stoutner <soren@stoutner.com>.
3  *
4  * This file is part of Privacy Browser <https://www.stoutner.com/privacy-browser>.
5  *
6  * Privacy Browser 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 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.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 package com.stoutner.privacybrowser.asynctasks;
21
22 import android.app.Activity;
23 import android.content.ContentResolver;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.net.Uri;
27 import android.os.AsyncTask;
28 import android.os.Build;
29 import android.util.Base64;
30 import android.view.View;
31 import android.webkit.CookieManager;
32 import android.webkit.MimeTypeMap;
33
34 import androidx.core.content.FileProvider;
35
36 import com.google.android.material.snackbar.Snackbar;
37 import com.stoutner.privacybrowser.R;
38 import com.stoutner.privacybrowser.helpers.ProxyHelper;
39 import com.stoutner.privacybrowser.views.NoSwipeViewPager;
40
41 import java.io.BufferedInputStream;
42 import java.io.File;
43 import java.io.FileOutputStream;
44 import java.io.InputStream;
45 import java.io.OutputStream;
46 import java.lang.ref.WeakReference;
47 import java.net.HttpURLConnection;
48 import java.net.Proxy;
49 import java.net.URL;
50 import java.text.NumberFormat;
51
52 public class SaveUrl extends AsyncTask<String, Long, String> {
53     // Define a weak references.
54     private WeakReference<Context> contextWeakReference;
55     private WeakReference<Activity> activityWeakReference;
56
57     // Define a success string constant.
58     private final String SUCCESS = "Success";
59
60     // Define the class variables.
61     private String filePathString;
62     private String userAgent;
63     private boolean cookiesEnabled;
64     private Snackbar savingFileSnackbar;
65
66     // The public constructor.
67     public SaveUrl(Context context, Activity activity, String filePathString, String userAgent, boolean cookiesEnabled) {
68         // Populate weak references to the calling context and activity.
69         contextWeakReference = new WeakReference<>(context);
70         activityWeakReference = new WeakReference<>(activity);
71
72         // Store the class variables.
73         this.filePathString = filePathString;
74         this.userAgent = userAgent;
75         this.cookiesEnabled = cookiesEnabled;
76     }
77
78     // `onPreExecute()` operates on the UI thread.
79     @Override
80     protected void onPreExecute() {
81         // Get a handle for the activity.
82         Activity activity = activityWeakReference.get();
83
84         // Abort if the activity is gone.
85         if ((activity==null) || activity.isFinishing()) {
86             return;
87         }
88
89         // Get a handle for the no swipe view pager.
90         NoSwipeViewPager noSwipeViewPager = activity.findViewById(R.id.webviewpager);
91
92         // Create a saving file snackbar.
93         savingFileSnackbar = Snackbar.make(noSwipeViewPager, activity.getString(R.string.saving_file) + "  0% - " + filePathString, Snackbar.LENGTH_INDEFINITE);
94
95         // Display the saving file snackbar.
96         savingFileSnackbar.show();
97     }
98
99     @Override
100     protected String doInBackground(String... urlToSave) {
101         // Get handles for the context and activity.
102         Context context = contextWeakReference.get();
103         Activity activity = activityWeakReference.get();
104
105         // Abort if the activity is gone.
106         if ((activity == null) || activity.isFinishing()) {
107             return null;
108         }
109
110         // Define a save disposition string.
111         String saveDisposition = SUCCESS;
112
113         try {
114             // Get the file.
115             File file = new File(filePathString);
116
117             // Delete the file if it exists.
118             if (file.exists()) {
119                 //noinspection ResultOfMethodCallIgnored
120                 file.delete();
121             }
122
123             // Create a new file.
124             //noinspection ResultOfMethodCallIgnored
125             file.createNewFile();
126
127             // Create an output file stream.
128             OutputStream fileOutputStream = new FileOutputStream(file);
129
130             // Save the URL.
131             if (urlToSave[0].startsWith("data:")) {  // The URL contains the entire data of an image.
132                 // Get the Base64 data, which begins after a `,`.
133                 String base64DataString = urlToSave[0].substring(urlToSave[0].indexOf(",") + 1);
134
135                 // Decode the Base64 string to a byte array.
136                 byte[] base64DecodedDataByteArray = Base64.decode(base64DataString, Base64.DEFAULT);
137
138                 // Write the Base64 byte array to the file output stream.
139                 fileOutputStream.write(base64DecodedDataByteArray);
140
141                 // Close the file output stream.
142                 fileOutputStream.close();
143             } else {  // The URL points to the data location on the internet.
144                 // Get the URL from the calling activity.
145                 URL url = new URL(urlToSave[0]);
146
147                 // Instantiate the proxy helper.
148                 ProxyHelper proxyHelper = new ProxyHelper();
149
150                 // Get the current proxy.
151                 Proxy proxy = proxyHelper.getCurrentProxy(context);
152
153                 // Open a connection to the URL.  No data is actually sent at this point.
154                 HttpURLConnection httpUrlConnection = (HttpURLConnection) url.openConnection(proxy);
155
156                 // Add the user agent to the header property.
157                 httpUrlConnection.setRequestProperty("User-Agent", userAgent);
158
159                 // Add the cookies if they are enabled.
160                 if (cookiesEnabled) {
161                     // Get the cookies for the current domain.
162                     String cookiesString = CookieManager.getInstance().getCookie(url.toString());
163
164                     // Only add the cookies if they are not null.
165                     if (cookiesString != null) {
166                         // Add the cookies to the header property.
167                         httpUrlConnection.setRequestProperty("Cookie", cookiesString);
168                     }
169                 }
170
171                 // 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.
172                 try {
173                     // Get the content length header, which causes the connection to the server to be made.
174                     String contentLengthString = httpUrlConnection.getHeaderField("Content-Length");
175
176                     // Define the file size long.
177                     long fileSize;
178
179                     // Make sure the content length isn't null.
180                     if (contentLengthString != null) {  // The content length isn't null.
181                         // Convert the content length to an long.
182                         fileSize = Long.parseLong(contentLengthString);
183                     } else {  // The content length is null.
184                         // Set the file size to be `-1`.
185                         fileSize = -1;
186                     }
187
188                     // Get the response body stream.
189                     InputStream inputStream = new BufferedInputStream(httpUrlConnection.getInputStream());
190
191                     // Initialize the conversion buffer byte array.
192                     byte[] conversionBufferByteArray = new byte[1024];
193
194                     // Initialize the downloaded kilobytes counter.
195                     long downloadedKilobytesCounter = 0;
196
197                     // Define the buffer length variable.
198                     int bufferLength;
199
200                     // 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.
201                     while ((bufferLength = inputStream.read(conversionBufferByteArray)) > 0) {  // Proceed while the amount of data stored in the buffer in > 0.
202                         // Write the contents of the conversion buffer to the file output stream.
203                         fileOutputStream.write(conversionBufferByteArray, 0, bufferLength);
204
205                         // Update the file download progress snackbar.
206                         if (fileSize == -1) {  // The file size is unknown.
207                             // Negatively update the downloaded kilobytes counter.
208                             downloadedKilobytesCounter = downloadedKilobytesCounter - bufferLength;
209
210                             publishProgress(downloadedKilobytesCounter);
211                         } else {  // The file size is known.
212                             // Update the downloaded kilobytes counter.
213                             downloadedKilobytesCounter = downloadedKilobytesCounter + bufferLength;
214
215                             // Calculate the download percentage.
216                             long downloadPercentage = (downloadedKilobytesCounter * 100) / fileSize;
217
218                             // Update the download percentage.
219                             publishProgress(downloadPercentage);
220                         }
221                     }
222
223                     // Close the input stream.
224                     inputStream.close();
225
226                     // Close the file output stream.
227                     fileOutputStream.close();
228
229                     // Create a media scanner intent, which adds items like pictures to Android's recent file list.
230                     Intent mediaScannerIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
231
232                     // Add the URI to the media scanner intent.
233                     mediaScannerIntent.setData(Uri.fromFile(file));
234
235                     // Make it so.
236                     activity.sendBroadcast(mediaScannerIntent);
237                 } finally {
238                     // Disconnect the HTTP URL connection.
239                     httpUrlConnection.disconnect();
240                 }
241             }
242         } catch (Exception exception) {
243             // Store the error in the save disposition string.
244             saveDisposition = exception.toString();
245         }
246
247         // Return the save disposition string.
248         return saveDisposition;
249     }
250
251     // `onProgressUpdate()` operates on the UI thread.
252     @Override
253     protected void onProgressUpdate(Long... downloadPercentage) {
254         // Get a handle for the activity.
255         Activity activity = activityWeakReference.get();
256
257         // Abort if the activity is gone.
258         if ((activity == null) || activity.isFinishing()) {
259             return;
260         }
261
262         // Check to see if a download percentage has been calculated.
263         if (downloadPercentage[0] < 0) {  // There is no download percentage.  The negative number represents the raw downloaded kilobytes.
264             // Calculate the number of bytes downloaded.  When the `downloadPercentage` is negative, it is actually the raw number of kilobytes downloaded.
265             long numberOfBytesDownloaded = - downloadPercentage[0];
266
267             // Format the number of bytes downloaded.
268             String formattedNumberOfBytesDownloaded = NumberFormat.getInstance().format(numberOfBytesDownloaded);
269
270             // Update the snackbar.
271             savingFileSnackbar.setText(activity.getString(R.string.saving_file) + "  " + formattedNumberOfBytesDownloaded + " " + activity.getString(R.string.bytes) + " - " + filePathString);
272         } else {  // There is a download percentage.
273             // Update the snackbar.
274             savingFileSnackbar.setText(activity.getString(R.string.saving_file) + "  " + downloadPercentage[0] + "% - " + filePathString);
275         }
276     }
277
278     // `onPostExecute()` operates on the UI thread.
279     @Override
280     protected void onPostExecute(String saveDisposition) {
281         // Get handles for the context and activity.
282         Context context = contextWeakReference.get();
283         Activity activity = activityWeakReference.get();
284
285         // Abort if the activity is gone.
286         if ((activity == null) || activity.isFinishing()) {
287             return;
288         }
289
290         // Get a handle for the no swipe view pager.
291         NoSwipeViewPager noSwipeViewPager = activity.findViewById(R.id.webviewpager);
292
293         // Dismiss the saving file snackbar.
294         savingFileSnackbar.dismiss();
295
296         // Display a save disposition snackbar.
297         if (saveDisposition.equals(SUCCESS)) {
298             // Create a file saved snackbar.
299             Snackbar fileSavedSnackbar = Snackbar.make(noSwipeViewPager, activity.getString(R.string.file_saved) + "  " + filePathString, Snackbar.LENGTH_LONG);
300
301             // Add an open action if the file is not an APK on API >= 26 (that scenario requires the REQUEST_INSTALL_PACKAGES permission).
302             if (!(Build.VERSION.SDK_INT >= 26 && filePathString.endsWith(".apk"))) {
303                 fileSavedSnackbar.setAction(R.string.open, (View view) -> {
304                     // Get a file for the file path string.
305                     File file = new File(filePathString);
306
307                     // Declare a file URI variable.
308                     Uri fileUri;
309
310                     // Get the URI for the file according to the Android version.
311                     if (Build.VERSION.SDK_INT >= 24) {  // Use a file provider.
312                         fileUri = FileProvider.getUriForFile(context, activity.getString(R.string.file_provider), file);
313                     } else {  // Get the raw file path URI.
314                         fileUri = Uri.fromFile(file);
315                     }
316
317                     // Get a handle for the content resolver.
318                     ContentResolver contentResolver = context.getContentResolver();
319
320                     // Create an open intent with `ACTION_VIEW`.
321                     Intent openIntent = new Intent(Intent.ACTION_VIEW);
322
323                     // Set the URI and the MIME type.
324                     if (filePathString.endsWith("apk") || filePathString.endsWith("APK")) {  // Force detection of APKs.
325                         openIntent.setDataAndType(fileUri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"));
326                     } else {  // Autodetect the MIME type.
327                         openIntent.setDataAndType(fileUri, contentResolver.getType(fileUri));
328                     }
329
330                     // Allow the app to read the file URI.
331                     openIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
332
333                     // Show the chooser.
334                     activity.startActivity(Intent.createChooser(openIntent, context.getString(R.string.open)));
335                 });
336             }
337
338             // Show the file saved snackbar.
339             fileSavedSnackbar.show();
340         } else {
341             // Display the file saving error.
342             Snackbar.make(noSwipeViewPager, activity.getString(R.string.error_saving_file) + "  " + saveDisposition, Snackbar.LENGTH_INDEFINITE).show();
343         }
344     }
345 }