Allow saving of `data:` URLs. https://redmine.stoutner.com/issues/596
[PrivacyBrowser.git] / app / src / main / java / com / stoutner / privacybrowser / dialogs / SaveWebpageDialog.java
1 /*
2  * Copyright © 2019-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.dialogs;
21
22 import android.Manifest;
23 import android.annotation.SuppressLint;
24 import android.app.Activity;
25 import android.app.Dialog;
26 import android.content.Context;
27 import android.content.DialogInterface;
28 import android.content.Intent;
29 import android.content.SharedPreferences;
30 import android.content.pm.PackageManager;
31 import android.content.res.Configuration;
32 import android.os.AsyncTask;
33 import android.os.Build;
34 import android.os.Bundle;
35 import android.os.Environment;
36 import android.provider.DocumentsContract;
37 import android.text.Editable;
38 import android.text.InputType;
39 import android.text.TextWatcher;
40 import android.view.View;
41 import android.view.WindowManager;
42 import android.widget.Button;
43 import android.widget.EditText;
44 import android.widget.TextView;
45
46 import androidx.annotation.NonNull;
47 import androidx.appcompat.app.AlertDialog;
48 import androidx.core.content.ContextCompat;
49 import androidx.fragment.app.DialogFragment;
50 import androidx.preference.PreferenceManager;
51
52 import com.google.android.material.textfield.TextInputLayout;
53 import com.stoutner.privacybrowser.R;
54 import com.stoutner.privacybrowser.activities.MainWebViewActivity;
55 import com.stoutner.privacybrowser.asynctasks.GetUrlSize;
56 import com.stoutner.privacybrowser.helpers.DownloadLocationHelper;
57
58 import java.io.File;
59
60 public class SaveWebpageDialog extends DialogFragment {
61     // Define the save webpage listener.
62     private SaveWebpageListener saveWebpageListener;
63
64     // The public interface is used to send information back to the parent activity.
65     public interface SaveWebpageListener {
66         void onSaveWebpage(int saveType, String originalUrlString, DialogFragment dialogFragment);
67     }
68
69     // Define the get URL size AsyncTask.  This allows previous instances of the task to be cancelled if a new one is run.
70     private AsyncTask getUrlSize;
71
72     @Override
73     public void onAttach(@NonNull Context context) {
74         // Run the default commands.
75         super.onAttach(context);
76
77         // Get a handle for the save webpage listener from the launching context.
78         saveWebpageListener = (SaveWebpageListener) context;
79     }
80
81     public static SaveWebpageDialog saveWebpage(int saveType, String urlString, String fileSizeString, String contentDispositionFileNameString, String userAgentString, boolean cookiesEnabled) {
82         // Create an arguments bundle.
83         Bundle argumentsBundle = new Bundle();
84
85         // Store the arguments in the bundle.
86         argumentsBundle.putInt("save_type", saveType);
87         argumentsBundle.putString("url_string", urlString);
88         argumentsBundle.putString("file_size_string", fileSizeString);
89         argumentsBundle.putString("content_disposition_file_name_string", contentDispositionFileNameString);
90         argumentsBundle.putString("user_agent_string", userAgentString);
91         argumentsBundle.putBoolean("cookies_enabled", cookiesEnabled);
92
93         // Create a new instance of the save webpage dialog.
94         SaveWebpageDialog saveWebpageDialog = new SaveWebpageDialog();
95
96         // Add the arguments bundle to the new dialog.
97         saveWebpageDialog.setArguments(argumentsBundle);
98
99         // Return the new dialog.
100         return saveWebpageDialog;
101     }
102
103     // `@SuppressLint("InflateParams")` removes the warning about using null as the parent view group when inflating the alert dialog.
104     @SuppressLint("InflateParams")
105     @Override
106     @NonNull
107     public Dialog onCreateDialog(Bundle savedInstanceState) {
108         // Get a handle for the arguments.
109         Bundle arguments = getArguments();
110
111         // Remove the incorrect lint warning that the arguments might be null.
112         assert arguments != null;
113
114         // Get the arguments from the bundle.
115         int saveType = arguments.getInt("save_type");
116         String urlString = arguments.getString("url_string");
117         String fileSizeString = arguments.getString("file_size_string");
118         String contentDispositionFileNameString = arguments.getString("content_disposition_file_name_string");
119         String userAgentString = arguments.getString("user_agent_string");
120         boolean cookiesEnabled = arguments.getBoolean("cookies_enabled");
121
122         // Get a handle for the activity and the context.
123         Activity activity = requireActivity();
124         Context context = requireContext();
125
126         // Use an alert dialog builder to create the alert dialog.
127         AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(context, R.style.PrivacyBrowserAlertDialog);
128
129         // Get the current theme status.
130         int currentThemeStatus = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
131
132         // Set the icon according to the theme.
133         if (currentThemeStatus == Configuration.UI_MODE_NIGHT_YES) {  // The night theme is enabled.
134             // Set the icon according to the save type.
135             switch (saveType) {
136                 case StoragePermissionDialog.SAVE_URL:
137                     dialogBuilder.setIcon(R.drawable.copy_enabled_night);
138                     break;
139
140                 case StoragePermissionDialog.SAVE_ARCHIVE:
141                     dialogBuilder.setIcon(R.drawable.dom_storage_cleared_night);
142                     break;
143
144                 case StoragePermissionDialog.SAVE_IMAGE:
145                     dialogBuilder.setIcon(R.drawable.images_enabled_night);
146                     break;
147             }
148         } else {  // The day theme is enabled.
149             // Set the icon according to the save type.
150             switch (saveType) {
151                 case StoragePermissionDialog.SAVE_URL:
152                     dialogBuilder.setIcon(R.drawable.copy_enabled_day);
153                     break;
154
155                 case StoragePermissionDialog.SAVE_ARCHIVE:
156                     dialogBuilder.setIcon(R.drawable.dom_storage_cleared_day);
157                     break;
158
159                 case StoragePermissionDialog.SAVE_IMAGE:
160                     dialogBuilder.setIcon(R.drawable.images_enabled_day);
161                     break;
162             }
163         }
164
165         // Set the title according to the type.
166         switch (saveType) {
167             case StoragePermissionDialog.SAVE_URL:
168                 dialogBuilder.setTitle(R.string.save);
169                 break;
170
171             case StoragePermissionDialog.SAVE_ARCHIVE:
172                 dialogBuilder.setTitle(R.string.save_archive);
173                 break;
174
175             case StoragePermissionDialog.SAVE_IMAGE:
176                 dialogBuilder.setTitle(R.string.save_image);
177                 break;
178         }
179
180         // Set the view.  The parent view is null because it will be assigned by the alert dialog.
181         dialogBuilder.setView(activity.getLayoutInflater().inflate(R.layout.save_url_dialog, null));
182
183         // Set the cancel button listener.  Using `null` as the listener closes the dialog without doing anything else.
184         dialogBuilder.setNegativeButton(R.string.cancel, null);
185
186         // Set the save button listener.
187         dialogBuilder.setPositiveButton(R.string.save, (DialogInterface dialog, int which) -> {
188             // Return the dialog fragment to the parent activity.
189             saveWebpageListener.onSaveWebpage(saveType, urlString, this);
190         });
191
192         // Create an alert dialog from the builder.
193         AlertDialog alertDialog = dialogBuilder.create();
194
195         // Remove the incorrect lint warning below that the window might be null.
196         assert alertDialog.getWindow() != null;
197
198         // Get a handle for the shared preferences.
199         SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
200
201         // Get the screenshot preference.
202         boolean allowScreenshots = sharedPreferences.getBoolean("allow_screenshots", false);
203
204         // Disable screenshots if not allowed.
205         if (!allowScreenshots) {
206             alertDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
207         }
208
209         // The alert dialog must be shown before items in the layout can be modified.
210         alertDialog.show();
211
212         // Get handles for the layout items.
213         TextInputLayout urlTextInputLayout = alertDialog.findViewById(R.id.url_textinputlayout);
214         EditText urlEditText = alertDialog.findViewById(R.id.url_edittext);
215         EditText fileNameEditText = alertDialog.findViewById(R.id.file_name_edittext);
216         Button browseButton = alertDialog.findViewById(R.id.browse_button);
217         TextView fileSizeTextView = alertDialog.findViewById(R.id.file_size_textview);
218         TextView fileExistsWarningTextView = alertDialog.findViewById(R.id.file_exists_warning_textview);
219         TextView storagePermissionTextView = alertDialog.findViewById(R.id.storage_permission_textview);
220         Button saveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE);
221
222         // Remove the incorrect warnings that the views might be null.
223         assert urlTextInputLayout != null;
224         assert urlEditText != null;
225         assert fileNameEditText != null;
226         assert browseButton != null;
227         assert fileSizeTextView != null;
228         assert fileExistsWarningTextView != null;
229         assert storagePermissionTextView != null;
230
231         // Set the file size text view.
232         fileSizeTextView.setText(fileSizeString);
233
234         // Modify the layout based on the save type.
235         if (saveType == StoragePermissionDialog.SAVE_URL) {  // A URL is being saved.
236             // Remove the incorrect lint error below that the URL string might be null.
237             assert urlString != null;
238
239             // Populate the URL edit text according to the type.  This must be done before the text change listener is created below so that the file size isn't requested again.
240             if (urlString.startsWith("data:")) {  // The URL contains the entire data of an image.
241                 // Get a substring of the data URL with the first 100 characters.  Otherwise, the user interface will freeze while trying to layout the edit text.
242                 String urlSubstring = urlString.substring(0, 100) + "…";
243
244                 // Populate the URL edit text with the truncated URL.
245                 urlEditText.setText(urlSubstring);
246
247                 // Disable the editing of the URL edit text.
248                 urlEditText.setInputType(InputType.TYPE_NULL);
249             } else {  // The URL contains a reference to the location of the data.
250                 // Populate the URL edit text with the full URL.
251                 urlEditText.setText(urlString);
252             }
253
254             // Update the file size and the status of the save button when the URL changes.
255             urlEditText.addTextChangedListener(new TextWatcher() {
256                 @Override
257                 public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
258                     // Do nothing.
259                 }
260
261                 @Override
262                 public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
263                     // Do nothing.
264                 }
265
266                 @Override
267                 public void afterTextChanged(Editable editable) {
268                     // Cancel the get URL size AsyncTask if it is running.
269                     if ((getUrlSize != null)) {
270                         getUrlSize.cancel(true);
271                     }
272
273                     // Get the current URL to save.
274                     String urlToSave = urlEditText.getText().toString();
275
276                     // Wipe the file size text view.
277                     fileSizeTextView.setText("");
278
279                     // Get the file size for the current URL.
280                     getUrlSize = new GetUrlSize(context, alertDialog, userAgentString, cookiesEnabled).execute(urlToSave);
281
282                     // Enable the save button if the URL and file name are populated.
283                     saveButton.setEnabled(!urlToSave.isEmpty() && !fileNameEditText.getText().toString().isEmpty());
284                 }
285             });
286         } else {  // An archive or an image is being saved.
287             // Hide the URL edit text and the file size text view.
288             urlTextInputLayout.setVisibility(View.GONE);
289             fileSizeTextView.setVisibility(View.GONE);
290         }
291
292         // Update the status of the save button when the file name changes.
293         fileNameEditText.addTextChangedListener(new TextWatcher() {
294             @Override
295             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
296                 // Do nothing.
297             }
298
299             @Override
300             public void onTextChanged(CharSequence s, int start, int before, int count) {
301                 // Do nothing.
302             }
303
304             @Override
305             public void afterTextChanged(Editable s) {
306                 // Get the current file name.
307                 String fileNameString = fileNameEditText.getText().toString();
308
309                 // Convert the file name string to a file.
310                 File file = new File(fileNameString);
311
312                 // Check to see if the file exists.
313                 if (file.exists()) {
314                     // Show the file exists warning.
315                     fileExistsWarningTextView.setVisibility(View.VISIBLE);
316                 } else {
317                     // Hide the file exists warning.
318                     fileExistsWarningTextView.setVisibility(View.GONE);
319                 }
320
321                 // Enable the save button based on the save type.
322                 if (saveType == StoragePermissionDialog.SAVE_URL) {  // A URL is being saved.
323                     // Enable the save button if the file name and the URL is populated.
324                     saveButton.setEnabled(!fileNameString.isEmpty() && !urlEditText.getText().toString().isEmpty());
325                 } else {  // An archive or an image is being saved.
326                     // Enable the save button if the file name is populated.
327                     saveButton.setEnabled(!fileNameString.isEmpty());
328                 }
329             }
330         });
331
332         // Create a file name string.
333         String fileName = "";
334
335         // Set the file name according to the type.
336         switch (saveType) {
337             case StoragePermissionDialog.SAVE_URL:
338                 // Use the file name from the content disposition.
339                 fileName = contentDispositionFileNameString;
340                 break;
341
342             case StoragePermissionDialog.SAVE_ARCHIVE:
343                 // Use an archive name ending in `.mht`.
344                 fileName = getString(R.string.webpage_mht);
345                 break;
346
347             case StoragePermissionDialog.SAVE_IMAGE:
348                 // Use a file name ending in `.png`.
349                 fileName = getString(R.string.webpage_png);
350                 break;
351         }
352
353         // Save the file name as the default file name.  This must be final to be used in the lambda below.
354         final String defaultFileName = fileName;
355
356         // Instantiate the download location helper.
357         DownloadLocationHelper downloadLocationHelper = new DownloadLocationHelper();
358
359         // Get the default file path.
360         String defaultFilePath = downloadLocationHelper.getDownloadLocation(context) + "/" + defaultFileName;
361
362         // Populate the file name edit text.
363         fileNameEditText.setText(defaultFilePath);
364
365         // Move the cursor to the end of the default file path.
366         fileNameEditText.setSelection(defaultFilePath.length());
367
368         // Handle clicks on the browse button.
369         browseButton.setOnClickListener((View view) -> {
370             // Create the file picker intent.
371             Intent browseIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
372
373             // Set the intent MIME type to include all files so that everything is visible.
374             browseIntent.setType("*/*");
375
376             // Set the initial file name according to the type.
377             browseIntent.putExtra(Intent.EXTRA_TITLE, defaultFileName);
378
379             // Set the initial directory if the minimum API >= 26.
380             if (Build.VERSION.SDK_INT >= 26) {
381                 browseIntent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.getExternalStorageDirectory());
382             }
383
384             // Request a file that can be opened.
385             browseIntent.addCategory(Intent.CATEGORY_OPENABLE);
386
387             // Start the file picker.  This must be started under `activity` so that the request code is returned correctly.
388             activity.startActivityForResult(browseIntent, MainWebViewActivity.BROWSE_SAVE_WEBPAGE_REQUEST_CODE);
389         });
390
391         // Hide the storage permission text view if the permission has already been granted.
392         if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
393             storagePermissionTextView.setVisibility(View.GONE);
394         }
395
396         // Return the alert dialog.
397         return alertDialog;
398     }
399 }