]> gitweb.stoutner.com Git - PrivacyBrowserAndroid.git/blobdiff - app/src/main/java/com/stoutner/privacybrowser/activities/MainWebViewActivity.java
Create UltraList. https://redmine.stoutner.com/issues/450
[PrivacyBrowserAndroid.git] / app / src / main / java / com / stoutner / privacybrowser / activities / MainWebViewActivity.java
index 657a2706a6f17d7f1a1dee55ab13fa8a0bf58d6a..097c8af9b883b443710a0fd3375af767829ece81 100644 (file)
@@ -24,6 +24,7 @@ package com.stoutner.privacybrowser.activities;
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
+import android.app.Dialog;
 import android.app.DownloadManager;
 import android.app.SearchManager;
 import android.content.ActivityNotFoundException;
@@ -39,7 +40,6 @@ import android.content.res.Configuration;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
-import android.graphics.Canvas;
 import android.graphics.Typeface;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
@@ -50,6 +50,7 @@ import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
 import android.os.Handler;
+import android.os.Message;
 import android.preference.PreferenceManager;
 import android.print.PrintDocumentAdapter;
 import android.print.PrintManager;
@@ -117,6 +118,7 @@ import com.stoutner.privacybrowser.R;
 import com.stoutner.privacybrowser.adapters.WebViewPagerAdapter;
 import com.stoutner.privacybrowser.asynctasks.GetHostIpAddresses;
 import com.stoutner.privacybrowser.asynctasks.PopulateBlocklists;
+import com.stoutner.privacybrowser.asynctasks.SaveWebpageImage;
 import com.stoutner.privacybrowser.dialogs.AdConsentDialog;
 import com.stoutner.privacybrowser.dialogs.CreateBookmarkDialog;
 import com.stoutner.privacybrowser.dialogs.CreateBookmarkFolderDialog;
@@ -127,7 +129,9 @@ import com.stoutner.privacybrowser.dialogs.DownloadLocationPermissionDialog;
 import com.stoutner.privacybrowser.dialogs.EditBookmarkDialog;
 import com.stoutner.privacybrowser.dialogs.EditBookmarkFolderDialog;
 import com.stoutner.privacybrowser.dialogs.HttpAuthenticationDialog;
+import com.stoutner.privacybrowser.dialogs.SaveWebpageImageDialog;
 import com.stoutner.privacybrowser.dialogs.SslCertificateErrorDialog;
+import com.stoutner.privacybrowser.dialogs.StoragePermissionDialog;
 import com.stoutner.privacybrowser.dialogs.UrlHistoryDialog;
 import com.stoutner.privacybrowser.dialogs.ViewSslCertificateDialog;
 import com.stoutner.privacybrowser.fragments.WebViewTabFragment;
@@ -136,13 +140,13 @@ import com.stoutner.privacybrowser.helpers.BlocklistHelper;
 import com.stoutner.privacybrowser.helpers.BookmarksDatabaseHelper;
 import com.stoutner.privacybrowser.helpers.CheckPinnedMismatchHelper;
 import com.stoutner.privacybrowser.helpers.DomainsDatabaseHelper;
+import com.stoutner.privacybrowser.helpers.FileNameHelper;
 import com.stoutner.privacybrowser.helpers.OrbotProxyHelper;
 import com.stoutner.privacybrowser.views.NestedScrollWebView;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
-import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.MalformedURLException;
@@ -160,7 +164,8 @@ import java.util.Set;
 // AppCompatActivity from android.support.v7.app.AppCompatActivity must be used to have access to the SupportActionBar until the minimum API is >= 21.
 public class MainWebViewActivity extends AppCompatActivity implements CreateBookmarkDialog.CreateBookmarkListener, CreateBookmarkFolderDialog.CreateBookmarkFolderListener,
         DownloadFileDialog.DownloadFileListener, DownloadImageDialog.DownloadImageListener, DownloadLocationPermissionDialog.DownloadLocationPermissionDialogListener, EditBookmarkDialog.EditBookmarkListener,
-        EditBookmarkFolderDialog.EditBookmarkFolderListener, NavigationView.OnNavigationItemSelectedListener, PopulateBlocklists.PopulateBlocklistsListener, WebViewTabFragment.NewTabListener {
+        EditBookmarkFolderDialog.EditBookmarkFolderListener, NavigationView.OnNavigationItemSelectedListener, PopulateBlocklists.PopulateBlocklistsListener, SaveWebpageImageDialog.SaveWebpageImageListener,
+        StoragePermissionDialog.StoragePermissionDialogListener, WebViewTabFragment.NewTabListener {
 
     // `orbotStatus` is public static so it can be accessed from `OrbotProxyHelper`.  It is also used in `onCreate()`, `onResume()`, and `applyProxyThroughOrbot()`.
     public static String orbotStatus;
@@ -187,6 +192,9 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
     public final static int DOMAINS_WEBVIEW_DEFAULT_USER_AGENT = 2;
     public final static int DOMAINS_CUSTOM_USER_AGENT = 13;
 
+    // Start activity for result request codes.
+    private final int FILE_UPLOAD_REQUEST_CODE = 0;
+    public final static int BROWSE_SAVE_WEBPAGE_IMAGE_REQUEST_CODE = 1;
 
 
     // The current WebView is used in `onCreate()`, `onPrepareOptionsMenu()`, `onOptionsItemSelected()`, `onNavigationItemSelected()`, `onRestart()`, `onCreateContextMenu()`, `findPreviousOnPage()`,
@@ -207,6 +215,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
     private ArrayList<List<String[]>> easyPrivacy;
     private ArrayList<List<String[]>> fanboysAnnoyanceList;
     private ArrayList<List<String[]>> fanboysSocialList;
+    private ArrayList<List<String[]>> ultraList;
     private ArrayList<List<String[]>> ultraPrivacy;
 
     // `webViewDefaultUserAgent` is used in `onCreate()` and `onPrepareOptionsMenu()`.
@@ -295,9 +304,14 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
     // `downloadImageUrl` is used in `onCreateContextMenu()` and `onRequestPermissionResult()`.
     private String downloadImageUrl;
 
-    // The request codes are used in `onCreate()`, `onCreateContextMenu()`, `onCloseDownloadLocationPermissionDialog()`, `onRequestPermissionResult()`, and `initializeWebView()`.
+    // The save website image file path string is used in `onSaveWebpageImage()` and `onRequestPermissionResult()`
+    private String saveWebsiteImageFilePath;
+
+    // The permission result request codes are used in `onCreateContextMenu()`, `onCloseDownloadLocationPermissionDialog()`, `onRequestPermissionResult()`, `onSaveWebpageImage()`,
+    // `onCloseStoragePermissionDialog()`, and `initializeWebView()`.
     private final int DOWNLOAD_FILE_REQUEST_CODE = 1;
     private final int DOWNLOAD_IMAGE_REQUEST_CODE = 2;
+    private final int SAVE_WEBPAGE_IMAGE_REQUEST_CODE = 3;
 
     @Override
     // Remove the warning about needing to override `performClick()` when using an `OnTouchListener` with `WebView`.
@@ -712,6 +726,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         MenuItem easyPrivacyMenuItem = menu.findItem(R.id.easyprivacy);
         MenuItem fanboysAnnoyanceListMenuItem = menu.findItem(R.id.fanboys_annoyance_list);
         MenuItem fanboysSocialBlockingListMenuItem = menu.findItem(R.id.fanboys_social_blocking_list);
+        MenuItem ultraListMenuItem = menu.findItem(R.id.ultralist);
         MenuItem ultraPrivacyMenuItem = menu.findItem(R.id.ultraprivacy);
         MenuItem blockAllThirdPartyRequestsMenuItem = menu.findItem(R.id.block_all_third_party_requests);
         MenuItem fontSizeMenuItem = menu.findItem(R.id.font_size);
@@ -746,11 +761,12 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
             // Set the status of the menu item checkboxes.
             domStorageMenuItem.setChecked(currentWebView.getSettings().getDomStorageEnabled());
             saveFormDataMenuItem.setChecked(currentWebView.getSettings().getSaveFormData());  // Form data can be removed once the minimum API >= 26.
-            easyListMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_LIST));
-            easyPrivacyMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_PRIVACY));
+            easyListMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYLIST));
+            easyPrivacyMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYPRIVACY));
             fanboysAnnoyanceListMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.FANBOYS_ANNOYANCE_LIST));
             fanboysSocialBlockingListMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.FANBOYS_SOCIAL_BLOCKING_LIST));
-            ultraPrivacyMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRA_PRIVACY));
+            ultraListMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRALIST));
+            ultraPrivacyMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRAPRIVACY));
             blockAllThirdPartyRequestsMenuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.THIRD_PARTY_REQUESTS));
             swipeToRefreshMenuItem.setChecked(currentWebView.getSwipeToRefresh());
             wideViewportMenuItem.setChecked(currentWebView.getSettings().getUseWideViewPort());
@@ -759,11 +775,12 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
             // Initialize the display names for the blocklists with the number of blocked requests.
             blocklistsMenuItem.setTitle(getString(R.string.blocklists) + " - " + currentWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
-            easyListMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.EASY_LIST) + " - " + getString(R.string.easylist));
-            easyPrivacyMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.EASY_PRIVACY) + " - " + getString(R.string.easyprivacy));
+            easyListMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.EASYLIST) + " - " + getString(R.string.easylist));
+            easyPrivacyMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.EASYPRIVACY) + " - " + getString(R.string.easyprivacy));
             fanboysAnnoyanceListMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.FANBOYS_ANNOYANCE_LIST) + " - " + getString(R.string.fanboys_annoyance_list));
             fanboysSocialBlockingListMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.FANBOYS_SOCIAL_BLOCKING_LIST) + " - " + getString(R.string.fanboys_social_blocking_list));
-            ultraPrivacyMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.ULTRA_PRIVACY) + " - " + getString(R.string.ultraprivacy));
+            ultraListMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.ULTRALIST) + " - " + getString(R.string.ultralist));
+            ultraPrivacyMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.ULTRAPRIVACY) + " - " + getString(R.string.ultraprivacy));
             blockAllThirdPartyRequestsMenuItem.setTitle(currentWebView.getRequestsCount(NestedScrollWebView.THIRD_PARTY_REQUESTS) + " - " + getString(R.string.block_all_third_party_requests));
 
             // Only modify third-party cookies if the API >= 21.
@@ -917,23 +934,6 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
     // Remove Android Studio's warning about the dangers of using SetJavaScriptEnabled.
     @SuppressLint("SetJavaScriptEnabled")
     public boolean onOptionsItemSelected(MenuItem menuItem) {
-        // Reenter full screen browsing mode if it was interrupted by the options menu.  <https://redmine.stoutner.com/issues/389>
-        if (inFullScreenBrowsingMode) {
-            // Remove the translucent status flag.  This is necessary so the root frame layout can fill the entire screen.
-            getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
-
-            FrameLayout rootFrameLayout = findViewById(R.id.root_framelayout);
-
-            /* Hide the system bars.
-             * SYSTEM_UI_FLAG_FULLSCREEN hides the status bar at the top of the screen.
-             * SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN makes the root frame layout fill the area that is normally reserved for the status bar.
-             * SYSTEM_UI_FLAG_HIDE_NAVIGATION hides the navigation bar on the bottom or right of the screen.
-             * SYSTEM_UI_FLAG_IMMERSIVE_STICKY makes the status and navigation bars translucent and automatically re-hides them after they are shown.
-             */
-            rootFrameLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
-                    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
-        }
-
         // Get the selected menu item ID.
         int menuItemId = menuItem.getItemId();
 
@@ -963,6 +963,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.add_or_edit_domain:
@@ -1069,6 +1071,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Make it so.
                     startActivity(domainsIntent);
                 }
+
+                // Consume the event.
                 return true;
 
             case R.id.toggle_first_party_cookies:
@@ -1095,6 +1099,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.toggle_third_party_cookies:
@@ -1115,6 +1121,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Reload the current WebView.
                     currentWebView.reload();
                 } // Else do nothing because SDK < 21.
+
+                // Consume the event.
                 return true;
 
             case R.id.toggle_dom_storage:
@@ -1136,6 +1144,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             // Form data can be removed once the minimum API >= 26.
@@ -1158,6 +1168,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.clear_cookies:
@@ -1180,6 +1192,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                             }
                         })
                         .show();
+
+                // Consume the event.
                 return true;
 
             case R.id.clear_dom_storage:
@@ -1235,6 +1249,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                             }
                         })
                         .show();
+
+                // Consume the event.
                 return true;
 
             // Form data can be remove once the minimum API >= 26.
@@ -1255,28 +1271,34 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                             }
                         })
                         .show();
+
+                // Consume the event.
                 return true;
 
             case R.id.easylist:
                 // Toggle the EasyList status.
-                currentWebView.enableBlocklist(NestedScrollWebView.EASY_LIST, !currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_LIST));
+                currentWebView.enableBlocklist(NestedScrollWebView.EASYLIST, !currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYLIST));
 
                 // Update the menu checkbox.
-                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_LIST));
+                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYLIST));
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.easyprivacy:
                 // Toggle the EasyPrivacy status.
-                currentWebView.enableBlocklist(NestedScrollWebView.EASY_PRIVACY, !currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_PRIVACY));
+                currentWebView.enableBlocklist(NestedScrollWebView.EASYPRIVACY, !currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYPRIVACY));
 
                 // Update the menu checkbox.
-                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASY_PRIVACY));
+                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.EASYPRIVACY));
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.fanboys_annoyance_list:
@@ -1292,6 +1314,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.fanboys_social_blocking_list:
@@ -1303,17 +1327,34 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
+                return true;
+
+            case R.id.ultralist:
+                // Toggle the UltraList status.
+                currentWebView.enableBlocklist(NestedScrollWebView.ULTRALIST, !currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRALIST));
+
+                // Update the menu checkbox.
+                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRALIST));
+
+                // Reload the current WebView.
+                currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.ultraprivacy:
                 // Toggle the UltraPrivacy status.
-                currentWebView.enableBlocklist(NestedScrollWebView.ULTRA_PRIVACY, !currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRA_PRIVACY));
+                currentWebView.enableBlocklist(NestedScrollWebView.ULTRAPRIVACY, !currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRAPRIVACY));
 
                 // Update the menu checkbox.
-                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRA_PRIVACY));
+                menuItem.setChecked(currentWebView.isBlocklistEnabled(NestedScrollWebView.ULTRAPRIVACY));
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.block_all_third_party_requests:
@@ -1325,6 +1366,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_privacy_browser:
@@ -1333,6 +1376,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_webview_default:
@@ -1341,6 +1386,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_firefox_on_android:
@@ -1349,6 +1396,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_chrome_on_android:
@@ -1357,6 +1406,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_safari_on_ios:
@@ -1365,6 +1416,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_firefox_on_linux:
@@ -1373,6 +1426,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_chromium_on_linux:
@@ -1381,6 +1436,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_firefox_on_windows:
@@ -1389,6 +1446,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_chrome_on_windows:
@@ -1397,6 +1456,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_edge_on_windows:
@@ -1405,6 +1466,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_internet_explorer_on_windows:
@@ -1413,6 +1476,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_safari_on_macos:
@@ -1421,6 +1486,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.user_agent_custom:
@@ -1429,38 +1496,64 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the current WebView.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_twenty_five_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(25);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_fifty_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(50);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_seventy_five_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(75);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_one_hundred_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(100);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_one_hundred_twenty_five_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(125);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_one_hundred_fifty_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(150);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_one_hundred_seventy_five_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(175);
+
+                // Consume the event.
                 return true;
 
             case R.id.font_size_two_hundred_percent:
+                // Set the font size.
                 currentWebView.getSettings().setTextZoom(200);
+
+                // Consume the event.
                 return true;
 
             case R.id.swipe_to_refresh:
@@ -1478,11 +1571,15 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Disable the swipe refresh layout.
                     swipeRefreshLayout.setEnabled(false);
                 }
+
+                // Consume the event.
                 return true;
 
             case R.id.wide_viewport:
                 // Toggle the viewport.
                 currentWebView.getSettings().setUseWideViewPort(!currentWebView.getSettings().getUseWideViewPort());
+
+                // Consume the event.
                 return true;
 
             case R.id.display_images:
@@ -1496,6 +1593,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Enable loading of images.  Missing images will be loaded without the need for a reload.
                     currentWebView.getSettings().setLoadsImagesAutomatically(true);
                 }
+
+                // Consume the event.
                 return true;
 
             case R.id.night_mode:
@@ -1519,6 +1618,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Reload the website.
                 currentWebView.reload();
+
+                // Consume the event.
                 return true;
 
             case R.id.find_on_page:
@@ -1551,6 +1652,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Display the keyboard.  `0` sets no input flags.
                     inputMethodManager.showSoftInput(findOnPageEditText, 0);
                 }, 200);
+
+                // Consume the event.
                 return true;
 
             case R.id.print:
@@ -1565,42 +1668,18 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Print the document.
                 printManager.print(getString(R.string.privacy_browser_web_page), printDocumentAdapter, null);
+
+                // Consume the event.
                 return true;
 
             case R.id.save_as_image:
-                // Create a webpage bitmap.  Once the Minimum API >= 26 Bitmap.Config.RBGA_F16 can be used instead of ARGB_8888.
-                Bitmap webpageBitmap = Bitmap.createBitmap(currentWebView.getHorizontalScrollRange(), currentWebView.getVerticalScrollRange(), Bitmap.Config.ARGB_8888);
-
-                // Create a canvas.
-                Canvas webpageCanvas = new Canvas(webpageBitmap);
-
-                // Draw the current webpage onto the bitmap.
-                currentWebView.draw(webpageCanvas);
-
-                // Create a webpage PNG byte array output stream.
-                ByteArrayOutputStream webpageByteArrayOutputStream = new ByteArrayOutputStream();
-
-                // Convert the bitmap to a PNG.  `0` is for lossless compression (the only option for a PNG).
-                webpageBitmap.compress(Bitmap.CompressFormat.PNG, 0, webpageByteArrayOutputStream);
-
-                // Get a file for the image.
-                File imageFile = new File("/storage/emulated/0/webpage.png");
-
-                // Delete the current file if it exists.
-                if (imageFile.exists()) {
-                    //noinspection ResultOfMethodCallIgnored
-                    imageFile.delete();
-                }
+                // Instantiate the save webpage image dialog.
+                DialogFragment saveWebpageImageDialogFragment = new SaveWebpageImageDialog();
 
-                try {
-                    // Create an image file output stream.
-                    FileOutputStream imageFileOutputStream = new FileOutputStream(imageFile);
+                // Show the save webpage image dialog.
+                saveWebpageImageDialogFragment.show(getSupportFragmentManager(), getString(R.string.save_as_image));
 
-                    // Write the webpage image to the image file.
-                    webpageByteArrayOutputStream.writeTo(imageFileOutputStream);
-                } catch (Exception exception) {
-                    // Add a snackbar.
-                }
+                // Consume the event.
                 return true;
 
             case R.id.add_to_homescreen:
@@ -1610,6 +1689,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Show the create home screen shortcut dialog.
                 createHomeScreenShortcutDialogFragment.show(getSupportFragmentManager(), getString(R.string.create_shortcut));
+
+                // Consume the event.
                 return true;
 
             case R.id.view_source:
@@ -1622,6 +1703,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Make it so.
                 startActivity(viewSourceIntent);
+
+                // Consume the event.
                 return true;
 
             case R.id.share_url:
@@ -1635,14 +1718,22 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Make it so.
                 startActivity(Intent.createChooser(shareIntent, getString(R.string.share_url)));
+
+                // Consume the event.
                 return true;
 
             case R.id.open_with_app:
+                // Open the URL with an outside app.
                 openWithApp(currentWebView.getUrl());
+
+                // Consume the event.
                 return true;
 
             case R.id.open_with_browser:
+                // Open the URL with an outside browser.
                 openWithBrowser(currentWebView.getUrl());
+
+                // Consume the event.
                 return true;
 
             case R.id.proxy_through_orbot:
@@ -1651,6 +1742,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Apply the proxy through Orbot settings.
                 applyProxyThroughOrbot(true);
+
+                // Consume the event.
                 return true;
 
             case R.id.refresh:
@@ -1661,12 +1754,18 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Stop the loading of the WebView.
                     currentWebView.stopLoading();
                 }
+
+                // Consume the event.
                 return true;
 
             case R.id.ad_consent:
-                // Display the ad consent dialog.
+                // Instantiate the ad consent dialog.
                 DialogFragment adConsentDialogFragment = new AdConsentDialog();
+
+                // Display the ad consent dialog.
                 adConsentDialogFragment.show(getSupportFragmentManager(), getString(R.string.ad_consent));
+
+                // Consume the event.
                 return true;
 
             default:
@@ -1842,7 +1941,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                 // Create a string array for the blocklist versions.
                 String[] blocklistVersions = new String[] {easyList.get(0).get(0)[0], easyPrivacy.get(0).get(0)[0], fanboysAnnoyanceList.get(0).get(0)[0], fanboysSocialList.get(0).get(0)[0],
-                        ultraPrivacy.get(0).get(0)[0]};
+                        ultraList.get(0).get(0)[0], ultraPrivacy.get(0).get(0)[0]};
 
                 // Add the blocklist versions to the intent.
                 aboutIntent.putExtra("blocklist_versions", blocklistVersions);
@@ -1902,7 +2001,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         // Store the hit test result.
         final WebView.HitTestResult hitTestResult = currentWebView.getHitTestResult();
 
-        // Create the URL strings.
+        // Define the URL strings.
         final String imageUrl;
         final String linkUrl;
 
@@ -1928,19 +2027,25 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 menu.add(R.string.open_in_new_tab).setOnMenuItemClickListener((MenuItem item) -> {
                     // Load the link URL in a new tab.
                     addNewTab(linkUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add an Open with App entry.
                 menu.add(R.string.open_with_app).setOnMenuItemClickListener((MenuItem item) -> {
                     openWithApp(linkUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add an Open with Browser entry.
                 menu.add(R.string.open_with_browser).setOnMenuItemClickListener((MenuItem item) -> {
                     openWithBrowser(linkUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a Copy URL entry.
@@ -1950,7 +2055,9 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                     // Set the `ClipData` as the clipboard's primary clip.
                     clipboardManager.setPrimaryClip(srcAnchorTypeClipData);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a Download URL entry.
@@ -1985,7 +2092,9 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                             downloadFileDialogFragment.show(fragmentManager, getString(R.string.download));
                         }
                     }
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a Cancel entry, which by default closes the context menu.
@@ -2012,7 +2121,9 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                     // Make it so.
                     startActivity(emailIntent);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a Copy Email Address entry.
@@ -2022,16 +2133,17 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                     // Set the `ClipData` as the clipboard's primary clip.
                     clipboardManager.setPrimaryClip(srcEmailTypeClipData);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a `Cancel` entry, which by default closes the `ContextMenu`.
                 menu.add(R.string.cancel);
                 break;
 
-            // `IMAGE_TYPE` is an image. `SRC_IMAGE_ANCHOR_TYPE` is an image that is also a link.  Privacy Browser processes them the same.
+            // `IMAGE_TYPE` is an image.
             case WebView.HitTestResult.IMAGE_TYPE:
-            case WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE:
                 // Get the image URL.
                 imageUrl = hitTestResult.getExtra();
 
@@ -2042,16 +2154,21 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 menu.add(R.string.open_in_new_tab).setOnMenuItemClickListener((MenuItem item) -> {
                     // Load the image URL in a new tab.
                     addNewTab(imageUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add a View Image entry.
                 menu.add(R.string.view_image).setOnMenuItemClickListener(item -> {
+                    // Load the image in the current tab.
                     loadUrl(imageUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
-                // Add a `Download Image` entry.
+                // Add a Download Image entry.
                 menu.add(R.string.download_image).setOnMenuItemClickListener((MenuItem item) -> {
                     // Check if the download should be processed by an external app.
                     if (sharedPreferences.getBoolean("download_with_external_app", false)) {  // Download with an external app.
@@ -2070,7 +2187,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                                 // Show the download location permission alert dialog.  The permission will be requested when the dialog is closed.
                                 downloadLocationPermissionDialogFragment.show(fragmentManager, getString(R.string.download_location));
                             } else {  // Show the permission request directly.
-                                // Request the permission.  The download dialog will be launched by `onRequestPermissionResult().
+                                // Request the permission.  The download dialog will be launched by `onRequestPermissionResult()`.
                                 ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, DOWNLOAD_IMAGE_REQUEST_CODE);
                             }
                         } else {  // The storage permission has already been granted.
@@ -2081,32 +2198,149 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                             downloadImageDialogFragment.show(fragmentManager, getString(R.string.download));
                         }
                     }
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
-                // Add a `Copy URL` entry.
-                menu.add(R.string.copy_url).setOnMenuItemClickListener(item -> {
-                    // Save the image URL in a `ClipData`.
-                    ClipData srcImageAnchorTypeClipData = ClipData.newPlainText(getString(R.string.url), imageUrl);
+                // Add a Copy URL entry.
+                menu.add(R.string.copy_url).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Save the image URL in a clip data.
+                    ClipData imageTypeClipData = ClipData.newPlainText(getString(R.string.url), imageUrl);
 
-                    // Set the `ClipData` as the clipboard's primary clip.
-                    clipboardManager.setPrimaryClip(srcImageAnchorTypeClipData);
-                    return false;
+                    // Set the clip data as the clipboard's primary clip.
+                    clipboardManager.setPrimaryClip(imageTypeClipData);
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add an Open with App entry.
                 menu.add(R.string.open_with_app).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Open the image URL with an external app.
                     openWithApp(imageUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
                 // Add an Open with Browser entry.
                 menu.add(R.string.open_with_browser).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Open the image URL with an external browser.
                     openWithBrowser(imageUrl);
-                    return false;
+
+                    // Consume the event.
+                    return true;
                 });
 
-                // Add a `Cancel` entry, which by default closes the `ContextMenu`.
+                // Add a Cancel entry, which by default closes the context menu.
+                menu.add(R.string.cancel);
+                break;
+
+            // `SRC_IMAGE_ANCHOR_TYPE` is an image that is also a link.
+            case WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE:
+                // Get the image URL.
+                imageUrl = hitTestResult.getExtra();
+
+                // Instantiate a handler.
+                Handler handler = new Handler();
+
+                // Get a message from the handler.
+                Message message = handler.obtainMessage();
+
+                // Request the image details from the last touched node be returned in the message.
+                currentWebView.requestFocusNodeHref(message);
+
+                // Get the link URL from the message data.
+                linkUrl = message.getData().getString("url");
+
+                // Set the link URL as the title of the context menu.
+                menu.setHeaderTitle(linkUrl);
+
+                // Add an Open in New Tab entry.
+                menu.add(R.string.open_in_new_tab).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Load the link URL in a new tab.
+                    addNewTab(linkUrl);
+
+                    // Consume the event.
+                    return true;
+                });
+
+                // Add a View Image entry.
+                menu.add(R.string.view_image).setOnMenuItemClickListener((MenuItem item) -> {
+                   // View the image in the current tab.
+                   loadUrl(imageUrl);
+
+                   // Consume the event.
+                   return true;
+                });
+
+                // Add a Download Image entry.
+                menu.add(R.string.download_image).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Check if the download should be processed by an external app.
+                    if (sharedPreferences.getBoolean("download_with_external_app", false)) {  // Download with an external app.
+                        openUrlWithExternalApp(imageUrl);
+                    } else {  // Download with Android's download manager.
+                        // Check to see if the storage permission has already been granted.
+                        if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {  // The storage permission needs to be requested.
+                            // Store the image URL for use by `onRequestPermissionResult()`.
+                            downloadImageUrl = imageUrl;
+
+                            // Show a dialog if the user has previously denied the permission.
+                            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {  // Show a dialog explaining the request first.
+                                // Instantiate the download location permission alert dialog and set the download type to DOWNLOAD_IMAGE.
+                                DialogFragment downloadLocationPermissionDialogFragment = DownloadLocationPermissionDialog.downloadType(DownloadLocationPermissionDialog.DOWNLOAD_IMAGE);
+
+                                // Show the download location permission alert dialog.  The permission will be requested when the dialog is closed.
+                                downloadLocationPermissionDialogFragment.show(fragmentManager, getString(R.string.download_location));
+                            } else {  // Show the permission request directly.
+                                // Request the permission.  The download dialog will be launched by `onRequestPermissionResult()`.
+                                ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, DOWNLOAD_IMAGE_REQUEST_CODE);
+                            }
+                        } else {  // The storage permission has already been granted.
+                            // Get a handle for the download image alert dialog.
+                            DialogFragment downloadImageDialogFragment = DownloadImageDialog.imageUrl(imageUrl);
+
+                            // Show the download image alert dialog.
+                            downloadImageDialogFragment.show(fragmentManager, getString(R.string.download));
+                        }
+                    }
+
+                    // Consume the event.
+                    return true;
+                });
+
+                // Add a Copy URL entry.
+                menu.add(R.string.copy_url).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Save the link URL in a clip data.
+                    ClipData srcImageAnchorTypeClipData = ClipData.newPlainText(getString(R.string.url), linkUrl);
+
+                    // Set the clip data as the clipboard's primary clip.
+                    clipboardManager.setPrimaryClip(srcImageAnchorTypeClipData);
+
+                    // Consume the event.
+                    return true;
+                });
+
+                // Add an Open with App entry.
+                menu.add(R.string.open_with_app).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Open the link URL with an external app.
+                    openWithApp(linkUrl);
+
+                    // Consume the event.
+                    return true;
+                });
+
+                // Add an Open with Browser entry.
+                menu.add(R.string.open_with_browser).setOnMenuItemClickListener((MenuItem item) -> {
+                    // Open the link URL with an external browser.
+                    openWithBrowser(linkUrl);
+
+                    // Consume the event.
+                    return true;
+                });
+
+                // Add a cancel entry, which by default closes the context menu.
                 menu.add(R.string.cancel);
                 break;
         }
@@ -2378,6 +2612,20 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 // Reset the image URL variable.
                 downloadImageUrl = "";
                 break;
+
+            case SAVE_WEBPAGE_IMAGE_REQUEST_CODE:
+                // Check to see if the storage permission was granted.  If the dialog was canceled the grant result will be empty.
+                if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {  // The storage permission was granted.
+                    // Save the webpage image.
+                    new SaveWebpageImage(this, currentWebView).execute(saveWebsiteImageFilePath);
+                } else {  // The storage permission was not granted.
+                    // Display an error snackbar.
+                    Snackbar.make(currentWebView, getString(R.string.cannot_use_location), Snackbar.LENGTH_LONG).show();
+                }
+
+                // Reset the save website image file path.
+                saveWebsiteImageFilePath = "";
+                break;
         }
     }
 
@@ -2533,13 +2781,44 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         }
     }
 
-    // Process the results of an upload file chooser.  Currently there is only one `startActivityForResult` in this activity, so the request code, used to differentiate them, is ignored.
+    // Process the results of a file browse.
     @Override
     public void onActivityResult(int requestCode, int resultCode, Intent data) {
-        // File uploads only work on API >= 21.
-        if (Build.VERSION.SDK_INT >= 21) {
-            // Pass the file to the WebView.
-            fileChooserCallback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data));
+        // Run the commands that correlate to the specified request code.
+        switch (requestCode) {
+            case FILE_UPLOAD_REQUEST_CODE:
+                // File uploads only work on API >= 21.
+                if (Build.VERSION.SDK_INT >= 21) {
+                    // Pass the file to the WebView.
+                    fileChooserCallback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(resultCode, data));
+                }
+                break;
+
+            case BROWSE_SAVE_WEBPAGE_IMAGE_REQUEST_CODE:
+                // Don't do anything if the user pressed back from the file picker.
+                if (resultCode == Activity.RESULT_OK) {
+                    // Get a handle for the save dialog fragment.
+                    DialogFragment saveWebpageImageDialogFragment= (DialogFragment) getSupportFragmentManager().findFragmentByTag(getString(R.string.save_as_image));
+
+                    // Only update the file name if the dialog still exists.
+                    if (saveWebpageImageDialogFragment != null) {
+                        // Get a handle for the save webpage image dialog.
+                        Dialog saveWebpageImageDialog = saveWebpageImageDialogFragment.getDialog();
+
+                        // Get a handle for the file name edit text.
+                        EditText fileNameEditText = saveWebpageImageDialog.findViewById(R.id.file_name_edittext);
+
+                        // Instantiate the file name helper.
+                        FileNameHelper fileNameHelper = new FileNameHelper();
+
+                        // Convert the file name URI to a file name path.
+                        String fileNamePath = fileNameHelper.convertUriToFileNamePath(data.getData());
+
+                        // Set the file name path as the text of the file name edit text.
+                        fileNameEditText.setText(fileNamePath);
+                    }
+                }
+                break;
         }
     }
 
@@ -2664,6 +2943,54 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         inputMethodManager.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
     }
 
+    @Override
+    public void onSaveWebpageImage(DialogFragment dialogFragment) {
+        // Get a handle for the file name edit text.
+        EditText fileNameEditText = dialogFragment.getDialog().findViewById(R.id.file_name_edittext);
+
+        // Get the file path string.
+        saveWebsiteImageFilePath = fileNameEditText.getText().toString();
+
+        // Check to see if the storage permission is needed.
+        if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {  // The storage permission has been granted.
+            // Save the webpage image.
+            new SaveWebpageImage(this, currentWebView).execute(saveWebsiteImageFilePath);
+        } else {  // The storage permission has not been granted.
+            // Get the external private directory `File`.
+            File externalPrivateDirectoryFile = getExternalFilesDir(null);
+
+            // Remove the incorrect lint error below that the file might be null.
+            assert externalPrivateDirectoryFile != null;
+
+            // Get the external private directory string.
+            String externalPrivateDirectory = externalPrivateDirectoryFile.toString();
+
+            // Check to see if the file path is in the external private directory.
+            if (saveWebsiteImageFilePath.startsWith(externalPrivateDirectory)) {  // The file path is in the external private directory.
+                // Save the webpage image.
+                new SaveWebpageImage(this, currentWebView).execute(saveWebsiteImageFilePath);
+            } else {  // The file path is in a public directory.
+                // Check if the user has previously denied the storage permission.
+                if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {  // Show a dialog explaining the request first.
+                    // Instantiate the storage permission alert dialog.
+                    DialogFragment storagePermissionDialogFragment = new StoragePermissionDialog();
+
+                    // Show the storage permission alert dialog.  The permission will be requested when the dialog is closed.
+                    storagePermissionDialogFragment.show(getSupportFragmentManager(), getString(R.string.storage_permission));
+                } else {  // Show the permission request directly.
+                    // Request the write external storage permission.  The webpage image will be saved when it finishes.
+                    ActivityCompat.requestPermissions(this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, SAVE_WEBPAGE_IMAGE_REQUEST_CODE);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void onCloseStoragePermissionDialog() {
+        // Request the write external storage permission.  The webpage image will be saved when it finishes.
+        ActivityCompat.requestPermissions(this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, SAVE_WEBPAGE_IMAGE_REQUEST_CODE);
+    }
+
     private void applyAppSettings() {
         // Initialize the app if this is the first run.  This is done here instead of in `onCreate()` to shorten the time that an unthemed background is displayed on app startup.
         if (webViewDefaultUserAgent == null) {
@@ -3410,15 +3737,16 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 nestedScrollWebView.getSettings().setDomStorageEnabled(currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_DOM_STORAGE)) == 1);
                 // Form data can be removed once the minimum API >= 26.
                 boolean saveFormData = (currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_FORM_DATA)) == 1);
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASY_LIST,
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASYLIST,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_EASYLIST)) == 1);
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASY_PRIVACY,
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASYPRIVACY,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_EASYPRIVACY)) == 1);
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.FANBOYS_ANNOYANCE_LIST,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_FANBOYS_ANNOYANCE_LIST)) == 1);
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.FANBOYS_SOCIAL_BLOCKING_LIST,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_FANBOYS_SOCIAL_BLOCKING_LIST)) == 1);
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRA_PRIVACY,
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRALIST, currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ULTRALIST)) == 1);
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRAPRIVACY,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.ENABLE_ULTRAPRIVACY)) == 1);
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.THIRD_PARTY_REQUESTS,
                         currentDomainSettingsCursor.getInt(currentDomainSettingsCursor.getColumnIndex(DomainsDatabaseHelper.BLOCK_ALL_THIRD_PARTY_REQUESTS)) == 1);
@@ -3632,11 +3960,12 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 boolean defaultThirdPartyCookiesEnabled = sharedPreferences.getBoolean("third_party_cookies", false);
                 nestedScrollWebView.getSettings().setDomStorageEnabled(sharedPreferences.getBoolean("dom_storage", false));
                 boolean saveFormData = sharedPreferences.getBoolean("save_form_data", false);  // Form data can be removed once the minimum API >= 26.
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASY_LIST, sharedPreferences.getBoolean("easylist", true));
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASY_PRIVACY, sharedPreferences.getBoolean("easyprivacy", true));
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASYLIST, sharedPreferences.getBoolean("easylist", true));
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.EASYPRIVACY, sharedPreferences.getBoolean("easyprivacy", true));
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.FANBOYS_ANNOYANCE_LIST, sharedPreferences.getBoolean("fanboys_annoyance_list", true));
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.FANBOYS_SOCIAL_BLOCKING_LIST, sharedPreferences.getBoolean("fanboys_social_blocking_list", true));
-                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRA_PRIVACY, sharedPreferences.getBoolean("ultraprivacy", true));
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRALIST, sharedPreferences.getBoolean("ultralist", true));
+                nestedScrollWebView.enableBlocklist(NestedScrollWebView.ULTRAPRIVACY, sharedPreferences.getBoolean("ultraprivacy", true));
                 nestedScrollWebView.enableBlocklist(NestedScrollWebView.THIRD_PARTY_REQUESTS, sharedPreferences.getBoolean("block_all_third_party_requests", false));
                 nestedScrollWebView.setNightMode(sharedPreferences.getBoolean("night_mode", false));
 
@@ -4025,8 +4354,13 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         // Flag the intent to open in a new task.
         openWithAppIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 
-        // Show the chooser.
-        startActivity(openWithAppIntent);
+        try {
+            // Show the chooser.
+            startActivity(openWithAppIntent);
+        } catch (ActivityNotFoundException exception) {
+            // Show a snackbar with the error.
+            Snackbar.make(currentWebView, getString(R.string.error) + "  " + exception, Snackbar.LENGTH_INDEFINITE).show();
+        }
     }
 
     private void openWithBrowser(String url) {
@@ -4039,8 +4373,13 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         // Flag the intent to open in a new task.
         openWithBrowserIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 
-        // Show the chooser.
-        startActivity(openWithBrowserIntent);
+        try {
+            // Show the chooser.
+            startActivity(openWithBrowserIntent);
+        } catch (ActivityNotFoundException exception) {
+            // Show a snackbar with the error.
+            Snackbar.make(currentWebView, getString(R.string.error) + "  " + exception, Snackbar.LENGTH_INDEFINITE).show();
+        }
     }
 
     private String sanitizeUrl(String url) {
@@ -4088,7 +4427,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
         easyPrivacy = combinedBlocklists.get(1);
         fanboysAnnoyanceList = combinedBlocklists.get(2);
         fanboysSocialList = combinedBlocklists.get(3);
-        ultraPrivacy = combinedBlocklists.get(4);
+        ultraList = combinedBlocklists.get(4);
+        ultraPrivacy = combinedBlocklists.get(5);
 
         // Add the first tab.
         addNewTab("");
@@ -4661,13 +5001,26 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
             }
         });
 
-        // Update the status of swipe to refresh based on the scroll position of the nested scroll WebView.
+        // Update the status of swipe to refresh based on the scroll position of the nested scroll WebView.  Also reinforce full screen browsing mode.
         // Once the minimum API >= 23 this can be replaced with `nestedScrollWebView.setOnScrollChangeListener()`.
         nestedScrollWebView.getViewTreeObserver().addOnScrollChangedListener(() -> {
             if (nestedScrollWebView.getSwipeToRefresh()) {
                 // Only enable swipe to refresh if the WebView is scrolled to the top.
                 swipeRefreshLayout.setEnabled(nestedScrollWebView.getScrollY() == 0);
             }
+
+            // Reinforce the system UI visibility flags if in full screen browsing mode.
+            // This hides the status and navigation bars, which are displayed if other elements are shown, like dialog boxes, the options menu, or the keyboard.
+            if (inFullScreenBrowsingMode) {
+                /* Hide the system bars.
+                 * SYSTEM_UI_FLAG_FULLSCREEN hides the status bar at the top of the screen.
+                 * SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN makes the root frame layout fill the area that is normally reserved for the status bar.
+                 * SYSTEM_UI_FLAG_HIDE_NAVIGATION hides the navigation bar on the bottom or right of the screen.
+                 * SYSTEM_UI_FLAG_IMMERSIVE_STICKY makes the status and navigation bars translucent and automatically re-hides them after they are shown.
+                 */
+                rootFrameLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+                        View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
+            }
         });
 
         // Set the web chrome client.
@@ -4903,8 +5256,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     // Create an intent to open a chooser based ont the file chooser parameters.
                     Intent fileChooserIntent = fileChooserParams.createIntent();
 
-                    // Open the file chooser.  Currently only one `startActivityForResult` exists in this activity, so the request code, used to differentiate them, is simply `0`.
-                    startActivityForResult(fileChooserIntent, 0);
+                    // Open the file chooser.
+                    startActivityForResult(fileChooserIntent, FILE_UPLOAD_REQUEST_CODE);
                 }
                 return true;
             }
@@ -5093,8 +5446,48 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                     return emptyWebResourceResponse;
                 }
 
+                // Check UltraList if it is enabled.
+                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.ULTRALIST)) {
+                    // Check the URL against UltraList.
+                    String[] ultraListResults = blocklistHelper.checkBlocklist(currentDomain, url, isThirdPartyRequest, ultraList);
+
+                    // Process the UltraList results.
+                    if (ultraListResults[0].equals(BlocklistHelper.REQUEST_BLOCKED)) {  // The resource request matched UltraLists's blacklist.
+                        // Add the result to the resource requests.
+                        nestedScrollWebView.addResourceRequest(new String[] {ultraListResults[0], ultraListResults[1], ultraListResults[2], ultraListResults[3], ultraListResults[4], ultraListResults[5]});
+
+                        // Increment the blocked requests counters.
+                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS);
+                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.ULTRALIST);
+
+                        // Update the titles of the blocklist menu items if the WebView is currently displayed.
+                        if (webViewDisplayed) {
+                            // Updating the UI must be run from the UI thread.
+                            activity.runOnUiThread(() -> {
+                                // Update the menu item titles.
+                                navigationRequestsMenuItem.setTitle(getString(R.string.requests) + " - " + nestedScrollWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
+
+                                // Update the options menu if it has been populated.
+                                if (optionsMenu != null) {
+                                    optionsMenu.findItem(R.id.blocklists).setTitle(getString(R.string.blocklists) + " - " + nestedScrollWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
+                                    optionsMenu.findItem(R.id.ultralist).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.ULTRALIST) + " - " + getString(R.string.ultralist));
+                                }
+                            });
+                        }
+
+                        // The resource request was blocked.  Return an empty web resource response.
+                        return emptyWebResourceResponse;
+                    } else if (ultraListResults[0].equals(BlocklistHelper.REQUEST_ALLOWED)) {  // The resource request matched UltraList's whitelist.
+                        // Add a whitelist entry to the resource requests array.
+                        nestedScrollWebView.addResourceRequest(new String[] {ultraListResults[0], ultraListResults[1], ultraListResults[2], ultraListResults[3], ultraListResults[4], ultraListResults[5]});
+
+                        // The resource request has been allowed by UltraPrivacy.  `return null` loads the requested resource.
+                        return null;
+                    }
+                }
+
                 // Check UltraPrivacy if it is enabled.
-                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.ULTRA_PRIVACY)) {
+                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.ULTRAPRIVACY)) {
                     // Check the URL against UltraPrivacy.
                     String[] ultraPrivacyResults = blocklistHelper.checkBlocklist(currentDomain, url, isThirdPartyRequest, ultraPrivacy);
 
@@ -5106,7 +5499,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                         // Increment the blocked requests counters.
                         nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS);
-                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.ULTRA_PRIVACY);
+                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.ULTRAPRIVACY);
 
                         // Update the titles of the blocklist menu items if the WebView is currently displayed.
                         if (webViewDisplayed) {
@@ -5118,7 +5511,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                                 // Update the options menu if it has been populated.
                                 if (optionsMenu != null) {
                                     optionsMenu.findItem(R.id.blocklists).setTitle(getString(R.string.blocklists) + " - " + nestedScrollWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
-                                    optionsMenu.findItem(R.id.ultraprivacy).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.ULTRA_PRIVACY) + " - " + getString(R.string.ultraprivacy));
+                                    optionsMenu.findItem(R.id.ultraprivacy).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.ULTRAPRIVACY) + " - " + getString(R.string.ultraprivacy));
                                 }
                             });
                         }
@@ -5136,7 +5529,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 }
 
                 // Check EasyList if it is enabled.
-                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.EASY_LIST)) {
+                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.EASYLIST)) {
                     // Check the URL against EasyList.
                     String[] easyListResults = blocklistHelper.checkBlocklist(currentDomain, url, isThirdPartyRequest, easyList);
 
@@ -5147,7 +5540,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                         // Increment the blocked requests counters.
                         nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS);
-                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.EASY_LIST);
+                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.EASYLIST);
 
                         // Update the titles of the blocklist menu items if the WebView is currently displayed.
                         if (webViewDisplayed) {
@@ -5159,7 +5552,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                                 // Update the options menu if it has been populated.
                                 if (optionsMenu != null) {
                                     optionsMenu.findItem(R.id.blocklists).setTitle(getString(R.string.blocklists) + " - " + nestedScrollWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
-                                    optionsMenu.findItem(R.id.easylist).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.EASY_LIST) + " - " + getString(R.string.easylist));
+                                    optionsMenu.findItem(R.id.easylist).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.EASYLIST) + " - " + getString(R.string.easylist));
                                 }
                             });
                         }
@@ -5173,7 +5566,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                 }
 
                 // Check EasyPrivacy if it is enabled.
-                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.EASY_PRIVACY)) {
+                if (nestedScrollWebView.isBlocklistEnabled(NestedScrollWebView.EASYPRIVACY)) {
                     // Check the URL against EasyPrivacy.
                     String[] easyPrivacyResults = blocklistHelper.checkBlocklist(currentDomain, url, isThirdPartyRequest, easyPrivacy);
 
@@ -5185,7 +5578,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
 
                         // Increment the blocked requests counters.
                         nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS);
-                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.EASY_PRIVACY);
+                        nestedScrollWebView.incrementRequestsCount(NestedScrollWebView.EASYPRIVACY);
 
                         // Update the titles of the blocklist menu items if the WebView is currently displayed.
                         if (webViewDisplayed) {
@@ -5197,7 +5590,7 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook
                                 // Update the options menu if it has been populated.
                                 if (optionsMenu != null) {
                                     optionsMenu.findItem(R.id.blocklists).setTitle(getString(R.string.blocklists) + " - " + nestedScrollWebView.getRequestsCount(NestedScrollWebView.BLOCKED_REQUESTS));
-                                    optionsMenu.findItem(R.id.easyprivacy).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.EASY_PRIVACY) + " - " + getString(R.string.easyprivacy));
+                                    optionsMenu.findItem(R.id.easyprivacy).setTitle(nestedScrollWebView.getRequestsCount(NestedScrollWebView.EASYPRIVACY) + " - " + getString(R.string.easyprivacy));
                                 }
                             });
                         }